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 (
+
+
+
+
+ {role === "assistant" && (
+
+
+
+ {recentBatch && (
+
+ Payroll "{recentBatch.name}" created
+ successfully with{" "}
+ {recentBatch.recipients.length} recipients.
+
+
+ )}
+
+
+
How it works
+
+ -
+ Create a payroll by adding
+ recipients manually or importing a CSV file.
+
+ -
+ Payroll is submitted for
+ admin approval (pending status).
+
+ -
+ Admin reviews and executes{" "}
+ the payroll by signing with their private
+ key.
+
+ -
+ Recipients receive BTC on
+ Arkade as new virtual outputs (VTXOs).
+
+
+
+
+ )}
+
+ {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).
+
+
+
+
+ );
+}
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 && (
+
+
+
+
+ | Name |
+ Address |
+ Amount |
+
+
+
+ {batch.recipients.map((r) => (
+
+ | {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: