diff --git a/examples/payroll/.gitignore b/examples/payroll/.gitignore new file mode 100644 index 00000000..ad89474b --- /dev/null +++ b/examples/payroll/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/examples/payroll/README.md b/examples/payroll/README.md new file mode 100644 index 00000000..872a0722 --- /dev/null +++ b/examples/payroll/README.md @@ -0,0 +1,176 @@ +# Arkade Payroll Example + +A minimal web-based payroll tool built with the [Arkade SDK](https://github.com/arkade-os/ts-sdk) demonstrating how to send payments to multiple recipients using Arkade virtual outputs (VTXOs). + +## Features + +### Assistant View (Read-only Access) +- Create payroll batches with multiple recipients +- Add/remove recipients using +/- buttons +- Import recipients from CSV file (Address,Amount format) +- Submit payrolls for admin approval + +### Admin View +- View all pending payrolls +- Check wallet balance before execution +- Approve and execute payrolls by signing with private key +- Fund wallet via USDT on Ethereum using [Lendaswap SDK](https://github.com/lendasat/lendaswap-sdk) + +## Getting Started + +### Prerequisites +- Node.js >= 20.0.0 +- pnpm (recommended) or npm + +### Installation + +```bash +# From the ts-sdk root directory +cd examples/payroll +pnpm install +``` + +### Development + +```bash +pnpm dev +``` + +Open http://localhost:5173 in your browser. + +### Build + +```bash +pnpm build +``` + +## CSV Format + +Import recipients using CSV with the following format: + +```csv +Address,Amount,Name +ark1q...,100000,Alice +ark1q...,200000,Bob +``` + +- **Address**: Arkade address (required) +- **Amount**: Amount in satoshis (required) +- **Name**: Optional recipient name + +## Architecture + +``` +src/ +├── components/ +│ ├── PayrollForm.tsx # Assistant: Create payrolls +│ ├── PendingPayrolls.tsx # Admin: View and execute payrolls +│ └── FundingPanel.tsx # Admin: Fund via Lendaswap +├── services/ +│ ├── payroll.ts # Core payroll service (Arkade SDK) +│ └── lendaswap.ts # USDT-to-BTC swap service +├── types/ +│ └── index.ts # TypeScript interfaces +├── utils/ +│ └── csv.ts # CSV parsing utilities +├── App.tsx # Main application +└── main.tsx # Entry point +``` + +## How It Works + +### Creating a Payroll (Assistant) + +1. Enter a payroll name +2. Add recipients manually or import from CSV +3. Review total amount and recipient count +4. Submit payroll (creates "pending" status) + +### Executing a Payroll (Admin) + +1. Switch to Admin view +2. Click on a pending payroll to expand details +3. Click "Execute Payroll" +4. Enter your private key (hex format) +5. Check wallet balance +6. Click "Sign & Execute" + +The Arkade SDK handles: +- Selecting available VTXOs as inputs +- Building the transaction with multiple outputs +- Signing with your private key +- Submitting to the Ark server +- Finalizing checkpoint transactions + +### Funding via Lendaswap + +1. Click "Fund Wallet" in Admin view +2. Enter USDT amount to swap +3. Get a quote showing BTC equivalent +4. Create swap order +5. Send USDT to the HTLC contract on Ethereum +6. Confirm deposit and claim BTC on Arkade + +## SDK Integration Points + +### Arkade SDK + +```typescript +import { Wallet, SingleKey, RestArkProvider, RestIndexerProvider, EsploraProvider } from "@arkade-os/sdk"; + +// Create wallet from private key +const identity = new SingleKey(privateKeyHex); +const wallet = await Wallet.create({ + identity, + arkProvider: new RestArkProvider(arkServerUrl), + indexerProvider: new RestIndexerProvider(indexerUrl), + onchainProvider: new EsploraProvider(esploraUrl), +}); + +// Check balance +const balance = await wallet.getBalance(); + +// Send to single recipient +const txId = await wallet.sendBitcoin({ + address: recipientAddress, + amount: amountSats, +}); + +// Send to multiple recipients via settle +const txId = await wallet.settle({ + inputs: [...vtxos, ...boardingUtxos], + outputs: recipients.map(r => ({ + address: r.address, + amount: r.amount, + })), +}); +``` + +### Lendaswap SDK + +```typescript +import { Client } from "@lendasat/lendaswap-sdk"; + +// Get quote for USDT -> BTC +const quote = await client.getQuote('usdt_eth', 'btc_arkade', usdtAmount); + +// Create swap order +const swap = await client.createEvmToArkadeSwap({ + user_address: ethereumAddress, + source_token: 'usdt_eth', +}, 'ethereum'); + +// Claim BTC after USDT deposit +await client.claimVhtlc(swap.swapId); +``` + +## Security Notes + +- Private keys are used locally for signing and never transmitted +- Payroll data is stored in browser localStorage +- Always verify recipient addresses before executing +- Use testnet for development and testing + +## License + +MIT diff --git a/examples/payroll/index.html b/examples/payroll/index.html new file mode 100644 index 00000000..84c905f8 --- /dev/null +++ b/examples/payroll/index.html @@ -0,0 +1,17 @@ + + + + + + + Arkade Payroll + + +
+ + + diff --git a/examples/payroll/package.json b/examples/payroll/package.json new file mode 100644 index 00000000..13cb894b --- /dev/null +++ b/examples/payroll/package.json @@ -0,0 +1,26 @@ +{ + "name": "@arkade-examples/payroll", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "format": "prettier --write \"src/**/*.{ts,tsx}\"" + }, + "dependencies": { + "@arkade-os/sdk": "file:../..", + "@lendasat/lendaswap-sdk": "^0.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "prettier": "^3.4.2", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } +} diff --git a/examples/payroll/src/App.css b/examples/payroll/src/App.css new file mode 100644 index 00000000..16951a14 --- /dev/null +++ b/examples/payroll/src/App.css @@ -0,0 +1,1005 @@ +/* Base styles */ +:root { + --color-bg: #0f0f0f; + --color-surface: #1a1a1a; + --color-surface-2: #242424; + --color-border: #333; + --color-text: #e5e5e5; + --color-text-muted: #888; + --color-primary: #f7931a; + --color-primary-hover: #ffa630; + --color-success: #10b981; + --color-error: #ef4444; + --color-warning: #f59e0b; + --color-info: #3b82f6; + --radius: 8px; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + background-color: var(--color-bg); + color: var(--color-text); + line-height: 1.6; +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ +.app-header { + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header-content h1 { + font-size: 1.5rem; + color: var(--color-primary); + margin-bottom: 0.25rem; +} + +.header-content .subtitle { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.role-switcher { + display: flex; + gap: 0.5rem; +} + +.role-switcher button { + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + background: var(--color-surface-2); + color: var(--color-text); + cursor: pointer; + border-radius: var(--radius); + transition: all 0.2s; +} + +.role-switcher button:hover { + background: var(--color-border); +} + +.role-switcher button.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: #000; +} + +/* Main content */ +.app-main { + flex: 1; + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +/* Form styles */ +.payroll-form, +.pending-payrolls, +.funding-panel { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.payroll-form h2, +.pending-payrolls h2, +.funding-panel h3 { + margin-bottom: 0.5rem; +} + +.description { + color: var(--color-text-muted); + margin-bottom: 1.5rem; + font-size: 0.875rem; +} + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--color-text); +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface-2); + color: var(--color-text); + font-size: 1rem; +} + +.form-group input:focus { + outline: none; + border-color: var(--color-primary); +} + +.input-hint { + font-size: 0.75rem; + color: var(--color-text-muted); + margin-top: 0.25rem; +} + +/* Recipients list */ +.recipients-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.recipient-row { + display: grid; + grid-template-columns: 1fr 2fr 120px 40px; + gap: 0.5rem; + margin-bottom: 0.5rem; + align-items: center; +} + +.recipient-row input { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface-2); + color: var(--color-text); + font-size: 0.875rem; +} + +.recipient-row input:focus { + outline: none; + border-color: var(--color-primary); +} + +.btn-remove { + width: 36px; + height: 36px; + border: 1px solid var(--color-error); + background: transparent; + color: var(--color-error); + border-radius: var(--radius); + cursor: pointer; + font-size: 1.25rem; + font-weight: bold; + transition: all 0.2s; +} + +.btn-remove:hover { + background: var(--color-error); + color: white; +} + +.btn-add { + padding: 0.5rem 1rem; + border: 1px dashed var(--color-border); + background: transparent; + color: var(--color-text-muted); + border-radius: var(--radius); + cursor: pointer; + width: 100%; + margin-top: 0.5rem; + transition: all 0.2s; +} + +.btn-add:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.btn-csv { + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + background: var(--color-surface-2); + color: var(--color-text); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; +} + +.btn-csv:hover { + background: var(--color-border); +} + +/* Form footer */ +.form-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +.total { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.total strong { + color: var(--color-primary); + font-size: 1.125rem; +} + +.recipient-count { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.btn-submit, +.btn-primary { + padding: 0.75rem 1.5rem; + background: var(--color-primary); + color: #000; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-weight: 600; + font-size: 1rem; + transition: all 0.2s; +} + +.btn-submit:hover, +.btn-primary:hover { + background: var(--color-primary-hover); +} + +.btn-submit:disabled, +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Errors */ +.errors { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--color-error); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1rem; +} + +.error { + color: var(--color-error); + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.error:last-child { + margin-bottom: 0; +} + +.error-message { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--color-error); + border-radius: var(--radius); + padding: 0.75rem; + color: var(--color-error); + font-size: 0.875rem; + margin-top: 1rem; +} + +/* Success message */ +.success-message { + background: rgba(16, 185, 129, 0.1); + border: 1px solid var(--color-success); + border-radius: var(--radius); + padding: 1rem; + color: var(--color-success); + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.btn-dismiss { + padding: 0.25rem 0.75rem; + border: 1px solid var(--color-success); + background: transparent; + color: var(--color-success); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.75rem; +} + +/* Info box */ +.info-box { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 1.5rem; +} + +.info-box h3 { + margin-bottom: 1rem; + color: var(--color-text); +} + +.info-box ol { + padding-left: 1.25rem; + color: var(--color-text-muted); +} + +.info-box li { + margin-bottom: 0.75rem; +} + +.info-box li strong { + color: var(--color-text); +} + +/* Payroll cards */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.stats { + display: flex; + gap: 1rem; +} + +.stat { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.stat strong { + color: var(--color-text); +} + +.stat.total strong { + color: var(--color-primary); +} + +.filter-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.filter-tabs button { + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-muted); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; +} + +.filter-tabs button:hover { + background: var(--color-surface-2); +} + +.filter-tabs button.active { + background: var(--color-surface-2); + color: var(--color-text); + border-color: var(--color-primary); +} + +.empty-state { + text-align: center; + padding: 3rem; + color: var(--color-text-muted); +} + +.payroll-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.payroll-card { + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius); + overflow: hidden; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + cursor: pointer; + transition: background 0.2s; +} + +.card-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.card-info h3 { + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.card-meta { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.card-status { + display: flex; + align-items: center; + gap: 1rem; +} + +.status-badge { + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + text-transform: uppercase; + color: white; +} + +.card-amount { + color: var(--color-primary); +} + +.card-details { + padding: 1rem; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.recipients-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.recipients-table th, +.recipients-table td { + padding: 0.5rem; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.recipients-table th { + color: var(--color-text-muted); + font-weight: 500; +} + +.recipients-table .address { + font-family: monospace; + font-size: 0.75rem; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; +} + +.tx-info { + background: var(--color-surface-2); + padding: 0.75rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.tx-info code { + font-size: 0.75rem; + background: var(--color-bg); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.error-info { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--color-error); + padding: 0.75rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.875rem; + color: var(--color-error); +} + +.card-actions { + display: flex; + gap: 0.5rem; +} + +.btn-execute { + padding: 0.5rem 1rem; + background: var(--color-primary); + color: #000; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.btn-execute:hover { + background: var(--color-primary-hover); +} + +.btn-delete { + padding: 0.5rem 1rem; + background: transparent; + color: var(--color-error); + border: 1px solid var(--color-error); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; +} + +.btn-delete:hover { + background: var(--color-error); + color: white; +} + +.btn-reset { + padding: 0.5rem 1rem; + background: transparent; + color: var(--color-warning); + border: 1px solid var(--color-warning); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; +} + +.btn-reset:hover { + background: var(--color-warning); + color: #000; +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.modal-header h3 { + font-size: 1.125rem; +} + +.btn-close { + background: none; + border: none; + color: var(--color-text-muted); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; +} + +.btn-close:hover { + color: var(--color-text); +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--color-border); +} + +.btn-cancel { + padding: 0.75rem 1.5rem; + background: transparent; + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; +} + +.btn-cancel:hover { + background: var(--color-surface-2); +} + +.btn-confirm { + padding: 0.75rem 1.5rem; + background: var(--color-success); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.btn-confirm:hover { + opacity: 0.9; +} + +.btn-confirm:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Payroll summary in modal */ +.payroll-summary { + background: var(--color-surface-2); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1.5rem; +} + +.summary-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.summary-row:last-child { + margin-bottom: 0; +} + +.input-key { + font-family: monospace; +} + +.btn-check-balance { + width: 100%; + padding: 0.75rem; + background: var(--color-surface-2); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; +} + +.btn-check-balance:hover { + background: var(--color-border); +} + +.btn-check-balance:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Balance info */ +.balance-info { + background: var(--color-surface-2); + border-radius: var(--radius); + padding: 1rem; + margin-top: 1rem; +} + +.balance-info h4 { + margin-bottom: 0.75rem; + font-size: 0.875rem; +} + +.wallet-address { + font-size: 0.75rem; + margin-bottom: 0.75rem; + display: flex; + gap: 0.5rem; +} + +.wallet-address code { + background: var(--color-bg); + padding: 0.25rem 0.5rem; + border-radius: 4px; + overflow: hidden; + text-overflow: ellipsis; +} + +.balance-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.balance-item { + display: flex; + justify-content: space-between; + font-size: 0.875rem; + padding: 0.25rem 0; +} + +.balance-item .sufficient { + color: var(--color-success); +} + +.balance-item .insufficient { + color: var(--color-error); +} + +.insufficient-warning { + margin-top: 0.75rem; + padding: 0.75rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--color-error); + border-radius: var(--radius); + color: var(--color-error); + font-size: 0.875rem; +} + +/* Admin actions */ +.admin-actions { + margin-bottom: 1rem; +} + +.btn-funding { + padding: 0.75rem 1.5rem; + background: var(--color-info); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.btn-funding:hover { + opacity: 0.9; +} + +.btn-funding.active { + background: var(--color-surface-2); + color: var(--color-info); + border: 1px solid var(--color-info); +} + +/* Funding panel */ +.funding-panel { + margin-bottom: 1.5rem; +} + +.funding-hint { + background: var(--color-surface-2); + padding: 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.funding-hint strong { + color: var(--color-primary); +} + +.usd-equiv { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.quote-result { + background: var(--color-surface-2); + border-radius: var(--radius); + padding: 1rem; + margin-top: 1rem; +} + +.quote-result h4 { + margin-bottom: 0.75rem; +} + +.quote-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.quote-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.quote-item span { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.quote-item strong { + color: var(--color-success); +} + +.step-content { + padding-top: 1rem; +} + +.step-content h4 { + margin-bottom: 0.5rem; +} + +.step-content p { + color: var(--color-text-muted); + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.deposit-instructions { + background: var(--color-surface-2); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1rem; +} + +.instruction-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.75rem; +} + +.instruction-item:last-child { + margin-bottom: 0; +} + +.instruction-item span { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.instruction-item code { + font-size: 0.75rem; + background: var(--color-bg); + padding: 0.25rem 0.5rem; + border-radius: 4px; + word-break: break-all; +} + +.instruction-item code.small { + font-size: 0.625rem; +} + +.claim-info { + background: var(--color-surface-2); + padding: 0.75rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.claim-info code { + font-size: 0.75rem; + display: block; + margin-top: 0.25rem; + background: var(--color-bg); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.step-content.success { + text-align: center; + padding: 2rem 1rem; +} + +.step-content.success h4 { + color: var(--color-success); + font-size: 1.25rem; +} + +.btn-secondary { + padding: 0.75rem 1.5rem; + background: var(--color-surface-2); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; + margin-top: 1rem; +} + +.btn-secondary:hover { + background: var(--color-border); +} + +/* Footer */ +.app-footer { + padding: 1rem 2rem; + border-top: 1px solid var(--color-border); + text-align: center; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.app-footer a { + color: var(--color-primary); + text-decoration: none; +} + +.app-footer a:hover { + text-decoration: underline; +} + +/* Responsive */ +@media (max-width: 768px) { + .app-header { + flex-direction: column; + gap: 1rem; + text-align: center; + } + + .recipient-row { + grid-template-columns: 1fr; + } + + .form-footer { + flex-direction: column; + gap: 1rem; + } + + .filter-tabs { + flex-wrap: wrap; + } + + .card-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .card-status { + width: 100%; + justify-content: space-between; + } + + .balance-grid, + .quote-grid { + grid-template-columns: 1fr; + } +} diff --git a/examples/payroll/src/App.tsx b/examples/payroll/src/App.tsx new file mode 100644 index 00000000..55dc2b90 --- /dev/null +++ b/examples/payroll/src/App.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import type { PayrollBatch, UserRole } from "./types"; +import { PayrollForm } from "./components/PayrollForm"; +import { PendingPayrolls } from "./components/PendingPayrolls"; +import { FundingPanel } from "./components/FundingPanel"; +import "./App.css"; + +function App() { + const [role, setRole] = useState("assistant"); + const [refreshKey, setRefreshKey] = useState(0); + const [showFunding, setShowFunding] = useState(false); + const [recentBatch, setRecentBatch] = useState(null); + + const handlePayrollCreated = (batch: PayrollBatch) => { + setRecentBatch(batch); + setRefreshKey((k) => k + 1); + }; + + return ( +
+
+
+

Arkade Payroll

+

+ Send payments to multiple recipients on Arkade +

+
+ +
+ + +
+
+ +
+ {role === "assistant" && ( +
+ + + {recentBatch && ( +
+ Payroll "{recentBatch.name}" created + successfully with{" "} + {recentBatch.recipients.length} recipients. + +
+ )} + +
+

How it works

+
    +
  1. + Create a payroll by adding + recipients manually or importing a CSV file. +
  2. +
  3. + Payroll is submitted for + admin approval (pending status). +
  4. +
  5. + Admin reviews and executes{" "} + the payroll by signing with their private + key. +
  6. +
  7. + Recipients receive BTC on + Arkade as new virtual outputs (VTXOs). +
  8. +
+
+
+ )} + + {role === "admin" && ( +
+
+ +
+ + {showFunding && ( + setRefreshKey((k) => k + 1)} + /> + )} + + +
+ )} +
+ + +
+ ); +} + +export default App; diff --git a/examples/payroll/src/components/FundingPanel.tsx b/examples/payroll/src/components/FundingPanel.tsx new file mode 100644 index 00000000..20bae84c --- /dev/null +++ b/examples/payroll/src/components/FundingPanel.tsx @@ -0,0 +1,310 @@ +import { useState } from "react"; +import { + lendaswapService, + type SwapQuote, + type SwapOrder, +} from "../services/lendaswap"; +import { formatAmount } from "../utils/csv"; + +interface FundingPanelProps { + arkadeAddress: string; + requiredAmount: number; + onFunded?: () => void; +} + +export function FundingPanel({ + arkadeAddress, + requiredAmount, + onFunded, +}: FundingPanelProps) { + const [usdtAmount, setUsdtAmount] = useState(""); + const [quote, setQuote] = useState(null); + const [order, setOrder] = useState(null); + const [txHash, setTxHash] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [step, setStep] = useState< + "quote" | "deposit" | "claim" | "complete" + >("quote"); + + // BTC price approximation for UI hints + const btcPriceUsd = 100_000; + const requiredUsd = (requiredAmount / 100_000_000) * btcPriceUsd; + + const handleGetQuote = async () => { + if (!usdtAmount || parseFloat(usdtAmount) <= 0) { + setError("Please enter a valid USDT amount"); + return; + } + + setIsLoading(true); + setError(null); + + try { + // Convert USDT to smallest units (6 decimals) + const amountSmallest = BigInt( + Math.floor(parseFloat(usdtAmount) * 1_000_000) + ); + const quoteResult = await lendaswapService.getQuote(amountSmallest); + setQuote(quoteResult); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to get quote"); + } finally { + setIsLoading(false); + } + }; + + const handleCreateOrder = async () => { + if (!quote) return; + + setIsLoading(true); + setError(null); + + try { + const orderResult = await lendaswapService.createSwapOrder( + quote.sourceAmount, + arkadeAddress + ); + setOrder(orderResult); + setStep("deposit"); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to create order"); + } finally { + setIsLoading(false); + } + }; + + const handleConfirmDeposit = async () => { + if (!order || !txHash) { + setError("Please enter the Ethereum transaction hash"); + return; + } + + setIsLoading(true); + setError(null); + + try { + await lendaswapService.confirmDeposit(order.swapId, txHash); + setStep("claim"); + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to confirm deposit" + ); + } finally { + setIsLoading(false); + } + }; + + const handleClaim = async () => { + if (!order) return; + + setIsLoading(true); + setError(null); + + try { + await lendaswapService.claimOnArkade(order.swapId); + setStep("complete"); + onFunded?.(); + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to claim on Arkade" + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Fund via USDT (Ethereum)

+

+ Swap USDT from Ethereum to BTC on Arkade using Lendaswap. +

+ + {step === "quote" && ( +
+
+ Required for payroll: + {formatAmount(requiredAmount)} + + (~${requiredUsd.toFixed(2)} USD) + +
+ +
+ + { + setUsdtAmount(e.target.value); + setQuote(null); + }} + min="10" + step="0.01" + /> +
+ + + + {quote && ( +
+

Swap Quote

+
+
+ You send: + + $ + {( + Number(quote.sourceAmount) / + 1_000_000 + ).toFixed(2)}{" "} + USDT + +
+
+ You receive: + + {formatAmount( + Number(quote.targetAmount) + )} + +
+
+ Protocol fee: + + {formatAmount( + Number(quote.protocolFee) + )} + +
+
+ Expires: + + {quote.expiresAt.toLocaleTimeString()} + +
+
+ + +
+ )} +
+ )} + + {step === "deposit" && order && ( +
+

Step 2: Deposit USDT on Ethereum

+

+ Send your USDT to the HTLC contract using your Ethereum + wallet (MetaMask, etc). +

+ +
+
+ Contract Address: + {order.contractAddress} +
+
+ Amount: + + $ + {( + Number(order.sourceAmount) / 1_000_000 + ).toFixed(2)}{" "} + USDT + +
+
+ Hash Lock: + {order.hashLock} +
+
+ Timelock: + + {new Date( + order.timelock * 1000 + ).toLocaleString()} + +
+
+ +
+ + setTxHash(e.target.value)} + /> +
+ + +
+ )} + + {step === "claim" && ( +
+

Step 3: Claim BTC on Arkade

+

+ Your USDT deposit has been confirmed. Click below to + claim your BTC on Arkade. +

+ +
+ Destination: + {arkadeAddress} +
+ + +
+ )} + + {step === "complete" && ( +
+

Swap Complete!

+

Your BTC has been credited to your Arkade wallet.

+ +
+ )} + + {error &&
{error}
} +
+ ); +} diff --git a/examples/payroll/src/components/PayrollForm.tsx b/examples/payroll/src/components/PayrollForm.tsx new file mode 100644 index 00000000..a20b0a27 --- /dev/null +++ b/examples/payroll/src/components/PayrollForm.tsx @@ -0,0 +1,282 @@ +import { useState, useRef } from "react"; +import type { PayrollRecipient, PayrollBatch } from "../types"; +import { parseCsv, formatAmount } from "../utils/csv"; +import { payrollService } from "../services/payroll"; + +interface RecipientRowProps { + recipient: Omit & { id?: string }; + index: number; + onUpdate: ( + index: number, + field: keyof PayrollRecipient, + value: string | number + ) => void; + onRemove: (index: number) => void; +} + +function RecipientRow({ + recipient, + index, + onUpdate, + onRemove, +}: RecipientRowProps) { + return ( +
+ onUpdate(index, "name", e.target.value)} + className="input-name" + /> + onUpdate(index, "address", e.target.value)} + className="input-address" + required + /> + + onUpdate(index, "amount", parseInt(e.target.value) || 0) + } + className="input-amount" + min="1" + required + /> + +
+ ); +} + +interface PayrollFormProps { + onCreated?: (batch: PayrollBatch) => void; +} + +export function PayrollForm({ onCreated }: PayrollFormProps) { + const [name, setName] = useState(""); + const [recipients, setRecipients] = useState< + (Omit & { id?: string })[] + >([{ address: "", amount: 0 }]); + const [errors, setErrors] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + + const totalAmount = recipients.reduce((sum, r) => sum + (r.amount || 0), 0); + + const handleAddRecipient = () => { + setRecipients([...recipients, { address: "", amount: 0 }]); + }; + + const handleRemoveRecipient = (index: number) => { + if (recipients.length > 1) { + setRecipients(recipients.filter((_, i) => i !== index)); + } + }; + + const handleUpdateRecipient = ( + index: number, + field: keyof PayrollRecipient, + value: string | number + ) => { + const updated = [...recipients]; + updated[index] = { ...updated[index], [field]: value }; + setRecipients(updated); + }; + + const handleCsvImport = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + const result = parseCsv(content); + + if (result.errors.length > 0) { + setErrors(result.errors); + } else { + setErrors([]); + } + + if (result.recipients.length > 0) { + setRecipients(result.recipients); + } + }; + reader.readAsText(file); + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrors([]); + setIsSubmitting(true); + + try { + // Validate + const validationErrors: string[] = []; + + if (!name.trim()) { + validationErrors.push("Payroll name is required"); + } + + const validRecipients = recipients.filter( + (r) => r.address && r.amount > 0 + ); + if (validRecipients.length === 0) { + validationErrors.push( + "At least one valid recipient is required" + ); + } + + for (let i = 0; i < recipients.length; i++) { + const r = recipients[i]; + if (r.address && r.address.length < 10) { + validationErrors.push( + `Recipient ${i + 1}: Invalid address` + ); + } + if (r.address && (!r.amount || r.amount <= 0)) { + validationErrors.push(`Recipient ${i + 1}: Invalid amount`); + } + } + + if (validationErrors.length > 0) { + setErrors(validationErrors); + return; + } + + // Create payroll batch + const batch = payrollService.createPayroll( + { + name: name.trim(), + recipients: validRecipients, + }, + "assistant" + ); + + // Reset form + setName(""); + setRecipients([{ address: "", amount: 0 }]); + + // Notify parent + onCreated?.(batch); + } catch (error) { + setErrors([ + error instanceof Error + ? error.message + : "Failed to create payroll", + ]); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+

Create Payroll

+

+ Add recipients manually using the +/- buttons or import from CSV + (Address,Amount format). +

+ +
+
+ + setName(e.target.value)} + className="input-name-full" + required + /> +
+ +
+
+ +
+ + +
+
+ +
+ {recipients.map((recipient, index) => ( + + ))} +
+ + +
+ + {errors.length > 0 && ( +
+ {errors.map((error, i) => ( +
+ {error} +
+ ))} +
+ )} + +
+
+ Total: + {formatAmount(totalAmount)} + + ({recipients.filter((r) => r.address).length}{" "} + recipients) + +
+ + +
+
+
+ ); +} diff --git a/examples/payroll/src/components/PendingPayrolls.tsx b/examples/payroll/src/components/PendingPayrolls.tsx new file mode 100644 index 00000000..d65795c2 --- /dev/null +++ b/examples/payroll/src/components/PendingPayrolls.tsx @@ -0,0 +1,444 @@ +import { useState, useEffect } from "react"; +import type { PayrollBatch, WalletBalance } from "../types"; +import { formatAmount } from "../utils/csv"; +import { payrollService } from "../services/payroll"; + +interface PayrollCardProps { + batch: PayrollBatch; + onExecute: (batchId: string) => void; + onDelete: (batchId: string) => void; + onReset: (batchId: string) => void; +} + +function PayrollCard({ + batch, + onExecute, + onDelete, + onReset, +}: PayrollCardProps) { + const [expanded, setExpanded] = useState(false); + + const statusColors: Record = { + draft: "#6b7280", + pending: "#f59e0b", + approved: "#3b82f6", + executed: "#10b981", + failed: "#ef4444", + }; + + return ( +
+
setExpanded(!expanded)}> +
+

{batch.name}

+ + {batch.recipients.length} recipients | Created{" "} + {batch.createdAt.toLocaleDateString()} + +
+
+ + {batch.status} + + + {formatAmount(batch.totalAmount)} + +
+
+ + {expanded && ( +
+ + + + + + + + + + {batch.recipients.map((r) => ( + + + + + + ))} + +
NameAddressAmount
{r.name || "-"}{r.address}{formatAmount(r.amount)}
+ + {batch.arkTxId && ( +
+ Transaction ID:{" "} + {batch.arkTxId} +
+ )} + + {batch.errorMessage && ( +
+ Error: {batch.errorMessage} +
+ )} + +
+ {batch.status === "pending" && ( + <> + + + + )} + {batch.status === "failed" && ( + + )} +
+
+ )} +
+ ); +} + +interface ExecuteModalProps { + batch: PayrollBatch; + onClose: () => void; + onExecuted: () => void; +} + +function ExecuteModal({ batch, onClose, onExecuted }: ExecuteModalProps) { + const [privateKey, setPrivateKey] = useState(""); + const [isExecuting, setIsExecuting] = useState(false); + const [isCheckingBalance, setIsCheckingBalance] = useState(false); + const [balance, setBalance] = useState(null); + const [walletAddress, setWalletAddress] = useState(null); + const [error, setError] = useState(null); + + const checkBalance = async () => { + if (!privateKey || privateKey.length < 64) { + setError("Please enter a valid private key (64 hex characters)"); + return; + } + + setIsCheckingBalance(true); + setError(null); + + try { + const [bal, addr] = await Promise.all([ + payrollService.getWalletBalance(privateKey), + payrollService.getWalletAddress(privateKey), + ]); + setBalance(bal); + setWalletAddress(addr); + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to check balance" + ); + } finally { + setIsCheckingBalance(false); + } + }; + + const handleExecute = async () => { + if (!privateKey) { + setError("Please enter your private key"); + return; + } + + setIsExecuting(true); + setError(null); + + try { + await payrollService.executePayroll({ + payrollId: batch.id, + privateKey, + }); + onExecuted(); + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to execute payroll" + ); + setIsExecuting(false); + } + }; + + const sufficientBalance = balance && balance.available >= batch.totalAmount; + + return ( +
+
e.stopPropagation()}> +
+

Execute Payroll: {batch.name}

+ +
+ +
+
+
+ Recipients: + {batch.recipients.length} +
+
+ Total Amount: + {formatAmount(batch.totalAmount)} +
+
+ +
+ + { + setPrivateKey(e.target.value); + setBalance(null); + setWalletAddress(null); + }} + className="input-key" + /> +

+ Your private key is used locally to sign the + transaction and is never transmitted. +

+
+ + {!balance && ( + + )} + + {balance && ( +
+

Wallet Balance

+ {walletAddress && ( +
+ Address: + {walletAddress} +
+ )} +
+
+ Available: + + {formatAmount(balance.available)} + +
+
+ Settled: + {formatAmount(balance.settled)} +
+
+ Preconfirmed: + + {formatAmount(balance.preconfirmed)} + +
+
+ Required: + + {formatAmount(batch.totalAmount)} + +
+
+ + {!sufficientBalance && ( +
+ Insufficient balance. You need{" "} + {formatAmount( + batch.totalAmount - balance.available + )}{" "} + more to execute this payroll. +
+ )} +
+ )} + + {error &&
{error}
} +
+ +
+ + +
+
+
+ ); +} + +interface PendingPayrollsProps { + refreshKey?: number; +} + +export function PendingPayrolls({ refreshKey }: PendingPayrollsProps) { + const [payrolls, setPayrolls] = useState([]); + const [selectedBatch, setSelectedBatch] = useState( + null + ); + const [filter, setFilter] = useState< + "all" | "pending" | "executed" | "failed" + >("all"); + + const loadPayrolls = () => { + const all = payrollService.getAllPayrolls(); + setPayrolls(all); + }; + + useEffect(() => { + loadPayrolls(); + }, [refreshKey]); + + const filteredPayrolls = payrolls.filter((p) => { + if (filter === "all") return true; + return p.status === filter; + }); + + const handleDelete = (batchId: string) => { + if (confirm("Are you sure you want to delete this payroll?")) { + payrollService.deletePayroll(batchId); + loadPayrolls(); + } + }; + + const handleReset = (batchId: string) => { + payrollService.resetPayroll(batchId); + loadPayrolls(); + }; + + const handleExecuted = () => { + setSelectedBatch(null); + loadPayrolls(); + }; + + const stats = { + total: payrolls.length, + pending: payrolls.filter((p) => p.status === "pending").length, + executed: payrolls.filter((p) => p.status === "executed").length, + failed: payrolls.filter((p) => p.status === "failed").length, + totalAmount: payrolls + .filter((p) => p.status === "pending") + .reduce((sum, p) => sum + p.totalAmount, 0), + }; + + return ( +
+
+

Payroll Management

+
+ + {stats.pending} pending + + + {stats.executed} executed + + {stats.pending > 0 && ( + + {formatAmount(stats.totalAmount)}{" "} + to pay + + )} +
+
+ +
+ + + + +
+ + {filteredPayrolls.length === 0 ? ( +
+ {filter === "all" + ? "No payrolls yet. Create one using the form above." + : `No ${filter} payrolls.`} +
+ ) : ( +
+ {filteredPayrolls.map((batch) => ( + + setSelectedBatch( + payrolls.find((p) => p.id === id) || null + ) + } + onDelete={handleDelete} + onReset={handleReset} + /> + ))} +
+ )} + + {selectedBatch && ( + setSelectedBatch(null)} + onExecuted={handleExecuted} + /> + )} +
+ ); +} diff --git a/examples/payroll/src/components/index.ts b/examples/payroll/src/components/index.ts new file mode 100644 index 00000000..67a893d6 --- /dev/null +++ b/examples/payroll/src/components/index.ts @@ -0,0 +1,3 @@ +export { PayrollForm } from "./PayrollForm"; +export { PendingPayrolls } from "./PendingPayrolls"; +export { FundingPanel } from "./FundingPanel"; diff --git a/examples/payroll/src/main.tsx b/examples/payroll/src/main.tsx new file mode 100644 index 00000000..d9e65797 --- /dev/null +++ b/examples/payroll/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/examples/payroll/src/services/index.ts b/examples/payroll/src/services/index.ts new file mode 100644 index 00000000..4e705790 --- /dev/null +++ b/examples/payroll/src/services/index.ts @@ -0,0 +1,7 @@ +export { + payrollService, + ArkadePayrollService, + DEFAULT_CONFIG, +} from "./payroll"; +export { lendaswapService, LendaswapService } from "./lendaswap"; +export type { SwapQuote, SwapOrder } from "./lendaswap"; diff --git a/examples/payroll/src/services/lendaswap.ts b/examples/payroll/src/services/lendaswap.ts new file mode 100644 index 00000000..f22e78e9 --- /dev/null +++ b/examples/payroll/src/services/lendaswap.ts @@ -0,0 +1,243 @@ +/** + * Lendaswap integration service for funding payroll via USDT on Ethereum + * + * This service uses the Lendaswap SDK to swap USDT (ERC-20 on Ethereum) + * to BTC on Arkade, enabling payroll funding from stablecoin sources. + */ + +import type { NetworkConfig } from "../types"; +import { DEFAULT_CONFIG } from "./payroll"; + +/** + * Quote for a swap operation + */ +export interface SwapQuote { + exchangeRate: number; + sourceAmount: bigint; + targetAmount: bigint; + protocolFee: bigint; + minAmount: bigint; + expiresAt: Date; +} + +/** + * Swap order details + */ +export interface SwapOrder { + swapId: string; + contractAddress: string; + hashLock: string; + timelock: number; + sourceAmount: bigint; + targetAddress: string; + status: "pending" | "deposited" | "claimed" | "refunded" | "expired"; +} + +/** + * LendaswapService handles USDT to Arkade BTC swaps + */ +export class LendaswapService { + private orders: Map = new Map(); + + constructor(_config: NetworkConfig = DEFAULT_CONFIG) { + this.loadOrdersFromStorage(); + } + + private loadOrdersFromStorage(): void { + try { + const stored = localStorage.getItem("lendaswap_orders"); + if (stored) { + const parsed = JSON.parse(stored); + for (const order of parsed) { + this.orders.set(order.swapId, order); + } + } + } catch { + console.warn("Failed to load swap orders from storage"); + } + } + + private saveOrdersToStorage(): void { + try { + const data = Array.from(this.orders.values()); + localStorage.setItem("lendaswap_orders", JSON.stringify(data)); + } catch { + console.warn("Failed to save swap orders to storage"); + } + } + + /** + * Get a quote for swapping USDT to BTC on Arkade + * + * @param usdtAmount Amount in USDT (6 decimals, so 1 USDT = 1_000_000) + */ + async getQuote(usdtAmount: bigint): Promise { + // In a real implementation, this would call the Lendaswap API: + // const client = await Client.create(...); + // const quote = await client.getQuote('usdt_eth', 'btc_arkade', usdtAmount); + + // For demo purposes, simulate a quote with typical exchange rates + // BTC price ~$100,000, so 1 USDT = ~1000 sats + const btcPriceUsd = 100_000; + const satsPerBtc = 100_000_000n; + const usdtDecimals = 6; + + // Convert USDT amount to BTC equivalent in sats + const usdtValue = Number(usdtAmount) / 10 ** usdtDecimals; + const btcValue = usdtValue / btcPriceUsd; + const satsValue = BigInt(Math.floor(btcValue * Number(satsPerBtc))); + + // Protocol fee (0.3%) + const protocolFee = (satsValue * 3n) / 1000n; + const targetAmount = satsValue - protocolFee; + + return { + exchangeRate: Number(satsPerBtc) / btcPriceUsd, + sourceAmount: usdtAmount, + targetAmount, + protocolFee, + minAmount: 10_000_000n, // $10 minimum + expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes + }; + } + + /** + * Create a swap order from USDT (Ethereum) to BTC (Arkade) + * + * @param usdtAmount Amount in USDT smallest units (6 decimals) + * @param arkadeAddress Destination Arkade address for the BTC + */ + async createSwapOrder( + usdtAmount: bigint, + arkadeAddress: string + ): Promise { + // In a real implementation, this would call the Lendaswap SDK: + // const swap = await client.createEvmToArkadeSwap({ + // user_address: ethereumAddress, + // source_token: 'usdt_eth', + // }, 'ethereum'); + + // Generate mock swap details for demo + const swapId = `swap-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const hashLock = Array.from({ length: 32 }, () => + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0") + ).join(""); + + const order: SwapOrder = { + swapId, + contractAddress: "0x1234567890abcdef1234567890abcdef12345678", // HTLC contract + hashLock: `0x${hashLock}`, + timelock: Math.floor(Date.now() / 1000) + 3600, // 1 hour + sourceAmount: usdtAmount, + targetAddress: arkadeAddress, + status: "pending", + }; + + this.orders.set(swapId, order); + this.saveOrdersToStorage(); + + return order; + } + + /** + * Get contract ABI for HTLC deposit + * This is used by the frontend to execute the Ethereum transaction + */ + getHtlcAbi(): object[] { + return [ + { + name: "deposit", + type: "function", + inputs: [ + { name: "hashLock", type: "bytes32" }, + { name: "timelock", type: "uint256" }, + { name: "receiver", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + name: "approve", + type: "function", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + }, + ]; + } + + /** + * Get USDT token contract address on Ethereum + */ + getUsdtAddress(): string { + // Mainnet USDT + return "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + } + + /** + * Confirm that the Ethereum deposit was made + * This should be called after the user submits the Ethereum transaction + */ + async confirmDeposit(swapId: string, _txHash: string): Promise { + const order = this.orders.get(swapId); + if (!order) { + throw new Error(`Swap order ${swapId} not found`); + } + + // In real implementation, verify the transaction on-chain + // await client.confirmDeposit(swapId, txHash); + + order.status = "deposited"; + this.orders.set(swapId, order); + this.saveOrdersToStorage(); + } + + /** + * Claim the BTC on Arkade after USDT deposit is confirmed + * This completes the swap and releases BTC to the Arkade address + */ + async claimOnArkade(swapId: string): Promise { + const order = this.orders.get(swapId); + if (!order) { + throw new Error(`Swap order ${swapId} not found`); + } + if (order.status !== "deposited") { + throw new Error(`Cannot claim swap with status ${order.status}`); + } + + // In real implementation: + // const arkTxId = await client.claimVhtlc(swapId); + + // For demo, simulate a successful claim + const arkTxId = `ark-${Date.now().toString(16)}`; + + order.status = "claimed"; + this.orders.set(swapId, order); + this.saveOrdersToStorage(); + + return arkTxId; + } + + /** + * Get all swap orders + */ + getOrders(): SwapOrder[] { + return Array.from(this.orders.values()); + } + + /** + * Get a specific swap order + */ + getOrder(swapId: string): SwapOrder | undefined { + return this.orders.get(swapId); + } +} + +// Export singleton instance +export const lendaswapService = new LendaswapService(); diff --git a/examples/payroll/src/services/payroll.ts b/examples/payroll/src/services/payroll.ts new file mode 100644 index 00000000..346cd4a6 --- /dev/null +++ b/examples/payroll/src/services/payroll.ts @@ -0,0 +1,362 @@ +import { + Wallet, + SingleKey, + RestArkProvider, + RestIndexerProvider, + EsploraProvider, +} from "@arkade-os/sdk"; +import type { + PayrollBatch, + PayrollRecipient, + CreatePayrollParams, + ExecutePayrollParams, + NetworkConfig, + WalletBalance, +} from "../types"; + +/** + * Generate a unique ID + */ +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Default network configuration for Arkade testnet + */ +export const DEFAULT_CONFIG: NetworkConfig = { + arkServerUrl: "https://ark.arkade.computer", + indexerUrl: "https://indexer.arkade.computer", + esploraUrl: "https://esplora.arkade.computer", + lendaswapUrl: "https://apilendaswap.lendasat.com", + network: "bitcoin", +}; + +/** + * Local storage key for payroll batches + */ +const STORAGE_KEY = "arkade_payroll_batches"; + +/** + * ArkadePayrollService manages payroll batches and integrates with Arkade SDK + * for transaction creation and execution + */ +export class ArkadePayrollService { + private batches: Map = new Map(); + private config: NetworkConfig; + + constructor(config: NetworkConfig = DEFAULT_CONFIG) { + this.config = config; + this.loadFromStorage(); + } + + /** + * Load batches from local storage + */ + private loadFromStorage(): void { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + for (const batch of parsed) { + batch.createdAt = new Date(batch.createdAt); + if (batch.approvedAt) + batch.approvedAt = new Date(batch.approvedAt); + if (batch.executedAt) + batch.executedAt = new Date(batch.executedAt); + this.batches.set(batch.id, batch); + } + } + } catch (e) { + console.warn("Failed to load payroll batches from storage:", e); + } + } + + /** + * Save batches to local storage + */ + private saveToStorage(): void { + try { + const data = Array.from(this.batches.values()); + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + console.warn("Failed to save payroll batches to storage:", e); + } + } + + /** + * Create a new payroll batch (Assistant action) + * This creates a draft payroll that needs admin approval + */ + createPayroll( + params: CreatePayrollParams, + createdBy: string + ): PayrollBatch { + const recipients: PayrollRecipient[] = params.recipients.map((r) => ({ + ...r, + id: generateId(), + })); + + const totalAmount = recipients.reduce((sum, r) => sum + r.amount, 0); + + const batch: PayrollBatch = { + id: generateId(), + name: params.name, + recipients, + totalAmount, + status: "pending", + createdAt: new Date(), + createdBy, + }; + + this.batches.set(batch.id, batch); + this.saveToStorage(); + + return batch; + } + + /** + * Get a payroll batch by ID + */ + getPayroll(id: string): PayrollBatch | undefined { + return this.batches.get(id); + } + + /** + * Get all payroll batches + */ + getAllPayrolls(): PayrollBatch[] { + return Array.from(this.batches.values()).sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + ); + } + + /** + * Get pending payrolls (awaiting admin approval) + */ + getPendingPayrolls(): PayrollBatch[] { + return this.getAllPayrolls().filter((b) => b.status === "pending"); + } + + /** + * Update a payroll batch recipients (only if pending) + */ + updatePayroll( + id: string, + recipients: Omit[] + ): PayrollBatch { + const batch = this.batches.get(id); + if (!batch) { + throw new Error(`Payroll ${id} not found`); + } + if (batch.status !== "pending") { + throw new Error( + `Cannot update payroll with status ${batch.status}` + ); + } + + batch.recipients = recipients.map((r) => ({ ...r, id: generateId() })); + batch.totalAmount = batch.recipients.reduce( + (sum, r) => sum + r.amount, + 0 + ); + + this.batches.set(id, batch); + this.saveToStorage(); + + return batch; + } + + /** + * Delete a payroll batch (only if pending) + */ + deletePayroll(id: string): void { + const batch = this.batches.get(id); + if (!batch) { + throw new Error(`Payroll ${id} not found`); + } + if (batch.status !== "pending" && batch.status !== "draft") { + throw new Error( + `Cannot delete payroll with status ${batch.status}` + ); + } + + this.batches.delete(id); + this.saveToStorage(); + } + + /** + * Create an Arkade wallet from a private key + */ + private async createWallet(privateKeyHex: string): Promise { + const identity = SingleKey.fromHex(privateKeyHex); + + const arkProvider = new RestArkProvider(this.config.arkServerUrl); + const indexerProvider = new RestIndexerProvider(this.config.indexerUrl); + const onchainProvider = new EsploraProvider(this.config.esploraUrl); + + const wallet = await Wallet.create({ + identity, + arkProvider, + indexerProvider, + onchainProvider, + }); + + return wallet; + } + + /** + * Get wallet balance for a given private key + */ + async getWalletBalance(privateKeyHex: string): Promise { + const wallet = await this.createWallet(privateKeyHex); + return await wallet.getBalance(); + } + + /** + * Get wallet address for a given private key + */ + async getWalletAddress(privateKeyHex: string): Promise { + const wallet = await this.createWallet(privateKeyHex); + return await wallet.getAddress(); + } + + /** + * Execute a payroll batch (Admin action) + * Signs and submits the transaction to Arkade + */ + async executePayroll(params: ExecutePayrollParams): Promise { + const batch = this.batches.get(params.payrollId); + if (!batch) { + throw new Error(`Payroll ${params.payrollId} not found`); + } + if (batch.status !== "pending" && batch.status !== "approved") { + throw new Error( + `Cannot execute payroll with status ${batch.status}` + ); + } + + try { + // Create wallet from admin's private key + const wallet = await this.createWallet(params.privateKey); + + // Check balance + const balance = await wallet.getBalance(); + if (balance.available < batch.totalAmount) { + throw new Error( + `Insufficient balance: ${balance.available} sats available, ` + + `${batch.totalAmount} sats required` + ); + } + + // Prepare outputs for each recipient + const recipients = batch.recipients.map((r) => ({ + address: r.address, + amount: r.amount, + })); + + // Execute batch send using wallet.settle() for multi-output support + // For single recipient, we could use sendBitcoin, but settle handles batches better + let arkTxId: string; + + if (recipients.length === 1) { + // Single recipient - use sendBitcoin + arkTxId = await wallet.sendBitcoin({ + address: recipients[0].address, + amount: recipients[0].amount, + }); + } else { + // Multiple recipients - use settle with multiple outputs + // Get available VTXOs + const vtxos = await wallet.getVtxos(); + const boardingUtxos = await wallet.getBoardingUtxos(); + + // Select inputs that cover the total amount + const inputs = [...boardingUtxos, ...vtxos]; + + // Create outputs array for settlement + const outputs = recipients.map((r) => ({ + address: r.address, + amount: BigInt(r.amount), + })); + + arkTxId = await wallet.settle({ + inputs, + outputs, + }); + } + + // Update batch status + batch.status = "executed"; + batch.executedAt = new Date(); + batch.approvedBy = "admin"; + batch.approvedAt = new Date(); + batch.arkTxId = arkTxId; + + this.batches.set(batch.id, batch); + this.saveToStorage(); + + return arkTxId; + } catch (error) { + // Update batch with error + batch.status = "failed"; + batch.errorMessage = + error instanceof Error ? error.message : String(error); + + this.batches.set(batch.id, batch); + this.saveToStorage(); + + throw error; + } + } + + /** + * Approve a payroll without executing (for two-step workflow) + */ + approvePayroll(id: string, approvedBy: string): PayrollBatch { + const batch = this.batches.get(id); + if (!batch) { + throw new Error(`Payroll ${id} not found`); + } + if (batch.status !== "pending") { + throw new Error( + `Cannot approve payroll with status ${batch.status}` + ); + } + + batch.status = "approved"; + batch.approvedAt = new Date(); + batch.approvedBy = approvedBy; + + this.batches.set(id, batch); + this.saveToStorage(); + + return batch; + } + + /** + * Reset a failed payroll to pending status for retry + */ + resetPayroll(id: string): PayrollBatch { + const batch = this.batches.get(id); + if (!batch) { + throw new Error(`Payroll ${id} not found`); + } + if (batch.status !== "failed") { + throw new Error(`Can only reset failed payrolls`); + } + + batch.status = "pending"; + batch.errorMessage = undefined; + batch.approvedAt = undefined; + batch.approvedBy = undefined; + + this.batches.set(id, batch); + this.saveToStorage(); + + return batch; + } +} + +// Export singleton instance +export const payrollService = new ArkadePayrollService(); diff --git a/examples/payroll/src/types/index.ts b/examples/payroll/src/types/index.ts new file mode 100644 index 00000000..c1f4baac --- /dev/null +++ b/examples/payroll/src/types/index.ts @@ -0,0 +1,107 @@ +/** + * Represents a single recipient in a payroll transaction + */ +export interface PayrollRecipient { + id: string; + address: string; + amount: number; + name?: string; +} + +/** + * Status of a payroll transaction + */ +export type PayrollStatus = + | "draft" + | "pending" + | "approved" + | "executed" + | "failed"; + +/** + * Represents a payroll batch that can contain multiple recipients + */ +export interface PayrollBatch { + id: string; + name: string; + recipients: PayrollRecipient[]; + totalAmount: number; + status: PayrollStatus; + createdAt: Date; + createdBy: string; + approvedAt?: Date; + approvedBy?: string; + executedAt?: Date; + arkTxId?: string; + errorMessage?: string; +} + +/** + * Parameters for creating a new payroll batch + */ +export interface CreatePayrollParams { + name: string; + recipients: Omit[]; +} + +/** + * Parameters for executing a payroll (admin action) + */ +export interface ExecutePayrollParams { + payrollId: string; + privateKey: string; +} + +/** + * Funding options for payroll + */ +export type FundingSource = "arkade_balance" | "usdt_ethereum"; + +/** + * Parameters for funding payroll via Lendaswap + */ +export interface FundPayrollParams { + payrollId: string; + source: FundingSource; + ethereumAddress?: string; +} + +/** + * Result of parsing a CSV file + */ +export interface CsvParseResult { + recipients: Omit[]; + errors: string[]; +} + +/** + * Network configuration + */ +export interface NetworkConfig { + arkServerUrl: string; + indexerUrl: string; + esploraUrl: string; + lendaswapUrl: string; + network: "bitcoin" | "testnet" | "regtest"; +} + +/** + * Wallet balance information + */ +export interface WalletBalance { + boarding: { + confirmed: number; + unconfirmed: number; + total: number; + }; + settled: number; + preconfirmed: number; + available: number; + recoverable: number; + total: number; +} + +/** + * User roles in the payroll system + */ +export type UserRole = "assistant" | "admin"; diff --git a/examples/payroll/src/utils/csv.ts b/examples/payroll/src/utils/csv.ts new file mode 100644 index 00000000..19e18b6d --- /dev/null +++ b/examples/payroll/src/utils/csv.ts @@ -0,0 +1,99 @@ +import type { CsvParseResult, PayrollRecipient } from "../types"; + +/** + * Parse CSV content with Address,Amount format + * Supports optional header row and various separators + */ +export function parseCsv(content: string): CsvParseResult { + const errors: string[] = []; + const recipients: Omit[] = []; + + const lines = content + .trim() + .split(/\r?\n/) + .filter((line) => line.trim() !== ""); + + if (lines.length === 0) { + return { recipients: [], errors: ["Empty CSV content"] }; + } + + // Check if first line is a header + const firstLine = lines[0].toLowerCase(); + const startIndex = + firstLine.includes("address") || firstLine.includes("amount") ? 1 : 0; + + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i].trim(); + const lineNumber = i + 1; + + // Support comma, semicolon, or tab as separators + const parts = line.split(/[,;\t]/).map((p) => p.trim()); + + if (parts.length < 2) { + errors.push( + `Line ${lineNumber}: Invalid format, expected "Address,Amount"` + ); + continue; + } + + const [address, amountStr, name] = parts; + + // Validate address (basic check for Ark addresses) + if (!address || address.length < 10) { + errors.push(`Line ${lineNumber}: Invalid address "${address}"`); + continue; + } + + // Parse and validate amount + const amount = parseFloat(amountStr); + if (isNaN(amount) || amount <= 0) { + errors.push(`Line ${lineNumber}: Invalid amount "${amountStr}"`); + continue; + } + + // Convert to satoshis if needed (assume BTC if has decimal point with significant digits) + const amountSats = + amountStr.includes(".") && parseFloat(amountStr) < 1 + ? Math.round(amount * 100_000_000) + : Math.round(amount); + + recipients.push({ + address, + amount: amountSats, + name: name || undefined, + }); + } + + return { recipients, errors }; +} + +/** + * Generate CSV content from recipients + */ +export function generateCsv(recipients: PayrollRecipient[]): string { + const header = "Address,Amount,Name"; + const rows = recipients.map( + (r) => `${r.address},${r.amount},${r.name || ""}` + ); + return [header, ...rows].join("\n"); +} + +/** + * Format satoshis to BTC string + */ +export function formatSats(sats: number): string { + return (sats / 100_000_000).toFixed(8); +} + +/** + * Format satoshis to display string with unit + */ +export function formatAmount(sats: number): string { + if (sats >= 100_000_000) { + return `${(sats / 100_000_000).toFixed(4)} BTC`; + } else if (sats >= 100_000) { + return `${(sats / 100_000).toFixed(2)} mBTC`; + } else { + return `${sats.toLocaleString()} sats`; + } +} diff --git a/examples/payroll/src/utils/index.ts b/examples/payroll/src/utils/index.ts new file mode 100644 index 00000000..c5cbec7e --- /dev/null +++ b/examples/payroll/src/utils/index.ts @@ -0,0 +1 @@ +export { parseCsv, generateCsv, formatSats, formatAmount } from "./csv"; diff --git a/examples/payroll/src/vite-env.d.ts b/examples/payroll/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/payroll/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/payroll/tsconfig.json b/examples/payroll/tsconfig.json new file mode 100644 index 00000000..5fca7dc3 --- /dev/null +++ b/examples/payroll/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/payroll/vite.config.ts b/examples/payroll/vite.config.ts new file mode 100644 index 00000000..945575be --- /dev/null +++ b/examples/payroll/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + define: { + global: "globalThis", + }, + optimizeDeps: { + esbuildOptions: { + target: "es2022", + }, + }, + build: { + target: "es2022", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a47f0d3..25984d4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,40 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) + examples/payroll: + dependencies: + '@arkade-os/sdk': + specifier: file:../.. + version: 'file:' + '@lendasat/lendaswap-sdk': + specifier: ^0.1.0 + version: 0.1.0 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.27 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.27) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1)) + prettier: + specifier: ^3.4.2 + version: 3.6.2 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^6.0.3 + version: 6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) + packages: '@0no-co/graphql.web@1.2.0': @@ -80,6 +114,10 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@arkade-os/sdk@file:': + resolution: {directory: '', type: directory} + engines: {node: '>=20.0.0'} + '@babel/code-frame@7.10.4': resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} @@ -1105,6 +1143,9 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@lendasat/lendaswap-sdk@0.1.0': + resolution: {integrity: sha512-eA6AKbuMAKs/p02B1R2nD8yNYIq4RtVsnXMmmaRn/k+NtxQmbm5EM/BlBS34Ou1BbYoJdioD8Ywq0RdE/kwsLg==} + '@noble/curves@2.0.0': resolution: {integrity: sha512-RiwZZeJnsTnhT+/gg2KvITJZhK5oagQrpZo+yQyd3mv3D5NAG2qEeEHpw7IkXRlpkoD45wl2o4ydHAvY9wyEfw==} engines: {node: '>= 20.19.0'} @@ -1215,6 +1256,9 @@ packages: '@types/react': optional: true + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/rollup-android-arm-eabi@4.50.1': resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} cpu: [arm] @@ -1395,6 +1439,17 @@ packages: '@types/node@24.3.1': resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.27': + resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1415,6 +1470,12 @@ packages: peerDependencies: '@urql/core': ^5.0.0 + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@3.2.4': resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: @@ -1861,6 +1922,9 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -1930,6 +1994,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + dexie@4.2.1: + resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3179,6 +3246,11 @@ packages: react-devtools-core@6.1.5: resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3200,6 +3272,14 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + react@19.1.1: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} @@ -3300,6 +3380,9 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -3702,19 +3785,19 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.1.5: - resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} - engines: {node: ^20.19.0 || >=22.12.0} + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 jiti: '>=1.21.0' - less: ^4.0.0 + less: '*' lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 @@ -3926,6 +4009,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.30 + '@arkade-os/sdk@file:': + dependencies: + '@noble/curves': 2.0.0 + '@noble/secp256k1': 3.0.0 + '@scure/base': 2.0.0 + '@scure/btc-signer': 2.0.1 + bip68: 1.0.4 + '@babel/code-frame@7.10.4': dependencies: '@babel/highlight': 7.25.9 @@ -5304,6 +5395,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lendasat/lendaswap-sdk@0.1.0': + dependencies: + dexie: 4.2.1 + '@noble/curves@2.0.0': dependencies: '@noble/hashes': 2.0.0 @@ -5485,6 +5580,8 @@ snapshots: react: 19.1.1 react-native: 0.81.4(@babel/core@7.28.4)(react@19.1.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/rollup-android-arm-eabi@4.50.1': optional: true @@ -5647,6 +5744,17 @@ snapshots: dependencies: undici-types: 7.10.0 + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.27)': + dependencies: + '@types/react': 18.3.27 + + '@types/react@18.3.27': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/stack-utils@2.0.3': {} '@types/unist@3.0.3': {} @@ -5669,6 +5777,18 @@ snapshots: '@urql/core': 5.2.0 wonka: 6.3.5 + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1))': + dependencies: + '@babel/core': 7.28.4 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 @@ -5696,13 +5816,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.5(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) + vite: 6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -6178,6 +6298,8 @@ snapshots: crypto-random-string@2.0.0: {} + csstype@3.2.3: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -6226,6 +6348,8 @@ snapshots: detect-libc@1.0.3: {} + dexie@4.2.1: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -7610,6 +7734,12 @@ snapshots: - bufferutil - utf-8-validate + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-is@16.13.1: {} react-is@18.3.1: {} @@ -7661,6 +7791,12 @@ snapshots: react-refresh@0.14.2: {} + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + react@19.1.1: {} recast@0.21.5: @@ -7776,6 +7912,10 @@ snapshots: sax@1.4.1: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + scheduler@0.26.0: {} selfsigned@2.4.1: @@ -8142,7 +8282,7 @@ snapshots: debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.5(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) + vite: 6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -8157,7 +8297,7 @@ snapshots: - tsx - yaml - vite@7.1.5(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1): + vite@6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -8176,7 +8316,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -8194,7 +8334,7 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.5(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) + vite: 6.4.1(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) vite-node: 3.2.4(@types/node@24.3.1)(lightningcss@1.27.0)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: