diff --git a/.env.example b/.env.example index c81de25..ea3d3a7 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,37 @@ PINATA_JWT= # Found in Pinata dashboard → Gateways. Looks like: yourname.mypinata.cloud PINATA_GATEWAY_URL= +# ── Bitcoin node + BTC wallet (optional, profile: bitcoin) ────────── +# Light wallet keys live in the bdk-wallet sidecar; bitcoind provides chain +# data and broadcasts transactions. Enable with: +# docker compose --profile bitcoin up -d +BITCOIN_ENABLED=false + +# Bitcoin network. Empty = follow NETWORK above (mainnet/testnet). +# Valid values: mainnet, testnet, regtest, signet. +BITCOIN_NETWORK= + +# Credentials for the bitcoind RPC (used by the bdk-wallet sidecar). +BITCOIN_RPC_USERNAME=bitcoin_user +BITCOIN_RPC_PASSWORD=bitcoin_password_change_me + +# Credentials the web GUI uses to reach the bdk-wallet sidecar's HTTP API. +BITCOIN_WALLET_HTTP_USERNAME=btcwallet_user +BITCOIN_WALLET_HTTP_PASSWORD=btcwallet_password_change_me + +# Advanced: transaction index and pruning for bitcoind. +# txindex=1 (default) is required for full wallet history. +# prune>0 saves disk but is incompatible with the wallet history sync +# (mainnet chain data is ~700 GB with txindex=1). +BITCOIN_TXINDEX=1 +BITCOIN_PRUNE=0 + +# Block explorer used for tx/address links in the Bitcoin page. +# Empty default: public networks link to mempool.space; regtest links to +# the self-hosted btc-rpc-explorer sidecar (http://localhost:3002). +# Set to any explorer base URL to override both. +BITCOIN_EXPLORER_URL= + # ── Watchtower (optional, profile: watchtower) ─────── # Auto-updates Mintlayer Docker images daily at 04:00. # Start with: docker compose --profile watchtower up -d diff --git a/.gitignore b/.gitignore index 2d7545f..5174d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ Thumbs.db .idea/ *.swp *.swo + +# Bitcoin (optional, profile: bitcoin) +bitcoin-data/ +bitcoin-wallet-data/ +bdk-wallet/target/ diff --git a/Makefile b/Makefile index aa181eb..26289e7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: up down restart nuke restart-gui build logs dev dev-build dev-local wallet-cli nft-images-public pending-transactions list-utxos +.PHONY: up down restart nuke restart-gui build logs dev dev-build dev-local wallet-cli bitcoin bitcoin-cli nft-images-public pending-transactions list-utxos ACCOUNT ?= 0 @@ -8,7 +8,7 @@ up: ## Stop and remove all containers (including optional profiles and orphaned run containers) down: - docker compose --profile indexer --profile wallet_cli down --remove-orphans + docker compose --profile indexer --profile wallet_cli --profile bitcoin down --remove-orphans ## Full clean restart: tear down everything, fix stuck networks, then bring up fresh ## Fixes "Network still in use" / "network not found" errors from dangling containers. @@ -27,7 +27,7 @@ restart: down ## Nuclear option: remove ALL stopped containers and unused networks project-wide, ## then restart. Use when restart still fails. nuke: - docker compose --profile indexer --profile wallet_cli down --remove-orphans --volumes 2>/dev/null || true + docker compose --profile indexer --profile wallet_cli --profile bitcoin down --remove-orphans --volumes 2>/dev/null || true docker container prune -f docker network prune -f docker compose up -d @@ -62,6 +62,17 @@ dev-build: wallet-cli: docker compose --profile wallet_cli run --rm wallet-cli +## Start the optional Bitcoin stack (bitcoind + BTC wallet sidecar) alongside core services +bitcoin: + docker compose --profile bitcoin up -d + @echo "Bitcoin node + BTC wallet started. First sync can take a long time on mainnet." + @echo "Open the Bitcoin page in the web UI to create your BTC wallet." + +## bitcoin-cli shell inside the Bitcoin node container +## Usage: make bitcoin-cli CMD='getblockchaininfo' +bitcoin-cli: + docker compose --profile bitcoin exec bitcoind bitcoin-cli -rpcport=8332 -rpcuser=$$(grep '^BITCOIN_RPC_USERNAME=' .env | cut -d= -f2) -rpcpassword=$$(grep '^BITCOIN_RPC_PASSWORD=' .env | cut -d= -f2) $(CMD) + ## List pending transactions for account ACCOUNT (default 0) via wallet RPC. ## Usage: make pending-transactions or make pending-transactions ACCOUNT=1 pending-transactions: diff --git a/README.md b/README.md index 4e956eb..3356b2c 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ A `Makefile` wraps the most common Docker Compose commands: | `make dev-indexer` | Dev mode + full indexer stack | | `make dev-build` | Rebuild the dev image (run after adding npm packages) | | `make wallet-cli` | Open an interactive wallet-cli session | +| `make bitcoin` | Start the optional Bitcoin node + BTC wallet | +| `make bitcoin-cli CMD='getblockchaininfo'` | Run bitcoin-cli inside the node | --- @@ -130,6 +132,33 @@ The REST API is available at (configurable via `API_WEB_ --- +## Optional: Bitcoin node + BTC wallet + +Adds a Bitcoin Core node and a built-in BTC wallet (balance, receive, send) to the web UI. + +```bash +docker compose --profile bitcoin up -d # or: make bitcoin +``` + +How it works: + +- **`bitcoind`** provides chain data and broadcasts transactions. It needs no host ports. +- **`bdk-wallet`** is a light-wallet sidecar (BDK, BIP84) that holds the BTC keys and signs + transactions locally. The web GUI talks to it over the internal Docker network. +- The wallet seed is generated in the web UI and **shown exactly once** — back it up when prompted. +- All BTC API routes require a logged-in session; the sidecar is not reachable from outside. + +**Requirements and warnings** + +- Mainnet chain data is roughly **700 GB** with the default `txindex=1`. The first sync can + take days. Use `BITCOIN_NETWORK=testnet` (or `regtest`) to try it out cheaply. +- The BTC wallet is a **hot wallet** — keep only spending amounts on it. +- Pruning (`BITCOIN_PRUNE`) is incompatible with the wallet's history sync; leave it off. +- Bitcoin Core is pinned to **25.x**: the BDK rpc backend cannot parse the `warnings` + format used by Core 26+. Override only if you know what you are doing (`BITCOIND_IMAGE`). + +--- + ## Useful commands ```bash @@ -170,6 +199,7 @@ docker compose pull && docker compose up -d | Staking | `/staking` | Staking status and instructions | | Token Management | `/token-management` | Issue and manage tokens — **requires indexer** | | Trading | `/trading` | DEX trading — **requires indexer** | +| Bitcoin | `/bitcoin` | BTC balance, receive and send — **requires bitcoin profile** | | Wallet setup | `/setup` | Create or open a wallet | > **Token Management** and **Trading** are hidden when `INDEXER_ENABLED=false` in `.env`. diff --git a/app/package-lock.json b/app/package-lock.json index 2b4264a..1a94e82 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -19,7 +19,9 @@ "astro": "^7.2.0", "better-sqlite3": "^12.9.0", "clsx": "^2.1.1", + "cookie": "^1.1.1", "es-module-lexer": "^1.7.0", + "ethers": "^6.17.0", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react": "^19.2.5", @@ -52,6 +54,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2292,6 +2300,30 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3816,6 +3848,12 @@ "node": ">= 0.6" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4855,7 +4893,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5463,6 +5500,76 @@ "node": ">= 0.6" } }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", diff --git a/app/package.json b/app/package.json index 1a1b93b..e65e88c 100644 --- a/app/package.json +++ b/app/package.json @@ -25,6 +25,7 @@ "astro": "^7.2.0", "better-sqlite3": "^12.9.0", "clsx": "^2.1.1", + "cookie": "^1.1.1", "es-module-lexer": "^1.7.0", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", @@ -32,6 +33,7 @@ "react-dom": "^19.2.5", "tailwind-merge": "^3.5.0", "tailwindcss": "^3.4.0", + "ethers": "^6.17.0", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/app/src/components/BitcoinWallet.tsx b/app/src/components/BitcoinWallet.tsx new file mode 100644 index 0000000..9452750 --- /dev/null +++ b/app/src/components/BitcoinWallet.tsx @@ -0,0 +1,433 @@ +"use client"; + +import { useCallback, useEffect, useState } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import { CopyButton } from '@/components/CopyButton'; +import { suggestAddressCorrection, hrpForNetwork } from '@/lib/bech32-correct'; + +// ── Types (mirror of the sidecar payloads) ──────────────────────────────────── + +interface BitcoinNodeInfo { + reachable: boolean; + blocks: number; + headers: number; + synced: boolean; + initialBlockDownload: boolean; +} + +interface BitcoinStatus { + network: string; + walletExists: boolean; + walletLoaded: boolean; + node: BitcoinNodeInfo; + balance: { confirmed: string; trustedPending: string; untrustedPending: string; immature: string } | null; +} + +interface BitcoinTransaction { + txid: string; + received: string; + sent: string; + fee: string | null; + confirmed: boolean; + height: number | null; + timestamp: number | null; +} + +interface Overview { + status: BitcoinStatus | null; + address: string | null; + balance: { confirmed: string; trustedPending: string; untrustedPending: string; immature: string } | null; + transactions: BitcoinTransaction[] | null; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function api(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + ...init, + headers: init?.body ? { 'Content-Type': 'application/json' } : undefined, + }); + const data = await res.json() as T & { ok?: boolean; error?: string }; + if (!res.ok || data.ok === false) throw new Error(data.error ?? `Request failed (${res.status})`); + return data; +} + +/** Format satoshi amount as BTC with at most 8 decimals, no float math. */ +function satsToBtc(sats: string | bigint): string { + const n = BigInt(sats); + const neg = n < 0n; + const abs = neg ? -n : n; + const whole = abs / 100_000_000n; + const frac = (abs % 100_000_000n).toString().padStart(8, '0').replace(/0+$/, ''); + return `${neg ? '-' : ''}${whole.toString()}${frac ? `.${frac}` : ''}`; +} + +const card = 'bg-gray-900 border border-gray-800 rounded-xl p-6'; +const input = 'w-full rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-600 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-mint-600'; +const primaryBtn = 'rounded-lg bg-mint-700 hover:bg-mint-600 px-4 py-2 text-sm font-semibold text-white transition-colors disabled:opacity-50'; + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string | null }) { + const [overview, setOverview] = useState(null); + const [loadError, setLoadError] = useState(null); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + const [newMnemonic, setNewMnemonic] = useState(null); + const [seedConfirmed, setSeedConfirmed] = useState(false); + + // Send form + const [sendTo, setSendTo] = useState(''); + const [sendAmount, setSendAmount] = useState(''); + const [sendError, setSendError] = useState(null); + const [sentTxid, setSentTxid] = useState(null); + const [addrSuggestion, setAddrSuggestion] = useState<{ corrected: string; fixedChars: number } | null>(null); + + const refresh = useCallback(async () => { + try { + setLoadError(null); + setOverview(await api('/api/bitcoin/overview')); + } catch (err) { + setLoadError((err as Error).message); + } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const walletExists = overview?.status?.walletExists ?? false; + + // ── Create / restore wallet ───────────────────────────────────────────────── + + async function createWallet(seed?: string) { + setBusy(true); + setNotice(null); + try { + const res = await api<{ mnemonic?: string }>('/api/bitcoin/wallet', { + method: 'POST', + body: JSON.stringify({ seed: seed ?? null }), + }); + setSeedConfirmed(false); + setNewMnemonic((res.mnemonic ?? '').split(/\s+/).filter(Boolean)); + await refresh(); + } catch (err) { + setNotice((err as Error).message); + } finally { + setBusy(false); + } + } + + function restoreWallet() { + const input = window.prompt('Enter your 12-word recovery phrase'); + if (!input) return; + createWallet(input.trim()); + } + + // ── Sync ──────────────────────────────────────────────────────────────────── + + async function triggerSync() { + setBusy(true); + try { + await api('/api/bitcoin/sync', { method: 'POST' }); + setNotice('Sync started - balances refresh in a few seconds.'); + setTimeout(refresh, 5000); + } catch (err) { + setNotice((err as Error).message); + } finally { + setBusy(false); + } + } + + // ── Send ──────────────────────────────────────────────────────────────────── + + async function submitSend(e: React.FormEvent) { + e.preventDefault(); + setSendError(null); + setSentTxid(null); + if (!sendAmount.trim() || !sendTo.trim()) { + setSendError('Address and amount are required.'); + return; + } + + // Typo recovery: checksum failure with a within-2-chars fix offers a + // suggestion instead of sending. Never auto-replaces the input. + const hrp = hrpForNetwork('btc', overview?.status?.network ?? ''); + if (hrp) { + const res = suggestAddressCorrection(sendTo.trim(), hrp, 'btc'); + if (res.status === 'corrected' && res.corrected) { + setAddrSuggestion({ corrected: res.corrected, fixedChars: res.fixedChars ?? 1 }); + setSendError('Address checksum failed — review the suggested correction.'); + return; + } + } + + setBusy(true); + try { + const res = await api<{ txid: string }>('/api/bitcoin/send', { + method: 'POST', + body: JSON.stringify({ address: sendTo.trim(), amount_btc: sendAmount.trim() }), + }); + setSentTxid(res.txid); + setSendTo(''); + setSendAmount(''); + setAddrSuggestion(null); + setTimeout(refresh, 4000); + } catch (err) { + setSendError((err as Error).message); + } finally { + setBusy(false); + } + } + + // ── Render ────────────────────────────────────────────────────────────────── + + if (loadError) { + return

{loadError}

; + } + if (!overview) { + return

Loading…

; + } + + const node = overview.status?.node; + const offline = !overview.status || !node?.reachable; + const balance = overview.balance; + const explorer = explorerUrl ?? null; + + return ( +
+ {/* Hot wallet warning */} +
+ BTC funds are held by a hot wallet on this machine. + Keep only spending amounts here and back up your seed phrase. +
+ + {/* Node status strip */} +
+ + + {offline + ? 'Node offline' + : node!.synced + ? `Node synced (#${node!.blocks.toLocaleString()})` + : `Syncing… #${node!.blocks.toLocaleString()} / #${node!.headers.toLocaleString()}`} + + {overview.status && ( + {overview.status.network} + )} + + {walletExists && ( + + )} +
+ + {offline && ( +
+

Bitcoin node is not running

+

+ Start the optional Bitcoin stack from the host: +

+ + docker compose --profile bitcoin up -d + +
+ )} + + {notice && ( +
{notice}
+ )} + + {/* ── No wallet yet ─────────────────────────────────────────────────────── */} + {overview.status && !walletExists && !newMnemonic && ( +
+

Create your BTC wallet

+

+ A new wallet generates a 12-word seed phrase. You can also restore an existing wallet. +

+
+ + +
+
+ )} + + {/* ── One-time seed display ─────────────────────────────────────────────── */} + {newMnemonic && ( +
+

Back up your seed phrase

+

+ Write these 12 words down and keep them safe. They are shown only once and + are the only way to recover your BTC wallet. +

+
+ {newMnemonic.map((word, i) => ( +
+ {i + 1} + {word} +
+ ))} +
+ + +
+ )} + + {/* ── Wallet ────────────────────────────────────────────────────────────── */} + {overview.status && walletExists && ( + <> + {/* Balance — the sidecar's "immature" bucket (unmatured coinbase + rewards) is intentionally not shown: it only applies to mined + rewards, which wallet users never receive. */} +
+
+

Confirmed

+

+ {balance ? satsToBtc(balance.confirmed) : '—'} BTC +

+
+
+

Pending

+

+ {balance ? satsToBtc(BigInt(balance.trustedPending) + BigInt(balance.untrustedPending)) : '—'} BTC +

+
+
+ + {/* Receive + Send */} +
+
+

Receive

+ {overview.address ? ( +
+ + + {overview.address} + + + {explorer && ( + + View in explorer ↗ + + )} +
+ ) : ( +

Wallet is loading…

+ )} +
+ +
+

Send

+ {sentTxid && ( +
+ Sent! Transaction {sentTxid} +
+ )} + {sendError && ( +
{sendError}
+ )} +
+
+ + { setSendTo(e.target.value); setAddrSuggestion(null); }} + placeholder="bc1…" + className={input} + required + /> +
+ {addrSuggestion && ( +
+ Did you mean{' '} + {addrSuggestion.corrected}? + (fixed {addrSuggestion.fixedChars} character{addrSuggestion.fixedChars === 1 ? '' : 's'}) + +
+ )} +
+ + setSendAmount(e.target.value)} + placeholder="0.005" + inputMode="decimal" + pattern="\d{1,8}(\.\d{1,8})?" + className={input} + required + /> +
+ +
+
+
+ + {/* Transactions */} +
+

Transactions

+ {!overview.transactions || overview.transactions.length === 0 ? ( +

No transactions yet.

+ ) : ( +
+ {overview.transactions.map(tx => { + const received = BigInt(tx.received) > BigInt(tx.sent); + const amount = BigInt(tx.received) - BigInt(tx.sent); + return ( +
+
+

+ {received ? '+' : ''} {satsToBtc(amount.toString())} BTC +

+ {explorer ? ( + + {tx.txid} ↗ + + ) : ( +

{tx.txid}

+ )} +
+
+

+ {tx.confirmed ? 'Confirmed' : 'Pending'} +

+ {tx.height !== null &&

#{tx.height.toLocaleString()}

} +
+
+ ); + })} +
+ )} +
+ + )} +
+ ); +} diff --git a/app/src/components/BridgePanel.tsx b/app/src/components/BridgePanel.tsx new file mode 100644 index 0000000..ad3e002 --- /dev/null +++ b/app/src/components/BridgePanel.tsx @@ -0,0 +1,556 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + createBridgeSdk, + type BridgeConfig, + type BridgeFees, + type BridgeRequestDetail, +} from '@/lib/bridge-sdk'; +import { + connectMetaMask, + depositToBridge, + getConnectedAccount, + getTokenBalance, + hasEthereumProvider, + switchChain, +} from '@/lib/evm'; +import { suggestAddressCorrection, hrpForNetwork } from '@/lib/bech32-correct'; + +const sdk = createBridgeSdk(); + +type Direction = 'e2m' | 'm2e'; +type Phase = + | 'idle' + | 'approve' + | 'deposit' + | 'signing' + | 'submitting' + | 'polling' + | 'done' + | 'failed'; + +interface Asset { + ticker: string; + mlTokenId: string; + ethAddress?: string; + maxAmountPerRequest?: string; +} + +const card = 'bg-gray-900 border border-gray-800 rounded-xl p-6'; +const input = + 'w-full rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-600 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-mint-600'; +const primaryBtn = + 'w-full rounded-lg bg-mint-700 hover:bg-mint-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; + +/** Fixed-point decimal total: fixed + amount × pct/100, at 18-digit scale. */ +function totalFee(fixed: string, amount: string, pctPercent: string): string | null { + try { + const toScaled = (s: string): bigint | null => { + const t = s.trim().replace(/%\s*$/, ''); + if (!/^\d+(\.\d+)?$/.test(t)) return null; + const [i, f = ''] = t.split('.'); + return BigInt(i + (f + '0'.repeat(18)).slice(0, 18)); + }; + const S = 10n ** 18n; + const f = toScaled(fixed); + const a = toScaled(amount); + const p = toScaled(pctPercent); + if (f === null || a === null || p === null) return null; + const total = f + (a * p) / (100n * S); // pct is a percentage: /100 overall + // format at scale 18, trim trailing zeros + const whole = total / S; + let frac = (total % S).toString().padStart(18, '0').replace(/0+$/, ''); + return `${whole.toString()}${frac ? `.${frac}` : ''}`; + } catch { + return null; + } +} + +/** Sepolia is the only non-mainnet flavor the bridge agents deploy to. */ +function chainForFlavor(flavor: string): { id: string; params: Record } | null { + if (flavor === 'sepolia') { + return { + id: '0xaa36a7', + params: { + chainName: 'Sepolia Testnet', + nativeCurrency: { name: 'Sepolia ETH', symbol: 'SEP', decimals: 18 }, + rpcUrls: ['https://sepolia.infura.io/v3/'], + blockExplorerUrls: ['https://sepolia.etherscan.io'], + }, + }; + } + return { id: '0x1', params: {} }; +} + +const STATUS_STEPS: Record = { + pending: { label: 'Agents processing deposit…', step: 1 }, + processed_by_master: { label: 'Signed by master agent — waiting for cosigner…', step: 2 }, + completed: { label: 'Bridge completed', step: 3 }, + failed: { label: 'Bridge failed', step: 3 }, + manual: { label: 'Escalated for manual review', step: 3 }, +}; + +export default function BridgePanel() { + const [config, setConfig] = useState(null); + const [fees, setFees] = useState(null); + const [configError, setConfigError] = useState(null); + + const [direction, setDirection] = useState('e2m'); + const [assetTicker, setAssetTicker] = useState(''); + const [amount, setAmount] = useState(''); + const [receiver, setReceiver] = useState(''); + const [receiverSuggestion, setReceiverSuggestion] = useState<{ corrected: string; fixedChars: number } | null>(null); + + const [ethAccount, setEthAccount] = useState(null); + const [ethBalance, setEthBalance] = useState(null); + const [connecting, setConnecting] = useState(false); + const [mlAddress, setMlAddress] = useState(null); + + const [phase, setPhase] = useState('idle'); + const [error, setError] = useState(null); + const [requestUuid, setRequestUuid] = useState(null); + const [requestStatus, setRequestStatus] = useState(null); + const pollRef = useRef | null>(null); + + const metamaskInstalled = hasEthereumProvider(); + + // ── Bridge config + fees ──────────────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [cfg, feeTable] = await Promise.all([sdk.getConfig(), sdk.getFees()]); + if (cancelled) return; + setConfig(cfg); + setFees(feeTable); + const network = cfg.network_type ?? 'mainnet'; + const flavorCfg = + cfg.eth_flavor_specific_config?.[network === 'mainnet' ? 'mainnet' : 'sepolia'] ?? + cfg.eth_flavor_specific_config?.[''] ?? + null; + const tickers = Object.keys(cfg.ml_tokens ?? {}); + setAssetTicker((prev) => prev || tickers[0] || ''); + void flavorCfg; + } catch (err) { + if (!cancelled) setConfigError((err as Error).message); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // ── Own ML address (receive side for E2M) ─────────────────────────────────── + const loadMlAddress = useCallback(async () => { + try { + const res = await fetch('/api/rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + method: 'address_show', + params: { account: 0, include_change_addresses: false }, + }), + }); + const data = (await res.json()) as { + ok: boolean; + result?: { address: string; used: boolean; purpose: string }[]; + }; + if (!data.ok || !Array.isArray(data.result)) return; + const first = data.result.find((a) => a.purpose === 'Receive' && !a.used) ?? data.result[0]; + if (first) setMlAddress(first.address); + } catch { + /* dashboard shows wallet errors elsewhere */ + } + }, []); + useEffect(() => { + loadMlAddress(); + }, [loadMlAddress]); + + // ── MetaMask session restore ──────────────────────────────────────────────── + useEffect(() => { + getConnectedAccount().then(setEthAccount); + }, []); + + const assets: Asset[] = useMemo(() => { + if (!config) return []; + const network = config.network_type === 'mainnet' ? 'mainnet' : 'sepolia'; + const flavors = config.eth_flavor_specific_config ?? {}; + const flavorCfg = flavors[network] ?? flavors[''] ?? null; + const tokenConfig = flavorCfg?.token_config ?? {}; + return Object.keys(config.ml_tokens ?? {}).map((ticker) => ({ + ticker, + mlTokenId: config.ml_tokens[ticker], + ethAddress: tokenConfig[ticker]?.address, + maxAmountPerRequest: tokenConfig[ticker]?.max_amount_per_request, + })); + }, [config]); + + const networkType = config?.network_type ?? 'mainnet'; + const flavor = networkType === 'mainnet' ? '' : 'sepolia'; + const sourceChain = direction === 'e2m' ? `Ethereum${flavor ? `-${flavor}` : ''}` : 'Mintlayer'; + const destinationChain = direction === 'e2m' ? 'Mintlayer' : `Ethereum${flavor ? `-${flavor}` : ''}`; + const asset = assets.find((a) => a.ticker === assetTicker) ?? null; + const assetFees = fees?.[assetTicker]; + const directionFees = direction === 'e2m' ? assetFees?.to_ml : assetFees?.to_eth; + + async function connectWallet() { + setConnecting(true); + setError(null); + try { + await switchChainIfNeeded(); + const address = await connectMetaMask(); + setEthAccount(address); + // Show the bridged-token balance for the selected asset + if (asset?.ethAddress) { + setEthBalance(await getTokenBalance(asset.ethAddress, address).catch(() => null)); + } + } catch (err) { + setError((err as Error).message); + } finally { + setConnecting(false); + } + } + + async function switchChainIfNeeded() { + const chain = chainForFlavor(flavor); + if (!chain) return; + await switchChain(chain.id, chain.params); + } + + function stopPolling() { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + } + + function pollRequest(uuid: string) { + stopPolling(); + setPhase('polling'); + pollRef.current = setInterval(async () => { + try { + const detail: BridgeRequestDetail = await sdk.getBridgeRequest(uuid); + const status = String(detail.status ?? 'pending'); + setRequestStatus(status); + if (status === 'completed' || status === 'failed' || status === 'manual') { + stopPolling(); + setPhase(status === 'completed' ? 'done' : 'failed'); + } + } catch { + /* transient poll errors are retried on the next tick */ + } + }, 10_000); + } + + useEffect(() => stopPolling, []); + + async function submit() { + setError(null); + setReceiverSuggestion(null); + if (!asset) { + setError('No bridged asset selected.'); + return; + } + if (!amount.trim() || !receiver.trim()) { + setError(direction === 'e2m' ? 'Amount and Mintlayer address are required.' : 'Amount and Ethereum address are required.'); + return; + } + if (!/^\d+(\.\d+)?$/.test(amount.trim())) { + setError('Amount must be a positive decimal number.'); + return; + } + if (direction === 'm2e' && !/^0x[0-9a-fA-F]{40}$/.test(receiver.trim())) { + setError('Receiver must be a valid 0x… Ethereum address.'); + return; + } + + // E2M: the ML receiver can be typo-corrected via the bech32 checksum. + if (direction === 'e2m') { + const hrp = hrpForNetwork('ml', networkType); + if (hrp) { + const res = suggestAddressCorrection(receiver.trim(), hrp, 'ml'); + if (res.status === 'corrected' && res.corrected) { + setReceiverSuggestion({ corrected: res.corrected, fixedChars: res.fixedChars ?? 1 }); + setError('Address checksum failed — review the suggested correction.'); + return; + } + if (res.status === 'invalid') { + setError('Invalid Mintlayer address.'); + return; + } + } + } + + try { + if (direction === 'e2m') { + // ── Ethereum → Mintlayer ── + if (!ethAccount) throw new Error('Connect MetaMask first.'); + if (!asset.ethAddress) throw new Error('No Ethereum token configured for this asset.'); + await switchChainIfNeeded(); + const txHash = await depositToBridge( + config?.eth_flavor_specific_config?.[flavor]?.bridge_contract_address ?? + config?.eth_flavor_specific_config?.['']?.bridge_contract_address ?? + '', + asset.ethAddress, + amount.trim(), + 18, + receiver.trim(), + setPhase, + ); + setPhase('submitting'); + const res = await sdk.broadcastBridgeRequest({ + source_chain: sourceChain, + destination_chain: destinationChain, + asset: asset.ticker, + amount: amount.trim(), + receiver_address: receiver.trim(), + deposit_transactions: [{ transaction_hash: txHash }], + }); + setRequestUuid(res.bridge_request_uuid); + pollRequest(res.bridge_request_uuid); + } else { + // ── Mintlayer → Ethereum ── + if (!ethAccount) throw new Error('Connect MetaMask first (it is the receiver).'); + setPhase('signing'); + const signed = await fetch('/api/bridge/ml-intent-tx', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token_id: asset.mlTokenId, + amount: amount.trim(), + intent: receiver.trim(), + }), + }).then((r) => r.json() as Promise<{ ok: boolean; error?: string; raw_transaction?: string; intent?: string }>); + if (!signed.ok || !signed.raw_transaction || !signed.intent) { + throw new Error(signed.error ?? 'Failed to create the bridge transaction.'); + } + setPhase('submitting'); + const res = await sdk.broadcastBridgeRequest({ + source_chain: sourceChain, + destination_chain: destinationChain, + asset: asset.ticker, + amount: amount.trim(), + receiver_address: receiver.trim(), + deposit_transactions: [ + { raw_transaction: signed.raw_transaction, intent: signed.intent }, + ], + }); + setRequestUuid(res.bridge_request_uuid); + pollRequest(res.bridge_request_uuid); + } + } catch (err) { + const e = err as { code?: number; message?: string }; + setError(e.message ?? 'Bridge request failed.'); + setPhase('idle'); + } + } + + const busy = phase === 'approve' || phase === 'deposit' || phase === 'signing' || phase === 'submitting' || phase === 'polling'; + const receiverLabel = direction === 'e2m' ? 'Your Mintlayer address (receiver)' : 'Your Ethereum address (receiver)'; + const receiverValue = direction === 'e2m' ? mlAddress ?? '' : ethAccount ?? ''; + + const phaseLabel = + phase === 'approve' + ? 'Waiting for ERC20 approval in MetaMask…' + : phase === 'deposit' + ? 'Waiting for deposit confirmation in MetaMask…' + : phase === 'signing' + ? 'Signing the bridge transaction with your ML wallet…' + : phase === 'submitting' + ? 'Submitting the bridge request…' + : phase === 'polling' + ? (requestStatus ? (STATUS_STEPS[requestStatus]?.label ?? `Status: ${requestStatus}`) : 'Agents are processing…') + : phase === 'done' + ? 'Bridge completed — tokens arrived.' + : phase === 'failed' + ? 'Bridge failed.' + : ''; + + return ( +
+ {configError && ( +
+ Bridge service unreachable: {configError} +
+ )} + + {/* ── Direction toggle ── */} +
+ {( + [ + { key: 'e2m', label: 'Ethereum → Mintlayer', sub: 'Deposit ERC20, receive ML tokens' }, + { key: 'm2e', label: 'Mintlayer → Ethereum', sub: 'Bridge ML tokens back to ERC20' }, + ] as const + ).map(({ key, label, sub }) => ( + + ))} +
+ + {/* ── Ethereum side ── */} +
+
+

Ethereum wallet

+ {ethAccount ? ( + + {ethAccount.slice(0, 6)}…{ethAccount.slice(-4)} + {ethBalance !== null && ` · ${ethBalance} ${assetTicker}`} + + ) : null} +
+ {ethAccount ? ( +

MetaMask connected.

+ ) : metamaskInstalled ? ( + + ) : ( +

+ MetaMask not detected.{' '} + + Install MetaMask + {' '} + to bridge ERC20 tokens. +

+ )} +
+ + {/* ── Bridge form ── */} +
+
+
+ + +
+
+ + setAmount(e.target.value)} + placeholder="0.00" + inputMode="decimal" + className={input} + /> + {directionFees && ( +

+ {(() => { + const pct = directionFees.percentage_fee ?? ''; + const amountValid = /^\d+(\.\d+)?$/.test(amount.trim()); + const total = amountValid + ? totalFee(directionFees.fixed_fee, amount.trim(), pct) + : null; + const parts = [ + `fixed ${directionFees.fixed_fee} ${assetTicker}`, + pct ? ` + ${pct}${pct.endsWith('%') ? '' : '%'} of amount` : '', + ]; + return total !== null + ? `Total fee ${total} ${assetTicker} (${parts.join('')})` + : `Fee: fixed ${directionFees.fixed_fee} ${assetTicker}${pct ? ` + ${pct}` : ''}`; + })()} +

+ )} +
+
+ +
+ + {direction === 'e2m' ? ( + { setReceiver(e.target.value); setReceiverSuggestion(null); }} + placeholder="tmt1…" + className={input} + /> + ) : ( + { setReceiver(e.target.value); setReceiverSuggestion(null); }} + placeholder="0x…" + className={input} + /> + )} + {receiverSuggestion && ( +
+ Did you mean{' '} + {receiverSuggestion.corrected}? + (fixed {receiverSuggestion.fixedChars} character{receiverSuggestion.fixedChars === 1 ? '' : 's'}) + +
+ )} + {direction === 'e2m' && mlAddress && !receiver && ( + + )} + {direction === 'm2e' && ethAccount && !receiver && ( + + )} +
+ + {error && ( +
{error}
+ )} + {phase !== 'idle' && phaseLabel && ( +
+ {phaseLabel} + {requestUuid && ( +

request {requestUuid}

+ )} +
+ )} + + {!ethAccount ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/app/src/layouts/Layout.astro b/app/src/layouts/Layout.astro index e36c39d..64bc95f 100644 --- a/app/src/layouts/Layout.astro +++ b/app/src/layouts/Layout.astro @@ -5,6 +5,8 @@ import CommandPalette from '@/components/CommandPalette'; import type { NavItem } from '@/components/CommandPalette'; import { getEnabledPlugins } from '@/lib/plugins'; import type { PluginManifest } from '@/lib/plugins'; +import { isBitcoinEnabled } from '@/lib/bitcoin-wallet'; +import { getPref } from '@/lib/prefs-db'; export interface Props { title?: string; @@ -14,6 +16,13 @@ export interface Props { const { title = 'Home', activeNav } = Astro.props; const network = (process.env.NETWORK ?? 'mainnet').toLowerCase(); const indexerEnabled = process.env.INDEXER_ENABLED === 'true'; +const bitcoinEnabled = isBitcoinEnabled() && getBitcoinShowUi(); +// The bridge agents run on Mintlayer mainnet only — hide the section elsewhere. +const bridgeEnabled = process.env.NETWORK === 'mainnet'; + +function getBitcoinShowUi(): boolean { + try { return getPref('bitcoin.show_ui') !== false; } catch { return true; } +} import pkg from '../../package.json'; const version = pkg.version; @@ -40,6 +49,12 @@ const navItems: NavItem[] = [ { key: 'token-management', label: 'Token Management', href: '/token-management', section: 'ASSETS' }, { key: 'trading', label: 'Trading', href: '/trading', section: 'TRADE' }, ] : []), + ...(bitcoinEnabled ? [ + { key: 'bitcoin', label: 'Bitcoin', href: '/bitcoin', section: 'WALLET' }, + ] : []), + ...(bridgeEnabled ? [ + { key: 'bridge', label: 'Bridge', href: '/bridge', section: 'WALLET' }, + ] : []), { key: 'management', label: 'Settings', href: '/management', section: 'SETTINGS' }, ...enabledPlugins.map(p => ({ key: p.id, @@ -126,6 +141,22 @@ const PLUGIN_ICON_DEFAULT = 'M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.8 Staking + {bridgeEnabled && ( + + + + + Bridge + + )} + {bitcoinEnabled && ( + + + + + Bitcoin + + )} {pluginsBySection.wallet.map(({ id, navLabel, navIcon }) => ( diff --git a/app/src/lib/bech32-correct.test.ts b/app/src/lib/bech32-correct.test.ts new file mode 100644 index 0000000..61db076 --- /dev/null +++ b/app/src/lib/bech32-correct.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest"; +import { suggestAddressCorrection, hrpForNetwork } from "@/lib/bech32-correct"; + +// Live addresses: ML testnet from the wallet daemon, BTC regtest from bitcoind. +const ML = "tmt1q9rlgx4dsse920z35xh5d8s5ydlj3xl7gqeze89c"; +const BTC_V0 = "bcrt1q2kyaddhscv9vpjgnmx7ldsd3uj3u7crg0j2nxj"; + +// bech32m v1 (taproot-style) address, 32-byte program under bcrt +function toBech32mV1(hrp: string): string { + const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + const polymod = (values: number[]) => { + let chk = 1; + for (const v of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GEN[i]; + } + return chk; + }; + const hrpExpand = (hrp: string) => { + const r: number[] = []; + for (const c of hrp) r.push(c.charCodeAt(0) >> 5); + r.push(0); + for (const c of hrp) r.push(c.charCodeAt(0) & 31); + return r; + }; + const program = Uint8Array.from(Buffer.from("751e76e8199196d454941c45d1b3a323f1433bd6751e76e8199196d454941c45", "hex")); + const data5 = [1]; + let acc = 0, bits = 0; + for (const b of program) { + acc = (acc << 8) | b; bits += 8; + while (bits >= 5) { data5.push((acc >> (bits - 5)) & 31); bits -= 5; } + } + if (bits) data5.push((acc << (5 - bits)) & 31); + const values = hrpExpand(hrp).concat(data5); + const chk = polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ 0x2bc830a3; + const cs: number[] = []; + for (let i = 0; i < 6; i++) cs.push((chk >> (5 * (5 - i))) & 31); + return hrp + "1" + data5.concat(cs).map((d) => CHARSET[d]).join(""); +} +const BTC_V1 = toBech32mV1("bcrt"); + +describe("suggestAddressCorrection", () => { + it("accepts valid addresses as-is", () => { + expect(suggestAddressCorrection(ML, "tmt", "ml")).toEqual({ status: "valid" }); + expect(suggestAddressCorrection(BTC_V0, "bcrt", "btc")).toEqual({ status: "valid" }); + expect(suggestAddressCorrection(BTC_V1, "bcrt", "btc")).toEqual({ status: "valid" }); + }); + + it("corrects 1 substitution on ML", () => { + const typo = ML.slice(0, 30) + "f" + ML.slice(31); + const r = suggestAddressCorrection(typo, "tmt", "ml"); + expect(r).toEqual({ status: "corrected", corrected: ML, fixedChars: 1 }); + }); + + it("corrects 1 substitution on BTC", () => { + const typo = BTC_V0.replace("kya", "kyd"); + const r = suggestAddressCorrection(typo, "bcrt", "btc"); + expect(r).toEqual({ status: "corrected", corrected: BTC_V0, fixedChars: 1 }); + }); + + it("corrects 2 substitutions on ML and BTC", () => { + const mlTypo = ML.slice(0, 10) + (ML[10] === "2" ? "3" : "2") + ML.slice(11, 30) + (ML[30] === "e" ? "f" : "e") + ML.slice(31); + expect(suggestAddressCorrection(mlTypo, "tmt", "ml")).toEqual({ + status: "corrected", corrected: ML, fixedChars: 2, + }); + const btcTypo = BTC_V0.slice(0, 8) + (BTC_V0[8] === "k" ? "m" : "k") + BTC_V0.slice(9, 25) + (BTC_V0[25] === "g" ? "h" : "g") + BTC_V0.slice(26); + expect(suggestAddressCorrection(btcTypo, "bcrt", "btc")).toEqual({ + status: "corrected", corrected: BTC_V0, fixedChars: 2, + }); + }); + + it("corrects 1 substitution on a bech32m v1 address", () => { + const typo = BTC_V1.slice(0, 10) + (BTC_V1[10] === "p" ? "q" : "p") + BTC_V1.slice(11); + const r = suggestAddressCorrection(typo, "bcrt", "btc"); + expect(r).toEqual({ status: "corrected", corrected: BTC_V1, fixedChars: 1 }); + }); + + it("cannot correct 3 substitutions", () => { + const typo = ML.slice(0, 5) + "abc" + ML.slice(8); + const r = suggestAddressCorrection(typo, "tmt", "ml"); + expect(r.status).toBe("invalid"); + expect(r.corrected).toBeUndefined(); + }); + + it("cannot correct insertions or deletions", () => { + expect(suggestAddressCorrection(ML.replace("dsse", "dse"), "tmt", "ml").status).toBe("invalid"); + expect(suggestAddressCorrection(ML.slice(0, 8) + "q" + ML.slice(8), "tmt", "ml").status).toBe("invalid"); + }); + + it("rejects charset violations without hallucinating a fix", () => { + // '1' inside the data part breaks separator detection + const typo = BTC_V0.slice(0, 12) + "1" + BTC_V0.slice(13); + expect(suggestAddressCorrection(typo, "bcrt", "btc").status).toBe("invalid"); + }); + + it("rejects mixed case", () => { + expect(suggestAddressCorrection(BTC_V0.slice(0, 5) + "Q" + BTC_V0.slice(6), "bcrt", "btc").status).toBe("invalid"); + }); + + it("rejects wrong-network hrp", () => { + expect(suggestAddressCorrection(BTC_V0, "tmt", "ml").status).toBe("invalid"); + }); + + it("repairs a corrupted witness-version symbol on bech32m v1", () => { + // first program symbol is the version marker (p=1); typo to q (0) and + // expect the original v1 address to be recovered + const typo = BTC_V1.slice(0, 5) + "q" + BTC_V1.slice(6); + const r = suggestAddressCorrection(typo, "bcrt", "btc"); + expect(r).toEqual({ status: "corrected", corrected: BTC_V1, fixedChars: 1 }); + }); + + it("rejects v0 addresses with invalid program length even when checksummed", () => { + // build a bech32 (v0) address with a 37-symbol program (invalid: v0 must + // be 20 or 32 bytes); its checksum is valid, so only the payload rule + // rejects it + const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + const polymod = (values: number[]) => { + let chk = 1; + for (const v of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GEN[i]; + } + return chk; + }; + const hrpExpand = (hrp: string) => { + const r: number[] = []; + for (const c of hrp) r.push(c.charCodeAt(0) >> 5); + r.push(0); + for (const c of hrp) r.push(c.charCodeAt(0) & 31); + return r; + }; + const payload = [0, ...new Array(37).fill(0)]; // v0 + 37-symbol program + const values = hrpExpand("bcrt").concat(payload); + const chk = polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ 1; + const cs: number[] = []; + for (let i = 0; i < 6; i++) cs.push((chk >> (5 * (5 - i))) & 31); + const addr = "bcrt" + "1" + payload.concat(cs).map((d) => CHARSET[d]).join(""); + expect(suggestAddressCorrection(addr, "bcrt", "btc").status).toBe("invalid"); + }); +}); + +describe("hrpForNetwork", () => { + it("maps BTC networks", () => { + expect(hrpForNetwork("btc", "mainnet")).toBe("bc"); + expect(hrpForNetwork("btc", "testnet")).toBe("tb"); + expect(hrpForNetwork("btc", "regtest")).toBe("bcrt"); + expect(hrpForNetwork("btc", "signet")).toBe("sb"); + }); + + it("maps ML networks", () => { + expect(hrpForNetwork("ml", "mainnet")).toBe("mtc"); + expect(hrpForNetwork("ml", "testnet")).toBe("tmt"); + }); + + it("returns null for unknown networks", () => { + expect(hrpForNetwork("ml", "unknown")).toBeNull(); + expect(hrpForNetwork("btc", "unknown")).toBeNull(); + }); +}); diff --git a/app/src/lib/bech32-correct.ts b/app/src/lib/bech32-correct.ts new file mode 100644 index 0000000..e262465 --- /dev/null +++ b/app/src/lib/bech32-correct.ts @@ -0,0 +1,196 @@ +/** + * Bech32/bech32m address typo correction (BIP-173/BIP-350). + * + * The bech32 checksum is a 30-bit BCH code that can LOCATE up to two symbol + * errors. When a pasted/typed address fails its checksum, this module tries + * to find a correction within that distance and returns it for explicit + * user confirmation - it never mutates the input silently. + * + * Ported from the BIP-173 reference (sipa/bech32) and validated against + * live Mintlayer (tmt, bech32m) and Bitcoin (bcrt, bech32 + bech32m) + * addresses. + */ + +const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; +const CONSTS = { bech32: 1, bech32m: 0x2bc830a3 } as const; + +export type Chain = "btc" | "ml"; + +export interface CorrectionResult { + /** 'valid' - input passes its checksum as typed */ + /** 'corrected' - checksum failed, but a fix within 2 character errors was found */ + /** 'invalid' - uncorrectable (charset violation, wrong length change, 3+ errors, wrong hrp) */ + status: "valid" | "corrected" | "invalid"; + /** The corrected address - present only when status === 'corrected'. Require explicit user confirmation before use. */ + corrected?: string; + /** How many characters were fixed (1 or 2) */ + fixedChars?: number; + reason?: string; +} + +function polymod(values: number[]): number { + let chk = 1; + for (const v of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GEN[i]; + } + return chk; +} + +function hrpExpand(hrp: string): number[] { + const ret: number[] = []; + for (const c of hrp) ret.push(c.charCodeAt(0) >> 5); + ret.push(0); + for (const c of hrp) ret.push(c.charCodeAt(0) & 31); + return ret; +} + +function reencode(hrp: string, payload: number[], encoding: "bech32" | "bech32m"): string { + const values = hrpExpand(hrp).concat(payload); + const chk = polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ CONSTS[encoding]; + const cs: number[] = []; + for (let i = 0; i < 6; i++) cs.push((chk >> (5 * (5 - i))) & 31); + return hrp + "1" + payload.concat(cs).map((d) => CHARSET[d]).join(""); +} + +function isValidChainPayload(hrp: string, data: number[], encoding: "bech32" | "bech32m", chain: Chain): boolean { + // `data` includes the 6-symbol checksum: [version, ...program, checksum] + const version = data[0]; + if (version === undefined || version > 16) return false; + if (chain === "btc") { + const programLen = data.length - 7; + if (version === 0) { + if (encoding !== "bech32") return false; + if (programLen !== 20 && programLen !== 32) return false; + } else { + if (encoding !== "bech32m") return false; + // program length in symbols: 2..40 bytes -> ceil(bytes*8/5) symbols + if (programLen < Math.ceil((2 * 8) / 5) || programLen > Math.ceil((40 * 8) / 5)) return false; + // BIP-173: the final converted group's padding bits must be zero + const bits = programLen * 5; + const padBits = bits - Math.floor(bits / 8) * 8; + if (padBits > 0 && (data[programLen] & ((1 << padBits) - 1)) !== 0) return false; + } + } + // ML: final payload validation is the wallet daemon's job on submit. + return true; +} + +/** Per-network HRPs. */ +export function hrpForNetwork(chain: Chain, network: string): string | null { + if (chain === "btc") { + switch (network) { + case "mainnet": return "bc"; + case "testnet": return "tb"; + case "regtest": return "bcrt"; + case "signet": return "sb"; + default: return null; + } + } + switch (network) { + case "mainnet": return "mtc"; + case "testnet": return "tmt"; + default: return null; // ML regtest/signet hrps unverified - daemon validates + } +} + +/** + * Check an address for checksum errors and attempt up to 2-character recovery. + * `expectedHrp` scopes the address to one network (strongly recommended). + * For BTC, segwit version/program rules are enforced on corrected candidates. + */ +export function suggestAddressCorrection( + address: string, + expectedHrp: string, + chain: Chain, +): CorrectionResult { + if (address !== address.toLowerCase() && address !== address.toUpperCase()) { + return { status: "invalid", reason: "mixed case" }; + } + const lower = address.toLowerCase(); + if (!/^[\x21-\x7e]+$/.test(lower)) { + return { status: "invalid", reason: "invalid characters" }; + } + + // separator = last '1' such that the prefix equals the expected hrp + let sep = -1; + let idx = lower.indexOf(expectedHrp + "1"); + while (idx !== -1) { + sep = idx + expectedHrp.length; + idx = lower.indexOf(expectedHrp + "1", idx + 1); + } + if (sep < 1 || sep + 7 > lower.length) { + return { status: "invalid", reason: "bad separator or hrp" }; + } + const hrp = lower.slice(0, sep); + const data = [...lower.slice(sep + 1)].map((c) => CHARSET.indexOf(c)); + if (data.some((d) => d < 0)) { + return { status: "invalid", reason: "invalid character" }; + } + + const hrpExp = hrpExpand(hrp); + const values = hrpExp.concat(data); + const n = values.length; + const dataStart = hrpExp.length; + + for (const encoding of ["bech32", "bech32m"] as const) { + const target = CONSTS[encoding]; + const chk = polymod(values) ^ target; + + if (chk === 0 && isValidChainPayload(hrp, data, encoding, chain)) { + return { status: "valid" }; + } + + // polymod is affine over GF(2) (initial chk=1): the linear response of a + // single-symbol delta is polymod(delta) ^ polymod(zeros). Without + // removing that constant, error syndromes never match. + const c0 = polymod(new Array(n).fill(0)); + + // single-error syndromes for all data positions x magnitudes + const single = new Map(); + for (let pos = dataStart; pos < n; pos++) { + for (let m = 1; m < 32; m++) { + const delta = new Array(n).fill(0); + delta[pos] = m; + const d = polymod(delta) ^ c0; + if (!single.has(d)) single.set(d, [pos, m]); + } + } + + // 1-error correction + for (const [d, [pos, m]] of single) { + if ((chk ^ d) === 0) { + const fixed = data.slice(); + fixed[pos - dataStart] ^= m; + if (isValidChainPayload(hrp, fixed, encoding, chain)) { + const corrected = reencode(hrp, fixed.slice(0, -6), encoding); + if (corrected !== lower) { + return { status: "corrected", corrected, fixedChars: 1 }; + } + } + } + } + + // 2-error correction: fix one candidate, look up the remainder + for (const [d1, [p1, m1]] of single) { + const rest = chk ^ d1; + if (rest === 0) continue; + const hit = single.get(rest); + if (hit && hit[0] !== p1) { + const fixed = data.slice(); + fixed[p1 - dataStart] ^= m1; + fixed[hit[0] - dataStart] ^= hit[1]; + if (isValidChainPayload(hrp, fixed, encoding, chain)) { + const corrected = reencode(hrp, fixed.slice(0, -6), encoding); + if (corrected !== lower) { + return { status: "corrected", corrected, fixedChars: 2 }; + } + } + } + } + } + + return { status: "invalid", reason: "checksum mismatch (cannot auto-correct)" }; +} diff --git a/app/src/lib/bitcoin-wallet.test.ts b/app/src/lib/bitcoin-wallet.test.ts new file mode 100644 index 0000000..191437f --- /dev/null +++ b/app/src/lib/bitcoin-wallet.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + process.env.BITCOIN_ENABLED = 'true'; + process.env.BITCOIN_WALLET_URL = 'http://bdk-wallet:8080'; + process.env.BITCOIN_WALLET_USERNAME = 'gui'; + process.env.BITCOIN_WALLET_PASSWORD = 'guipass'; +}); + +afterEach(() => { + mockFetch.mockReset(); + vi.unstubAllGlobals(); + delete process.env.BITCOIN_ENABLED; + delete process.env.BITCOIN_WALLET_URL; + delete process.env.BITCOIN_WALLET_USERNAME; + delete process.env.BITCOIN_WALLET_PASSWORD; +}); + +describe('bitcoin-wallet client', () => { + it('reports enabled only when BITCOIN_ENABLED=true', async () => { + const mod = await import('@/lib/bitcoin-wallet'); + expect(mod.isBitcoinEnabled()).toBe(true); + process.env.BITCOIN_ENABLED = 'false'; + expect(mod.isBitcoinEnabled()).toBe(false); + delete process.env.BITCOIN_ENABLED; + expect(mod.isBitcoinEnabled()).toBe(false); + }); + + it('sends basic auth to the sidecar', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, satPerVb: { '6': 12 } }), { status: 200 }), + ); + const { getBitcoinFeeEstimate } = await import('@/lib/bitcoin-wallet'); + await getBitcoinFeeEstimate(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://bdk-wallet:8080/fee-estimate'); + const auth = (init.headers as Record).Authorization; + expect(auth).toBe(`Basic ${Buffer.from('gui:guipass').toString('base64')}`); + }); + + it('getBitcoinStatus parses the sidecar payload', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + network: 'mainnet', + walletExists: true, + walletLoaded: true, + node: { reachable: true, blocks: 900000, headers: 900000, synced: true, initialBlockDownload: false }, + balance: { confirmed: '1000', trustedPending: '0', untrustedPending: '0', immature: '0' }, + }), + { status: 200 }, + ), + ); + const { getBitcoinStatus } = await import('@/lib/bitcoin-wallet'); + const status = await getBitcoinStatus(); + expect(status.network).toBe('mainnet'); + expect(status.walletLoaded).toBe(true); + expect(status.balance?.confirmed).toBe('1000'); + expect(status.node.synced).toBe(true); + }); + + it('createBitcoinWallet posts the optional seed and returns the mnemonic', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, created: true, network: 'testnet', mnemonic: 'a b c' }), { status: 200 }), + ); + const { createBitcoinWallet } = await import('@/lib/bitcoin-wallet'); + const res = await createBitcoinWallet('a b c'); + expect(res.mnemonic).toBe('a b c'); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.body as string)).toBe(JSON.stringify({ seed: 'a b c' })); + }); + + it('sendBitcoin maps camelCase args to the sidecar snake_case payload', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, txid: 'abc123' }), { status: 200 }), + ); + const { sendBitcoin } = await import('@/lib/bitcoin-wallet'); + const res = await sendBitcoin({ address: 'bc1qxyz', amountBtc: '0.5', feeRateSatVb: 12 }); + expect(res.txid).toBe('abc123'); + expect(JSON.parse(mockFetch.mock.calls[0][1]!.body as string)).toEqual({ + address: 'bc1qxyz', + amount_btc: '0.5', + fee_rate_sat_vb: 12, + }); + }); + + it('throws BitcoinWalletError with sidecar error message', async () => { + mockFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: false, error: 'wallet already exists' }), { status: 409 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: false, error: 'wallet already exists' }), { status: 409 }), + ); + const { createBitcoinWallet, BitcoinWalletError } = await import('@/lib/bitcoin-wallet'); + await expect(createBitcoinWallet()).rejects.toThrow(BitcoinWalletError); + await expect(createBitcoinWallet()).rejects.toThrow('wallet already exists'); + }); + + it('wraps network failures in a 503 BitcoinWalletError', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + const { getBitcoinStatus, BitcoinWalletError } = await import('@/lib/bitcoin-wallet'); + const err = await getBitcoinStatus().catch((e) => e); + expect(err).toBeInstanceOf(BitcoinWalletError); + expect(err.status).toBe(503); + expect(err.message).toMatch(/unreachable/); + }); + + it('listBitcoinTransactions passes the limit param', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, transactions: [] }), { status: 200 }), + ); + const { listBitcoinTransactions } = await import('@/lib/bitcoin-wallet'); + await listBitcoinTransactions(25); + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://bdk-wallet:8080/txs?limit=25'); + }); + + describe('getBitcoinExplorerUrl', () => { + beforeEach(() => { + delete process.env.BITCOIN_EXPLORER_URL; + delete process.env.BITCOIN_NETWORK; + process.env.NETWORK = 'mainnet'; + }); + afterEach(() => { + delete process.env.BITCOIN_EXPLORER_URL; + delete process.env.BITCOIN_NETWORK; + delete process.env.NETWORK; + }); + + it('maps public networks to mempool.space', async () => { + const { getBitcoinExplorerUrl } = await import('@/lib/bitcoin-wallet'); + process.env.BITCOIN_NETWORK = 'mainnet'; + expect(getBitcoinExplorerUrl()).toBe('https://mempool.space'); + process.env.BITCOIN_NETWORK = 'testnet'; + expect(getBitcoinExplorerUrl()).toBe('https://mempool.space/testnet'); + process.env.BITCOIN_NETWORK = 'signet'; + expect(getBitcoinExplorerUrl()).toBe('https://mempool.space/signet'); + }); + + it('falls back to NETWORK when BITCOIN_NETWORK is unset', async () => { + const { getBitcoinExplorerUrl } = await import('@/lib/bitcoin-wallet'); + process.env.NETWORK = 'testnet'; + expect(getBitcoinExplorerUrl()).toBe('https://mempool.space/testnet'); + }); + + it('points regtest at the self-hosted explorer', async () => { + const { getBitcoinExplorerUrl } = await import('@/lib/bitcoin-wallet'); + process.env.BITCOIN_NETWORK = 'regtest'; + expect(getBitcoinExplorerUrl()).toBe('http://localhost:3002'); + }); + + it('BITCOIN_EXPLORER_URL overrides everything and strips trailing slashes', async () => { + const { getBitcoinExplorerUrl } = await import('@/lib/bitcoin-wallet'); + process.env.BITCOIN_EXPLORER_URL = 'https://my-explorer.example///'; + expect(getBitcoinExplorerUrl()).toBe('https://my-explorer.example'); + }); + }); +}); diff --git a/app/src/lib/bitcoin-wallet.ts b/app/src/lib/bitcoin-wallet.ts new file mode 100644 index 0000000..ab2f8d1 --- /dev/null +++ b/app/src/lib/bitcoin-wallet.ts @@ -0,0 +1,211 @@ +/** + * BTC wallet client - server-side only. + * + * Talks to the BDK wallet sidecar (optional Docker Compose profile "bitcoin"), + * which holds the BTC keys and uses bitcoind for chain data + broadcast. + * + * Base URL is configured via BITCOIN_WALLET_URL env var + * (default http://bdk-wallet:8080, the internal Docker network name). + * All amounts are satoshi strings - never floats. + */ + +const BITCOIN_WALLET_URL = + process.env.BITCOIN_WALLET_URL ?? 'http://bdk-wallet:8080'; + +const BITCOIN_WALLET_USERNAME = + process.env.BITCOIN_WALLET_USERNAME ?? ''; + +const BITCOIN_WALLET_PASSWORD = + process.env.BITCOIN_WALLET_PASSWORD ?? ''; + +export const BITCOIN_START_CMD = 'docker compose --profile bitcoin up -d'; + +/** + * Block explorer base URL for tx/address links. + * + * Resolution order: + * 1. BITCOIN_EXPLORER_URL env override (used verbatim for all networks) + * 2. public mempool.space for mainnet/testnet/signet + * 3. the self-hosted btc-rpc-explorer sidecar for regtest (no public + * explorer exists; the compose profile publishes it on localhost:3002) + */ +export function getBitcoinExplorerUrl(): string | null { + const override = process.env.BITCOIN_EXPLORER_URL?.trim(); + if (override) return override.replace(/\/+$/, ''); + const network = (process.env.BITCOIN_NETWORK || process.env.NETWORK || 'mainnet').toLowerCase(); + switch (network) { + case 'testnet': return 'https://mempool.space/testnet'; + case 'signet': return 'https://mempool.space/signet'; + case 'regtest': return 'http://localhost:3002'; + case 'mainnet': return 'https://mempool.space'; + default: return null; + } +} + +/** Feature flag: the bitcoin profile was enabled at init/deploy time. */ +export function isBitcoinEnabled(): boolean { + return process.env.BITCOIN_ENABLED === 'true'; +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface BitcoinBalance { + confirmed: string; + trustedPending: string; + untrustedPending: string; + immature: string; + total?: string; +} + +export interface BitcoinNodeInfo { + reachable: boolean; + blocks: number; + headers: number; + synced: boolean; + initialBlockDownload: boolean; +} + +export interface BitcoinStatus { + ok: boolean; + network: 'mainnet' | 'testnet' | 'regtest' | 'signet' | 'unknown'; + walletExists: boolean; + walletLoaded: boolean; + node: BitcoinNodeInfo; + balance: BitcoinBalance | null; +} + +export interface BitcoinTransaction { + txid: string; + received: string; + sent: string; + fee: string | null; + confirmed: boolean; + height: number | null; + timestamp: number | null; +} + +export interface BitcoinCreateResult { + ok: boolean; + created: boolean; + network: string; + /** Only present on creation - the sidecar never returns it again. */ + mnemonic?: string; +} + +// ── Sidecar error ───────────────────────────────────────────────────────────── + +export class BitcoinWalletError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'BitcoinWalletError'; + this.status = status; + } +} + +// ── Internal fetch helper ───────────────────────────────────────────────────── + +async function sidecarRequest( + path: string, + init: RequestInit & { timeoutMs?: number } = {}, +): Promise { + const { timeoutMs = 10_000, ...rest } = init; + const auth = Buffer.from(`${BITCOIN_WALLET_USERNAME}:${BITCOIN_WALLET_PASSWORD}`).toString('base64'); + let res: Response; + try { + res = await fetch(`${BITCOIN_WALLET_URL}${path}`, { + ...rest, + headers: { Authorization: `Basic ${auth}`, ...(rest.headers ?? {}) }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + throw new BitcoinWalletError( + `BTC wallet service unreachable (is the bitcoin profile running?)`, + 503, + ); + } + const body = (await res.json().catch(() => null)) as + | { ok?: boolean; error?: string; [key: string]: unknown } + | null; + if (!res.ok || !body || body.ok === false) { + throw new BitcoinWalletError( + body?.error ?? `BTC wallet error ${res.status}`, + res.status, + ); + } + return body as T; +} + +// ── Wallet lifecycle ────────────────────────────────────────────────────────── + +/** Create the BTC wallet (generated seed, or restore from the given mnemonic). */ +export function createBitcoinWallet(seed?: string): Promise { + return sidecarRequest('/wallet', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seed: seed ?? null }), + }); +} + +// ── Status / sync ───────────────────────────────────────────────────────────── + +/** Node + wallet overview: reachability, sync state, balances. */ +export function getBitcoinStatus(): Promise { + return sidecarRequest('/status'); +} + +/** Ask the sidecar to sync the wallet against bitcoind (async on its side). */ +export function triggerBitcoinSync(): Promise<{ ok: boolean; syncStarted: boolean }> { + return sidecarRequest('/sync', { method: 'POST', timeoutMs: 30_000 }); +} + +// ── Balance / addresses ─────────────────────────────────────────────────────── + +/** Confirmed + pending balances in satoshis (strings). */ +export function getBitcoinBalance(): Promise { + return sidecarRequest('/balance'); +} + +/** Derive a fresh receive address. */ +export function newBitcoinAddress(): Promise<{ ok: boolean; address: string }> { + return sidecarRequest('/address/new', { method: 'POST' }); +} + +/** Last unused receive address (for the Receive view). */ +export function currentBitcoinAddress(): Promise<{ ok: boolean; address: string }> { + return sidecarRequest('/address/current'); +} + +// ── Transactions ────────────────────────────────────────────────────────────── + +/** Wallet transaction history, newest first. */ +export function listBitcoinTransactions(limit = 50): Promise<{ ok: boolean; transactions: BitcoinTransaction[] }> { + return sidecarRequest(`/txs?limit=${encodeURIComponent(limit)}`); +} + +export interface BitcoinSendRequest { + address: string; + /** BTC amount as a decimal string (max 8 decimals). */ + amountBtc: string; + /** Optional fee rate in sat/vB. */ + feeRateSatVb?: number; +} + +/** Build, sign and broadcast a transaction; resolves with the txid. */ +export function sendBitcoin(req: BitcoinSendRequest): Promise<{ ok: boolean; txid: string }> { + return sidecarRequest('/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + address: req.address, + amount_btc: req.amountBtc, + fee_rate_sat_vb: req.feeRateSatVb, + }), + timeoutMs: 60_000, + }); +} + +/** Fee estimates in sat/vB by confirmation target (1, 3, 6, 12, 25). */ +export function getBitcoinFeeEstimate(): Promise<{ ok: boolean; satPerVb: Record }> { + return sidecarRequest('/fee-estimate', { timeoutMs: 30_000 }); +} diff --git a/app/src/lib/bridge-sdk/addresses.ts b/app/src/lib/bridge-sdk/addresses.ts new file mode 100644 index 0000000..1d438f7 --- /dev/null +++ b/app/src/lib/bridge-sdk/addresses.ts @@ -0,0 +1,84 @@ +import { ethers } from 'ethers'; +import { bech32mDecode } from './bech32m'; + +export type BridgeChain = 'ML' | 'ETH'; + +export interface AddressValidation { + valid: boolean; + /** User-facing message when invalid. */ + error?: string; +} + +export const ML_MAINNET_HRP = 'mtc'; +export const ML_TESTNET_HRP = 'tmtc'; + +const hrpForNetwork = (network?: 'mainnet' | 'testnet'): string | null => { + if (network === 'mainnet') return ML_MAINNET_HRP; + if (network === 'testnet') return ML_TESTNET_HRP; + return null; +}; + +/** + * Validates an EVM address (format + EIP-55 checksum when mixed case). + */ +export function validateEvmAddress(address: string): AddressValidation { + if (ethers.isAddress(address.trim())) return { valid: true }; + return { + valid: false, + error: 'That doesn’t look like an Ethereum address (expected 0x followed by 40 hex characters).', + }; +} + +/** + * Validates a Mintlayer address: bech32m charset + checksum, the network + * human-readable prefix, and a sane payload length. The bech32m checksum is + * what lets a mistyped address be rejected instead of silently accepted. + */ +export function validateMintlayerAddress( + address: string, + network?: 'mainnet' | 'testnet', +): AddressValidation { + const value = address.trim(); + const expectedHrp = hrpForNetwork(network); + + let decoded: { prefix: string; words: number[] }; + try { + decoded = bech32mDecode(value); + } catch { + return { + valid: false, + error: 'Invalid Mintlayer address — the checksum doesn’t match, so the address likely contains a typo.', + }; + } + + if (expectedHrp && decoded.prefix !== expectedHrp) { + const networkName = expectedHrp === ML_MAINNET_HRP ? 'mainnet' : 'testnet'; + return { + valid: false, + error: `That's a Mintlayer ${decoded.prefix}… address, but this bridge runs on ${networkName} (expected ${expectedHrp}1…).`, + }; + } + + if (decoded.words.length < 16) { + return { valid: false, error: 'That Mintlayer address is too short to be valid.' }; + } + + return { valid: true, error: undefined }; +} + +/** + * Validates the bridge destination address for the target chain. + * `network` is the Mintlayer network of the bridge config (mainnet/testnet). + */ +export function validateDestinationAddress( + address: string, + chain: BridgeChain, + network?: 'mainnet' | 'testnet', +): AddressValidation { + const value = address.trim(); + if (!value) return { valid: false, error: 'Enter the destination address.' }; + + return chain === 'ETH' + ? validateEvmAddress(value) + : validateMintlayerAddress(value, network); +} diff --git a/app/src/lib/bridge-sdk/bech32m.ts b/app/src/lib/bridge-sdk/bech32m.ts new file mode 100644 index 0000000..e6f2545 --- /dev/null +++ b/app/src/lib/bridge-sdk/bech32m.ts @@ -0,0 +1,103 @@ +/** + * Minimal bech32m codec (BIP-350) used for Mintlayer address checksum + * validation. Self-contained so the SDK has no native/ESM edge cases. + * See https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki + */ + +const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + +/** bech32m constant (0x2bc832a3) distinguishes from legacy bech32 (1). */ +export const BECH32M_CONST = 0x2bc832a3; + +const polymod = (values: number[]): number => { + let chk = 1; + for (const value of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let i = 0; i < 5; i++) { + if ((top >> i) & 1) chk ^= GENERATOR[i]; + } + } + return chk; +}; + +const hrpExpand = (hrp: string): number[] => { + const result: number[] = []; + for (const c of hrp) result.push(c.charCodeAt(0) >> 5); + result.push(0); + for (const c of hrp) result.push(c.charCodeAt(0) & 31); + return result; +}; + +const verifyChecksum = (hrp: string, data: number[]): boolean => + polymod([...hrpExpand(hrp), ...data]) === BECH32M_CONST; + +export interface Bech32mDecoded { + prefix: string; + /** 5-bit payload words, checksum excluded. */ + words: number[]; +} + +/** + * Decodes and checksum-verifies a bech32m string. Throws on mixed case, + * invalid characters, bad separator or checksum mismatch. + */ +export function bech32mDecode(address: string): Bech32mDecoded { + if (address.length < 8) throw new Error('bech32m: too short'); + if (address.length > 1023) throw new Error('bech32m: too long'); + + const hasLower = /[a-z]/.test(address); + const hasUpper = /[A-Z]/.test(address); + if (hasLower && hasUpper) throw new Error('bech32m: mixed case'); + + const value = address.toLowerCase(); + const separatorIndex = value.lastIndexOf('1'); + if (separatorIndex < 1 || separatorIndex + 7 > value.length) { + throw new Error('bech32m: invalid separator position'); + } + + const prefix = value.slice(0, separatorIndex); + const dataPart = value.slice(separatorIndex + 1); + + const words: number[] = []; + for (const c of dataPart) { + const index = CHARSET.indexOf(c); + if (index === -1) throw new Error(`bech32m: invalid character '${c}'`); + words.push(index); + } + + if (!verifyChecksum(prefix, words)) { + throw new Error('bech32m: checksum mismatch'); + } + + return { prefix, words: words.slice(0, -6) }; +} + +/** Encodes a payload into a bech32m string (used by tests/fixtures). */ +export function bech32mEncode(prefix: string, words: number[]): string { + const checksumInput = [...hrpExpand(prefix), ...words, 0, 0, 0, 0, 0, 0]; + const mod = polymod(checksumInput) ^ BECH32M_CONST; + const checksum: number[] = []; + for (let i = 0; i < 6; i++) checksum.push((mod >> (5 * (5 - i))) & 31); + const data = [...words, ...checksum]; + return `${prefix}1${data.map((w) => CHARSET[w]).join('')}`; +} + +/** Converts 8-bit bytes into 5-bit bech32 words. */ +export function toWords(bytes: Uint8Array): number[] { + const words: number[] = []; + let accumulator = 0; + let bits = 0; + const mask = (1 << 5) - 1; + for (const byte of bytes) { + accumulator = (accumulator << 8) | (byte & 0xff); + bits += 8; + while (bits >= 5) { + bits -= 5; + words.push((accumulator >> bits) & mask); + } + } + if (bits > 0) words.push((accumulator << (5 - bits)) & mask); + return words; +} diff --git a/app/src/lib/bridge-sdk/bridge-sdk.test.ts b/app/src/lib/bridge-sdk/bridge-sdk.test.ts new file mode 100644 index 0000000..346dae0 --- /dev/null +++ b/app/src/lib/bridge-sdk/bridge-sdk.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createBridgeSdk, type BridgeSdk } from '@/lib/bridge-sdk'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +function makeSdk(): BridgeSdk { + return createBridgeSdk({ fetchImpl: mockFetch as unknown as typeof fetch }); +} + +describe('bridge-sdk', () => { + it('builds fees and config URLs from the api base', async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const sdk = makeSdk(); + await sdk.getFees(); + await sdk.getConfig(); + const urls = mockFetch.mock.calls.map((c) => c[0] as string); + expect(urls[0]).toContain('/api/v1/fees'); + expect(urls[1]).toContain('/api/v1/agents-config'); + }); + + it('broadcasts a bridge request via POST', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ bridge_request_uuid: 'u1', deposit_transaction_uuids: ['d1'] }), + { status: 200 }, + ), + ); + const sdk = makeSdk(); + const res = await sdk.broadcastBridgeRequest({ + source_chain: 'Mintlayer', + destination_chain: 'Ethereum', + asset: 'crv', + amount: '1.5', + receiver_address: '0xabc', + deposit_transactions: [{ raw_transaction: 'ff', intent: 'aa' }], + }); + expect(res.bridge_request_uuid).toBe('u1'); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string).deposit_transactions[0]).toEqual({ + raw_transaction: 'ff', + intent: 'aa', + }); + }); + + it('escapes uuid path segments', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ status: 'pending' }), { status: 200 }), + ); + const sdk = makeSdk(); + await sdk.getBridgeRequest('a/b'); + expect((mockFetch.mock.calls[0][0] as string)).toContain('/bridge-request/a%2Fb'); + }); + + it('throws BridgeSdkError with the API message on failure', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ error: 'boom' }), { status: 422 }), + ); + const sdk = makeSdk(); + await expect(sdk.getFees()).rejects.toThrow(/boom/); + }); + + it('listBridgeRequests serializes filters', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify([]), { status: 200 }), + ); + const sdk = makeSdk(); + await sdk.listBridgeRequests({ limit: 5, status: ['pending', 'completed'] }); + const url = decodeURIComponent(mockFetch.mock.calls[0][0] as string); + expect(url).toContain('limit=5'); + expect(url).toContain('status=pending,completed'); + }); +}); diff --git a/app/src/lib/bridge-sdk/config.ts b/app/src/lib/bridge-sdk/config.ts new file mode 100644 index 0000000..284f486 --- /dev/null +++ b/app/src/lib/bridge-sdk/config.ts @@ -0,0 +1,22 @@ +/** + * bridge-sdk — API layer for the Mintlayer bridge. + * + * All bridge/ml-api network access lives here. UI components never call + * fetch directly; they consume this SDK so endpoints, URLs, and error + * handling stay in one testable place. + * + * Ported from bridge-frontend/src/bridge-sdk, adapted for Astro: + * PUBLIC_* env vars are read via import.meta.env instead of process.env. + */ + +export const DEFAULT_BRIDGE_API_URL = 'https://api.bridge.mintlayer.org/api/v1/'; +export const DEFAULT_MINTLAYER_API_URL = + 'https://api-server.mintlayer.org/api/v2/'; + +const env = (import.meta as unknown as { env?: Record }).env ?? {}; + +export const getBridgeApiUrl = (): string => + env.PUBLIC_BRIDGE_API_URL ?? DEFAULT_BRIDGE_API_URL; + +export const getMintlayerApiUrl = (): string => + env.PUBLIC_MINTLAYER_API_URL ?? DEFAULT_MINTLAYER_API_URL; diff --git a/app/src/lib/bridge-sdk/endpoints.ts b/app/src/lib/bridge-sdk/endpoints.ts new file mode 100644 index 0000000..4dc6b10 --- /dev/null +++ b/app/src/lib/bridge-sdk/endpoints.ts @@ -0,0 +1,92 @@ +import { getBridgeApiUrl, getMintlayerApiUrl } from './config'; +import { apiRequest } from './http'; +import type { FetchLike } from './http'; +import type { + AddressInfo, + BridgeConfig, + BridgeFees, + BridgeRequestDetail, + BridgeRequestInput, + BridgeRequestListItem, + BridgeRequestResponse, + DepositTransactionDetail, + ListBridgeRequestsParams, + TokenInfo, +} from './types'; + +export interface BridgeSdk { + /** Bridge fee settings per asset and direction. */ + getFees: () => Promise; + /** Broadcasts a signed bridge request. */ + broadcastBridgeRequest: ( + input: BridgeRequestInput, + ) => Promise; + /** Fetches a single bridge request by UUID. */ + getBridgeRequest: (id: string) => Promise; + /** Network-wide bridge request feed (Live Activity). */ + listBridgeRequests: ( + params?: ListBridgeRequestsParams, + ) => Promise; + /** Fetches a deposit transaction by UUID. */ + getDepositTransaction: (id: string) => Promise; + /** Fetches the bridge agents config (tokens, contracts, network type). */ + getConfig: () => Promise; + /** Mintlayer api-server: address lookup. */ + getMintlayerAddress: (address: string) => Promise; + /** Mintlayer api-server: token lookup. */ + getTokenInfo: (tokenId: string) => Promise; +} + +export interface BridgeSdkDeps { + bridgeApiUrl?: string; + mintlayerApiUrl?: string; + fetchImpl?: FetchLike; +} + +/** + * Creates a bridge API client. All dependencies are injectable so callers + * (and tests) can point it at any environment. + */ +export const createBridgeSdk = ( + deps: BridgeSdkDeps = {}, +): BridgeSdk => { + const bridgeUrl = deps.bridgeApiUrl ?? getBridgeApiUrl(); + const mlUrl = deps.mintlayerApiUrl ?? getMintlayerApiUrl(); + // Resolved lazily so environments without a global fetch (tests, SSR edge + // cases) can still import the module and inject their own implementation. + const fetchImpl: FetchLike = + deps.fetchImpl ?? ((input, init) => fetch(input, init)); + + return { + getFees: () => apiRequest(`${bridgeUrl}fees`, {}, fetchImpl), + + listBridgeRequests: (params = {}) => { + const query = new URLSearchParams(); + if (params.limit != null) query.set('limit', String(params.limit)); + if (params.status?.length) query.set('status', params.status.join(',')); + if (params.createdAfter) query.set('created_after', params.createdAfter); + const qs = query.toString(); + return apiRequest(`${bridgeUrl}bridge-requests${qs ? `?${qs}` : ''}`, {}, fetchImpl); + }, + + broadcastBridgeRequest: (input) => + apiRequest(`${bridgeUrl}bridge-request`, { method: 'POST', body: input }, fetchImpl), + + getBridgeRequest: (id) => + apiRequest(`${bridgeUrl}bridge-request/${encodeURIComponent(id)}`, {}, fetchImpl), + + getDepositTransaction: (id) => + apiRequest(`${bridgeUrl}deposit-transaction/${encodeURIComponent(id)}`, {}, fetchImpl), + + getConfig: () => apiRequest(`${bridgeUrl}agents-config`, {}, fetchImpl), + + getMintlayerAddress: (address) => + apiRequest(`${mlUrl}address/${encodeURIComponent(address)}`, {}, fetchImpl), + + getTokenInfo: (tokenId) => + apiRequest(`${mlUrl}token/${encodeURIComponent(tokenId)}`, {}, fetchImpl), + }; +}; + +/** Default client using env-configured URLs and global fetch. */ +export const bridgeSdk = createBridgeSdk(); diff --git a/app/src/lib/bridge-sdk/http.ts b/app/src/lib/bridge-sdk/http.ts new file mode 100644 index 0000000..c2f5c7d --- /dev/null +++ b/app/src/lib/bridge-sdk/http.ts @@ -0,0 +1,106 @@ +export type FetchLike = ( + input: string, + init?: RequestInit, +) => Promise; + +export type HttpMethod = 'GET' | 'POST'; + +export interface RequestOptions { + method?: HttpMethod; + /** JSON-serializable request body. */ + body?: unknown; + headers?: Record; +} + +/** Error thrown by bridge-sdk requests; carries the HTTP status when known. */ +export class BridgeSdkError extends Error { + readonly status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = 'BridgeSdkError'; + this.status = status; + } +} + +const isRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null; + +async function extractErrorMessage(response: Response): Promise { + try { + const data = await response.json(); + if (isRecord(data) && typeof data.error === 'string') return data.error; + return JSON.stringify(data); + } catch { + return `Request failed with status: ${response.status}`; + } +} + +/** + * Performs an HTTP request against a bridge/api-server endpoint and returns + * the parsed JSON body. The fetch implementation is injectable for tests. + */ +export async function apiRequest( + url: string, + options: RequestOptions = {}, + fetchImpl: FetchLike = fetch, +): Promise { + const { method = 'GET', body, headers = {} } = options; + + const init: RequestInit = { + method, + headers: body !== undefined + ? { 'Content-Type': 'application/json', ...headers } + : headers, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }; + + let response: Response; + try { + response = await fetchImpl(url, init); + } catch (cause) { + throw new BridgeSdkError( + `Network error while calling ${url}: ${String(cause)}`, + ); + } + + if (!response.ok) { + throw new BridgeSdkError( + await extractErrorMessage(response), + response.status, + ); + } + + return (await response.json()) as T; +} + +/** + * Performs an HTTP request and returns the raw response text (used by + * legacy callers that parse lazily). + */ +export async function apiRequestText( + url: string, + options: RequestOptions = {}, + fetchImpl: FetchLike = fetch, +): Promise { + const { method = 'GET', body, headers = {} } = options; + + const init: RequestInit = { + method, + headers: body !== undefined + ? { 'Content-Type': 'application/json', ...headers } + : headers, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }; + + const response = await fetchImpl(url, init); + + if (!response.ok) { + throw new BridgeSdkError( + await extractErrorMessage(response), + response.status, + ); + } + + return response.text(); +} diff --git a/app/src/lib/bridge-sdk/icons.ts b/app/src/lib/bridge-sdk/icons.ts new file mode 100644 index 0000000..2f8d7b3 --- /dev/null +++ b/app/src/lib/bridge-sdk/icons.ts @@ -0,0 +1,63 @@ +import { getMintlayerApiUrl } from './config'; +import { apiRequest } from './http'; +import type { FetchLike } from './http'; + +const IPFS_PROXY = '/ipfs/'; + +/** Converts ipfs:// URIs to the same-origin gateway proxy; passes http(s) through. */ +export const toGatewayUrl = (uri: string): string => { + if (uri.startsWith('ipfs://ipfs/')) return IPFS_PROXY + uri.slice('ipfs://ipfs/'.length); + if (uri.startsWith('ipfs://')) return IPFS_PROXY + uri.slice('ipfs://'.length); + return uri; +}; + +interface RawTokenInfo { + metadata_uri?: { string?: string }; + icon_uri?: { string?: string }; +} + +/** + * Resolves a token's display icon URL from its on-chain metadata: + * token info → metadata_uri (usually an IPFS JSON document) → tokenIcon. + * Returns null when any step is missing or fails. + */ +export async function resolveTokenIcon( + tokenId: string, + deps: { mintlayerApiUrl?: string; fetchImpl?: FetchLike } = {}, +): Promise { + const mlUrl = deps.mintlayerApiUrl ?? getMintlayerApiUrl(); + const fetchImpl = deps.fetchImpl ?? ((input, init) => fetch(input, init)); + + let info: RawTokenInfo; + try { + info = await apiRequest( + `${mlUrl}token/${encodeURIComponent(tokenId)}`, + {}, + fetchImpl, + ); + } catch { + return null; + } + + const metadataUri = info.metadata_uri?.string ?? info.icon_uri?.string; + if (!metadataUri) return null; + + try { + const metadata = await apiRequest>( + toGatewayUrl(metadataUri), + {}, + fetchImpl, + ); + const icon = + metadata.tokenIcon ?? + metadata.icon_uri ?? + metadata.icon ?? + metadata.image; + if (typeof icon === 'string' && icon.length > 0) { + return toGatewayUrl(icon); + } + return null; + } catch { + return null; + } +} diff --git a/app/src/lib/bridge-sdk/index.ts b/app/src/lib/bridge-sdk/index.ts new file mode 100644 index 0000000..d898908 --- /dev/null +++ b/app/src/lib/bridge-sdk/index.ts @@ -0,0 +1,48 @@ +/** + * bridge-sdk public API. + * + * @example + * import { bridgeSdk } from '@/bridge-sdk'; + * const config = await bridgeSdk.getConfig(); + */ +export { createBridgeSdk, bridgeSdk } from './endpoints'; +export type { BridgeSdk, BridgeSdkDeps } from './endpoints'; +export type { + BridgeRequestStatus, + ListBridgeRequestsParams, + BridgeRequestListItem, +} from './types'; +export { + DEFAULT_BRIDGE_API_URL, + DEFAULT_MINTLAYER_API_URL, + getBridgeApiUrl, + getMintlayerApiUrl, +} from './config'; +export { apiRequest, apiRequestText, BridgeSdkError } from './http'; +export type { RequestOptions, FetchLike, HttpMethod } from './http'; +export type { + BridgeConfig, + BridgeFees, + BridgeRequestInput, + BridgeRequestResponse, + BridgeRequestDetail, + DepositTransaction, + DepositTransactionDetail, + AddressInfo, + TokenInfo, + EthFlavorConfig, + DirectionalFees, + AssetFees, + FeeDirection, +} from './types'; +export { + validateDestinationAddress, + validateEvmAddress, + validateMintlayerAddress, + ML_MAINNET_HRP, + ML_TESTNET_HRP, +} from './addresses'; +export type { AddressValidation, BridgeChain } from './addresses'; +export { bech32mDecode, bech32mEncode, toWords, BECH32M_CONST } from './bech32m'; +export type { Bech32mDecoded } from './bech32m'; +export { resolveTokenIcon, toGatewayUrl } from './icons'; diff --git a/app/src/lib/bridge-sdk/types.ts b/app/src/lib/bridge-sdk/types.ts new file mode 100644 index 0000000..91cdbaf --- /dev/null +++ b/app/src/lib/bridge-sdk/types.ts @@ -0,0 +1,128 @@ +export type EthFlavorConfig = { + token_config: { + [token: string]: { + address: string; + max_amount_per_request: string; + }; + }; + infura_rpc_url: string; + bridge_contract_address: string; + /** Absent (null) on mainnet. */ + e2m: { + deposit_tx_required_confirmations: number; + ml_token_owner_address: string; + tx_fee_change_address: string; + bridge_contract_deposit_log_record_topic: string; + deposit_tx_max_blocks_to_wait: number; + withdrawal_tx_required_confirmations: number; + resend_withdrawal_tx_after_block_count: number; + } | null; + /** Absent (null) on mainnet. */ + m2e: { + deposit_tx_required_confirmations: number; + deposit_tx_destination: string; + deposit_tx_max_blocks_to_wait: number; + withdrawal_tx_required_confirmations: number; + resend_withdrawal_tx_after_block_count: number; + multisend_contract_address: string; + gnosis_safe_address: string; + } | null; +}; + +export interface BridgeConfig { + network_type: string; + ml_tokens: { + [ticker: string]: string; + }; + eth_flavor_specific_config: { + /** Flavor key: '' on mainnet, 'sepolia' on testnet. */ + [flavor: string]: EthFlavorConfig; + }; +} + +/** Fees for one direction, in asset units / percent as decimal strings. */ +export interface DirectionalFees { + fixed_fee: string; + percentage_fee: string; +} + +/** Per-asset fees, keyed by direction: to_ml (ETH→ML deposits), to_eth (ML→ETH payouts). */ +export interface AssetFees { + to_ml?: DirectionalFees; + to_eth?: DirectionalFees; + [key: string]: unknown; +} + +export type FeeDirection = 'to_ml' | 'to_eth'; + +/** Fees route: one entry per asset. */ +export type BridgeFees = Record; + +export type DepositTransaction = + | { raw_transaction: string; intent?: string; transaction_hash?: never } + | { raw_transaction?: never; transaction_hash: string }; + +export interface BridgeRequestInput { + source_chain: string; + destination_chain: string; + asset: string; + amount: string; + receiver_address: string; + deposit_transactions: DepositTransaction[]; +} + +export interface BridgeRequestResponse { + bridge_request_uuid: string; + deposit_transaction_uuids: string[]; +} + +export interface BridgeRequestDetail { + bridge_request_uuid: string; + source_chain: string; + destination_chain: string; + asset: string; + amount: string; + receiver_address: string; + [key: string]: unknown; +} + +export interface DepositTransactionDetail { + deposit_transaction_uuid: string; + [key: string]: unknown; +} + +export type AddressInfo = Record; +export type TokenInfo = Record; + +export type BridgeRequestStatus = + | 'pending' + | 'processed_by_master' + | 'completed' + | 'failed' + | 'manual'; + +/** One row of the network-wide bridge-requests feed. */ +export interface BridgeRequestListItem { + bridge_request_uuid: string; + source_chain: 'mintlayer' | 'ethereum'; + destination_chain: 'mintlayer' | 'ethereum'; + /** Asset token id (or ticker). */ + asset: string; + /** Source-chain amount before fees. */ + amount: string; + /** Null until the master agent computes it. */ + amount_after_fees?: string | null; + status: BridgeRequestStatus; + withdrawal_transaction_uuid?: string | null; + created_at: string; + [key: string]: unknown; +} + +export interface ListBridgeRequestsParams { + /** Max rows (default 20, max 100). */ + limit?: number; + /** Optional status filter (comma-joined into one param). */ + status?: BridgeRequestStatus[]; + /** RFC3339 cursor for polling. */ + createdAfter?: string; +} diff --git a/app/src/lib/evm.ts b/app/src/lib/evm.ts new file mode 100644 index 0000000..5356cbd --- /dev/null +++ b/app/src/lib/evm.ts @@ -0,0 +1,154 @@ +/** + * EVM (MetaMask / EIP-1193) helpers for the Mintlayer bridge. + * + * Talks to `window.ethereum` directly (MetaMask only, per design) and wraps + * the ERC20 / MintlayerBridge interactions needed for E2M deposits: + * approve(spender, amount) -> deposit(token, amount, mintlayerAddress) + * + * Amounts are handled as token-unit decimal strings; conversion to wei is + * done with ethers.parseUnits using the token's decimals from the bridge + * config (all bridged ERC20s are 18-decimal). + */ +import { BrowserProvider, Contract, formatUnits, parseUnits } from 'ethers'; + +export const ERC20_ABI = [ + 'function balanceOf(address owner) view returns (uint256)', + 'function approve(address spender, uint256 amount) returns (bool)', + 'function decimals() view returns (uint8)', +]; + +export const BRIDGE_DEPOSIT_ABI = [ + { + inputs: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'string', name: 'mintlayerAddress', type: 'string' }, + ], + name: 'deposit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +]; + +declare global { + interface Window { + ethereum?: { + request: (args: { method: string; params?: unknown[] | object }) => Promise; + on?: (event: string, handler: (...args: unknown[]) => void) => void; + removeListener?: (event: string, handler: (...args: unknown[]) => void) => void; + isMetaMask?: boolean; + }; + } +} + +export class EvmError extends Error { + readonly code?: number; + constructor(message: string, code?: number) { + super(message); + this.name = 'EvmError'; + this.code = code; + } +} + +export function hasEthereumProvider(): boolean { + return typeof window !== 'undefined' && !!window.ethereum; +} + +function provider(): BrowserProvider { + if (!hasEthereumProvider()) { + throw new EvmError('MetaMask not installed'); + } + return new BrowserProvider(window.ethereum as never); +} + +/** Prompt MetaMask for account access. Returns the selected address. */ +export async function connectMetaMask(): Promise { + if (!hasEthereumProvider()) { + throw new EvmError( + 'MetaMask not installed. Install the MetaMask extension to bridge.', + ); + } + const accounts = (await window.ethereum!.request({ + method: 'eth_requestAccounts', + })) as string[]; + if (!accounts?.length) throw new EvmError('No accounts authorized'); + return accounts[0]; +} + +export async function getConnectedAccount(): Promise { + if (!hasEthereumProvider()) return null; + try { + const accounts = (await window.ethereum!.request({ + method: 'eth_accounts', + })) as string[]; + return accounts?.[0] ?? null; + } catch { + return null; + } +} + +/** Switch MetaMask to the given chain, adding it if unknown. */ +export async function switchChain( + chainIdHex: string, + chainParams?: Record, +): Promise { + if (!hasEthereumProvider()) throw new EvmError('MetaMask not installed'); + try { + await window.ethereum!.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainIdHex }], + }); + } catch (err) { + const code = (err as { code?: number }).code; + // 4902 = chain not added to the wallet + if (code === 4902 && chainParams) { + await window.ethereum!.request({ + method: 'wallet_addEthereumChain', + params: [{ chainId: chainIdHex, ...chainParams }], + }); + } else { + throw err; + } + } +} + +/** ERC20 token balance formatted in token units. */ +export async function getTokenBalance( + tokenAddress: string, + userAddress: string, + decimals = 18, +): Promise { + const p = provider(); + const token = new Contract(tokenAddress, ERC20_ABI, p); + const balance = await token.balanceOf(userAddress); + return formatUnits(balance, decimals); +} + +/** + * E2M deposit: approve the bridge contract, then call + * deposit(token, amountWei, mintlayerAddress). Returns the deposit tx hash. + * `onProgress` reports UX phase changes (approve / deposit). + */ +export async function depositToBridge( + bridgeContractAddress: string, + tokenAddress: string, + amount: string, + decimals: number, + mintlayerAddress: string, + onProgress?: (phase: 'approve' | 'deposit') => void, +): Promise { + const p = provider(); + const signer = await p.getSigner(); + const amountWei = parseUnits(amount, decimals); + + const token = new Contract(tokenAddress, ERC20_ABI, signer); + onProgress?.('approve'); + const approveTx = await token.approve(bridgeContractAddress, amountWei); + await approveTx.wait(); + + const bridge = new Contract(bridgeContractAddress, BRIDGE_DEPOSIT_ABI, signer); + onProgress?.('deposit'); + const depositTx = await bridge.deposit(tokenAddress, amountWei, mintlayerAddress); + return depositTx.hash as string; +} diff --git a/app/src/middleware.ts b/app/src/middleware.ts index 85cc62f..fcffb16 100644 --- a/app/src/middleware.ts +++ b/app/src/middleware.ts @@ -21,7 +21,12 @@ const PUBLIC_PREFIXES = ['/_astro/', '/favicon', '/_image']; const SECURITY_HEADERS: Record = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', - 'Referrer-Policy': 'no-referrer', + // strict-origin-when-cross-origin (the browser default) rather than + // no-referrer: Chrome 151+ elides the Origin header (sends `Origin: null`) + // on form POSTs when the referrer policy strips referrers entirely, which + // Astro's same-origin CSRF check then rejects with 403. Cross-origin + // requests still carry only the origin - no path or query. + 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', }; diff --git a/app/src/pages/api/bitcoin/api.test.ts b/app/src/pages/api/bitcoin/api.test.ts new file mode 100644 index 0000000..53ed986 --- /dev/null +++ b/app/src/pages/api/bitcoin/api.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockFetch = vi.fn(); + +vi.mock('@/lib/bitcoin-wallet', () => ({ + isBitcoinEnabled: vi.fn(), + getBitcoinStatus: vi.fn(), + currentBitcoinAddress: vi.fn(), + getBitcoinBalance: vi.fn(), + listBitcoinTransactions: vi.fn(), + createBitcoinWallet: vi.fn(), + sendBitcoin: vi.fn(), + triggerBitcoinSync: vi.fn(), + BitcoinWalletError: class BitcoinWalletError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } + }, +})); + +import { + isBitcoinEnabled, + getBitcoinStatus, + currentBitcoinAddress, + getBitcoinBalance, + listBitcoinTransactions, + createBitcoinWallet, + sendBitcoin, + triggerBitcoinSync, +} from '@/lib/bitcoin-wallet'; + +beforeEach(() => { + vi.mocked(isBitcoinEnabled).mockReturnValue(true); +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe('GET /api/bitcoin/overview', () => { + const makeCtx = () => + ({ request: new Request('http://localhost/api/bitcoin/overview') }) as never; + + it('returns 404 when bitcoin is disabled', async () => { + vi.mocked(isBitcoinEnabled).mockReturnValue(false); + const { GET } = await import('@/pages/api/bitcoin/overview'); + const res = await GET(makeCtx()); + expect(res.status).toBe(404); + }); + + it('aggregates status, address, balance and transactions', async () => { + const statusPayload = { + ok: true, network: "mainnet" as const, walletExists: true, walletLoaded: true, + node: { reachable: true, blocks: 1, headers: 1, synced: true, initialBlockDownload: false }, + balance: { confirmed: '1', trustedPending: '0', untrustedPending: '0', immature: '0' }, + }; + vi.mocked(getBitcoinStatus).mockResolvedValue(statusPayload); + vi.mocked(currentBitcoinAddress).mockResolvedValue({ ok: true, address: 'bc1qabc' }); + vi.mocked(getBitcoinBalance).mockResolvedValue({ + confirmed: "1", trustedPending: "0", untrustedPending: "0", immature: "0", total: "1", + }); + vi.mocked(listBitcoinTransactions).mockResolvedValue({ + ok: true, + transactions: [{ txid: 't1', received: '1', sent: '0', fee: null, confirmed: true, height: 5, timestamp: 1700000000 }], + }); + + const { GET } = await import('@/pages/api/bitcoin/overview'); + const res = await GET(makeCtx()); + const body = await res.json() as Record; + expect(res.status).toBe(200); + expect(body.address).toBe('bc1qabc'); + expect((body.transactions as unknown[]).length).toBe(1); + }); + + it('degrades gracefully when the sidecar is fully unreachable', async () => { + const boom = () => Promise.reject(new Error('unreachable')); + vi.mocked(getBitcoinStatus).mockImplementation(boom); + vi.mocked(currentBitcoinAddress).mockImplementation(boom); + vi.mocked(getBitcoinBalance).mockImplementation(boom); + vi.mocked(listBitcoinTransactions).mockImplementation(boom); + + const { GET } = await import('@/pages/api/bitcoin/overview'); + const res = await GET(makeCtx()); + const body = await res.json() as Record; + expect(res.status).toBe(200); + expect(body.status).toBeNull(); + expect(body.address).toBeNull(); + expect(body.transactions).toBeNull(); + }); +}); + +describe('POST /api/bitcoin/wallet', () => { + const makeCtx = (body: unknown) => + ({ request: new Request('http://localhost/api/bitcoin/wallet', { method: 'POST', body: JSON.stringify(body) }) }) as never; + + it('returns 404 when bitcoin is disabled', async () => { + vi.mocked(isBitcoinEnabled).mockReturnValue(false); + const { POST } = await import('@/pages/api/bitcoin/wallet'); + const res = await POST(makeCtx({})); + expect(res.status).toBe(404); + }); + + it('proxies creation and returns the one-time mnemonic', async () => { + vi.mocked(createBitcoinWallet).mockResolvedValue({ ok: true, created: true, network: 'mainnet', mnemonic: 'w1 w2 w3' }); + const { POST } = await import('@/pages/api/bitcoin/wallet'); + const res = await POST(makeCtx({})); + const body = await res.json() as Record; + expect(body.mnemonic).toBe('w1 w2 w3'); + expect(vi.mocked(createBitcoinWallet)).toHaveBeenCalledWith(undefined); + }); + + it('forwards the provided seed for restore', async () => { + vi.mocked(createBitcoinWallet).mockResolvedValue({ ok: true, created: true, network: 'mainnet' }); + const { POST } = await import('@/pages/api/bitcoin/wallet'); + await POST(makeCtx({ seed: 'w1 w2 w3' })); + expect(vi.mocked(createBitcoinWallet)).toHaveBeenCalledWith('w1 w2 w3'); + }); + + it('maps 409 (already exists) to a 409 response', async () => { + vi.mocked(createBitcoinWallet).mockRejectedValue( + Object.assign(new Error('wallet already exists'), { status: 409 }), + ); + const { POST } = await import('@/pages/api/bitcoin/wallet'); + const res = await POST(makeCtx({})); + expect(res.status).toBe(409); + }); +}); + +describe('POST /api/bitcoin/send', () => { + const makeCtx = (body: unknown) => + ({ request: new Request('http://localhost/api/bitcoin/send', { method: 'POST', body: JSON.stringify(body) }) }) as never; + + it('rejects invalid amounts', async () => { + const { POST } = await import('@/pages/api/bitcoin/send'); + for (const bad of ['', 'abc', '-1', '0', '0.000000001', '1.23456789012']) { + const res = await POST(makeCtx({ address: 'bc1qabc', amount_btc: bad })); + expect(res.status).toBe(400); + } + }); + + it('rejects a missing address', async () => { + const { POST } = await import('@/pages/api/bitcoin/send'); + const res = await POST(makeCtx({ amount_btc: '0.5' })); + expect(res.status).toBe(400); + }); + + it('proxies a valid send', async () => { + vi.mocked(sendBitcoin).mockResolvedValue({ ok: true, txid: 'deadbeef' }); + const { POST } = await import('@/pages/api/bitcoin/send'); + const res = await POST(makeCtx({ address: 'bc1qabc', amount_btc: '0.5', fee_rate_sat_vb: 10 })); + const body = await res.json() as Record; + expect(body.txid).toBe('deadbeef'); + expect(vi.mocked(sendBitcoin)).toHaveBeenCalledWith({ address: 'bc1qabc', amountBtc: '0.5', feeRateSatVb: 10 }); + }); + + it('keeps the sidecar 503 as 503', async () => { + vi.mocked(sendBitcoin).mockRejectedValue( + Object.assign(new Error('BTC wallet service unreachable'), { status: 503 }), + ); + const { POST } = await import('@/pages/api/bitcoin/send'); + const res = await POST(makeCtx({ address: 'bc1qabc', amount_btc: '0.5' })); + expect(res.status).toBe(503); + }); +}); + +describe('POST /api/bitcoin/sync', () => { + it('returns 404 when bitcoin is disabled', async () => { + vi.mocked(isBitcoinEnabled).mockReturnValue(false); + const { POST } = await import('@/pages/api/bitcoin/sync'); + const res = await POST({ request: new Request('http://localhost/api/bitcoin/sync', { method: 'POST' }) } as never); + expect(res.status).toBe(404); + }); + + it('proxies the sync trigger', async () => { + vi.mocked(triggerBitcoinSync).mockResolvedValue({ ok: true, syncStarted: true }); + const { POST } = await import('@/pages/api/bitcoin/sync'); + const res = await POST({ request: new Request('http://localhost/api/bitcoin/sync', { method: 'POST' }) } as never); + const body = await res.json() as Record; + expect(body.syncStarted).toBe(true); + }); +}); diff --git a/app/src/pages/api/bitcoin/fee-estimate.ts b/app/src/pages/api/bitcoin/fee-estimate.ts new file mode 100644 index 0000000..3e03f13 --- /dev/null +++ b/app/src/pages/api/bitcoin/fee-estimate.ts @@ -0,0 +1,16 @@ +import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; +import { isBitcoinEnabled, getBitcoinFeeEstimate } from '@/lib/bitcoin-wallet'; + +/** GET /api/bitcoin/fee-estimate - smart fee estimates (sat/vB) by target. */ +export const GET: APIRoute = async () => { + if (!isBitcoinEnabled()) { + return json({ ok: false, error: 'Bitcoin support is not enabled' }, 404); + } + try { + return json(await getBitcoinFeeEstimate()); + } catch { + // Estimates are best-effort; the send form works without them. + return json({ ok: true, satPerVb: {} }); + } +}; diff --git a/app/src/pages/api/bitcoin/overview.ts b/app/src/pages/api/bitcoin/overview.ts new file mode 100644 index 0000000..c3ed600 --- /dev/null +++ b/app/src/pages/api/bitcoin/overview.ts @@ -0,0 +1,44 @@ +import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; +import { + isBitcoinEnabled, + getBitcoinStatus, + currentBitcoinAddress, + getBitcoinBalance, + listBitcoinTransactions, + type BitcoinStatus, +} from '@/lib/bitcoin-wallet'; + +/** + * GET /api/bitcoin/overview + * One aggregated snapshot for the Bitcoin page: node/wallet status, balance, + * current receive address and recent transactions. Individual pieces degrade + * independently - a partial outage never blanks the whole page. + */ +export const GET: APIRoute = async () => { + if (!isBitcoinEnabled()) { + return json({ ok: false, error: 'Bitcoin support is not enabled' }, 404); + } + + let status: BitcoinStatus | null = null; + try { + status = await getBitcoinStatus(); + } catch { + status = null; // sidecar unreachable - page shows offline state + } + + const [address, balance, txs] = await Promise.all([ + currentBitcoinAddress().catch(() => null), + getBitcoinBalance().catch(() => null), + listBitcoinTransactions(25).catch(() => null), + ]); + + return json({ + ok: true, + enabled: true, + status, + address: address?.address ?? null, + balance, + transactions: txs?.transactions ?? null, + }); +}; diff --git a/app/src/pages/api/bitcoin/send.ts b/app/src/pages/api/bitcoin/send.ts new file mode 100644 index 0000000..7066338 --- /dev/null +++ b/app/src/pages/api/bitcoin/send.ts @@ -0,0 +1,53 @@ +import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; +import { isBitcoinEnabled, sendBitcoin } from '@/lib/bitcoin-wallet'; + +/** + * POST /api/bitcoin/send - send BTC. + * + * Amount arrives as a decimal string and is re-validated here before the + * sidecar performs the authoritative checks (network + precision + funds). + */ +const AMOUNT_RE = /^\d{1,8}(\.\d{1,8})?$/; + +export const POST: APIRoute = async ({ request }) => { + if (!isBitcoinEnabled()) { + return json({ ok: false, error: 'Bitcoin support is not enabled' }, 404); + } + + let address = ''; + let amountBtc = ''; + let feeRateSatVb: number | undefined; + try { + const body = (await request.json()) as { + address?: unknown; + amount_btc?: unknown; + fee_rate_sat_vb?: unknown; + }; + if (typeof body.address === 'string') address = body.address.trim(); + if (typeof body.amount_btc === 'string') amountBtc = body.amount_btc.trim(); + if (typeof body.fee_rate_sat_vb === 'number' && body.fee_rate_sat_vb > 0) { + feeRateSatVb = body.fee_rate_sat_vb; + } + } catch { + return json({ ok: false, error: 'Invalid request body' }, 400); + } + + if (!address) { + return json({ ok: false, error: 'Destination address is required' }, 400); + } + if (!AMOUNT_RE.test(amountBtc) || parseFloat(amountBtc) <= 0) { + return json( + { ok: false, error: 'Amount must be a positive number with at most 8 decimals' }, + 400, + ); + } + + try { + const result = await sendBitcoin({ address, amountBtc, feeRateSatVb }); + return json(result); + } catch (err) { + const status = (err as { status?: number }).status ?? 500; + return json({ ok: false, error: (err as Error).message }, status === 503 ? 503 : 400); + } +}; diff --git a/app/src/pages/api/bitcoin/sync.ts b/app/src/pages/api/bitcoin/sync.ts new file mode 100644 index 0000000..87068f1 --- /dev/null +++ b/app/src/pages/api/bitcoin/sync.ts @@ -0,0 +1,16 @@ +import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; +import { isBitcoinEnabled, triggerBitcoinSync } from '@/lib/bitcoin-wallet'; + +/** POST /api/bitcoin/sync - ask the sidecar to resync the wallet. */ +export const POST: APIRoute = async () => { + if (!isBitcoinEnabled()) { + return json({ ok: false, error: 'Bitcoin support is not enabled' }, 404); + } + try { + return json(await triggerBitcoinSync()); + } catch (err) { + const status = (err as { status?: number }).status ?? 500; + return json({ ok: false, error: (err as Error).message }, status); + } +}; diff --git a/app/src/pages/api/bitcoin/wallet.ts b/app/src/pages/api/bitcoin/wallet.ts new file mode 100644 index 0000000..c66f847 --- /dev/null +++ b/app/src/pages/api/bitcoin/wallet.ts @@ -0,0 +1,34 @@ +import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; +import { isBitcoinEnabled, createBitcoinWallet } from '@/lib/bitcoin-wallet'; + +/** + * POST /api/bitcoin/wallet - create (or restore) the BTC wallet. + * + * The mnemonic is returned EXACTLY ONCE, on creation; the sidecar persists it + * server-side and never serves it again. Clients must present the backup step + * immediately after a successful create. + */ +export const POST: APIRoute = async ({ request }) => { + if (!isBitcoinEnabled()) { + return json({ ok: false, error: 'Bitcoin support is not enabled' }, 404); + } + + let seed: string | undefined; + try { + const body = (await request.json()) as { seed?: unknown }; + if (typeof body.seed === 'string' && body.seed.trim() !== '') { + seed = body.seed; + } + } catch { + return json({ ok: false, error: 'Invalid request body' }, 400); + } + + try { + const result = await createBitcoinWallet(seed); + return json(result); + } catch (err) { + const status = (err as { status?: number }).status ?? 500; + return json({ ok: false, error: (err as Error).message }, status === 503 ? 503 : 409); + } +}; diff --git a/app/src/pages/api/bridge/ml-intent-tx.test.ts b/app/src/pages/api/bridge/ml-intent-tx.test.ts new file mode 100644 index 0000000..1ef1cb1 --- /dev/null +++ b/app/src/pages/api/bridge/ml-intent-tx.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + process.env.BRIDGE_API_URL = 'https://bridge.test/api/v1/'; + process.env.WALLET_RPC_URL = 'http://wallet-rpc:3034'; + process.env.WALLET_RPC_USERNAME = 'w'; + process.env.WALLET_RPC_PASSWORD = 'p'; +}); + +afterEach(() => { + mockFetch.mockReset(); + vi.unstubAllGlobals(); + delete process.env.BRIDGE_API_URL; + delete process.env.WALLET_RPC_URL; + delete process.env.WALLET_RPC_USERNAME; + delete process.env.WALLET_RPC_PASSWORD; +}); + +const CONFIG = { + network_type: 'testnet', + ml_tokens: { crv: '0xdeadbeef' }, + eth_flavor_specific_config: { + sepolia: { m2e: { deposit_tx_destination: 'tmt1deposit' } }, + }, +}; + +function mockAgentsConfig() { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(CONFIG), { status: 200 })); +} + +function makeCtx(body: unknown) { + return { + request: new Request('http://localhost/api/bridge/ml-intent-tx', { + method: 'POST', + body: JSON.stringify(body), + }), + } as never; +} + +describe('POST /api/bridge/ml-intent-tx', () => { + it('returns 400 when required fields are missing', async () => { + const { POST } = await import('@/pages/api/bridge/ml-intent-tx'); + for (const body of [{}, { token_id: 't' }, { token_id: 't', amount: '1' }, { token_id: 't', amount: '1', intent: 'nothex' }]) { + const res = await POST(makeCtx(body)); + expect(res.status).toBe(400); + } + }); + + it('rejects invalid amounts', async () => { + const { POST } = await import('@/pages/api/bridge/ml-intent-tx'); + for (const bad of ['-1', 'abc', '0', '1.234567890123']) { + const res = await POST(makeCtx({ token_id: 't', amount: bad, intent: '0xabc' })); + expect(res.status).toBe(400); + } + }); + + it('rejects non-0x intents', async () => { + const { POST } = await import('@/pages/api/bridge/ml-intent-tx'); + const res = await POST(makeCtx({ token_id: 'crv', amount: '1', intent: 'tmt1bad' })); + expect(res.status).toBe(400); + }); + + it('rejects unsupported tokens', async () => { + mockAgentsConfig(); + const { POST } = await import('@/pages/api/bridge/ml-intent-tx'); + const res = await POST(makeCtx({ token_id: 'unknown', amount: '1', intent: '0xabc' })); + expect(res.status).toBe(400); + }); + + it('returns 503 when the bridge config is unreachable', async () => { + const intent = '0x' + '1'.repeat(40); + mockFetch.mockRejectedValueOnce(new Error('down')); + const { POST } = await import('@/pages/api/bridge/ml-intent-tx'); + const res = await POST(makeCtx({ token_id: 't', amount: '1', intent })); + expect(res.status).toBe(503); + }); + + it('creates the intent tx and returns raw tx + intent', async () => { + mockAgentsConfig(); + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ result: { transaction: 'ff00', signed_intent: 'aa00' } }), + { status: 200 }, + ), + ); + const { POST } = await import('@/pages/api/bridge/ml-intent-tx'); + const res = await POST( + makeCtx({ token_id: 'crv', amount: '2.5', intent: '0x1111111111111111111111111111111111111111' }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.raw_transaction).toBe('ff00'); + expect(body.intent).toBe('aa00'); + }); + + it('maps wallet RPC errors to 400', async () => { + const intent = '0x' + '1'.repeat(40); + mockAgentsConfig(); + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: -4, message: 'insufficient funds' } }), + { status: 200 }, + ), + ); + const { POST } = await import('@/pages/api/bridge/ml-intent-tx'); + const res = await POST(makeCtx({ token_id: 'crv', amount: '999', intent })); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe('insufficient funds'); + }); +}); diff --git a/app/src/pages/api/bridge/ml-intent-tx.ts b/app/src/pages/api/bridge/ml-intent-tx.ts new file mode 100644 index 0000000..4fa05dc --- /dev/null +++ b/app/src/pages/api/bridge/ml-intent-tx.ts @@ -0,0 +1,135 @@ +import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; + +/** + * POST /api/bridge/ml-intent-tx + * + * Creates the signed-but-unbroadcast Mintlayer transaction + signed intent + * needed for an M2E bridge request (Mintlayer -> Ethereum). Runs server-side + * so wallet-rpc credentials never reach the browser. + * + * Security posture: the token_id and destination are NOT taken from the + * request — they are resolved server-side from the bridge agents-config, so + * a caller can only mint bridge deposits for supported tokens into the + * bridge's own deposit address. The intent (EVM receiver) is user choice. + * + * Body: { token_id, amount, intent } + * - token_id: Mintlayer token id (must be a bridged ML token) + * - amount: decimal amount string (token units) + * - intent: destination Ethereum address (0x…) + * Returns: { ok, raw_transaction, intent } on success. + */ + +interface AgentsConfig { + network_type: string; + ml_tokens: Record; + eth_flavor_specific_config: Record< + string, + { + m2e: { deposit_tx_destination: string } | null; + } + >; +} + +function pickFlavorConfig(config: AgentsConfig) { + const flavors = config.eth_flavor_specific_config ?? {}; + const key = Object.keys(flavors).find((k) => flavors[k]?.m2e != null) ?? ''; + return { flavor: key, cfg: flavors[key] }; +} + +async function fetchJson(url: string, timeoutMs = 15000): Promise { + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) throw new Error(`bridge API returned ${res.status}`); + return res.json() as Promise; +} + +export const POST: APIRoute = async ({ request }) => { + let body: { token_id?: unknown; amount?: unknown; intent?: unknown }; + try { + body = await request.json(); + } catch { + return json({ ok: false, error: 'Invalid request body' }, 400); + } + + const tokenId = typeof body.token_id === 'string' ? body.token_id.trim() : ''; + const amount = typeof body.amount === 'string' ? body.amount.trim() : ''; + const intent = typeof body.intent === 'string' ? body.intent.trim() : ''; + + if (!tokenId || !amount || !intent) { + return json({ ok: false, error: 'token_id, amount and intent are required' }, 400); + } + if (!/^0x[0-9a-fA-F]{40}$/.test(intent)) { + return json({ ok: false, error: 'intent must be a 0x… Ethereum address' }, 400); + } + if (!/^\d+(\.\d+)?$/.test(amount) || parseFloat(amount) <= 0) { + return json({ ok: false, error: 'amount must be a positive decimal string' }, 400); + } + + // Resolve bridged-token + deposit destination from the live agents config + // (never trust client-supplied destinations). + let destination: string; + try { + const config = await fetchJson( + `${process.env.BRIDGE_API_URL ?? 'https://api.bridge.mintlayer.org/api/v1/'}agents-config`, + ); + if (!config.ml_tokens?.[tokenId]) { + return json({ ok: false, error: `token ${tokenId} is not bridged` }, 400); + } + const { cfg } = pickFlavorConfig(config); + destination = cfg?.m2e?.deposit_tx_destination ?? ''; + if (!destination) { + return json({ ok: false, error: 'bridge M2E deposits are not configured' }, 503); + } + } catch (err) { + return json( + { ok: false, error: `bridge config unavailable: ${(err as Error).message}` }, + 503, + ); + } + + // Create the signed, unbroadcast intent transaction via wallet-rpc-daemon. + try { + const auth = Buffer.from( + `${process.env.WALLET_RPC_USERNAME}:${process.env.WALLET_RPC_PASSWORD}`, + ).toString('base64'); + const res = await fetch( + process.env.WALLET_RPC_URL ?? 'http://wallet-rpc-daemon:3034', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Basic ${auth}` }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'token_make_tx_to_send_with_intent', + params: { + account: 0, + token_id: tokenId, + address: destination, + amount, + intent, + options: {}, + }, + }), + signal: AbortSignal.timeout(120_000), + }, + ); + const data = (await res.json()) as { + result?: { transaction?: string; signed_intent?: string }; + error?: { message?: string }; + }; + if (data.error) { + return json({ ok: false, error: data.error.message ?? 'wallet RPC error' }, 400); + } + const rawTx = data.result?.transaction; + const signedIntent = data.result?.signed_intent; + if (!rawTx || !signedIntent) { + return json({ ok: false, error: 'wallet returned an incomplete transaction' }, 500); + } + return json({ ok: true, raw_transaction: rawTx, intent: signedIntent }); + } catch (err) { + return json( + { ok: false, error: `wallet RPC failed: ${(err as Error).message}` }, + 503, + ); + } +}; diff --git a/app/src/pages/balances.astro b/app/src/pages/balances.astro index a66bbd9..bd3cf77 100644 --- a/app/src/pages/balances.astro +++ b/app/src/pages/balances.astro @@ -5,6 +5,10 @@ import { getBalance, getTokensInfo, isWalletNotOpenError, type TokenInfo } from import { hexToText } from '@/lib/token-utils'; import { INDEXER_START_CMD } from '@/lib/indexer'; +// Mintlayer bech32 hrp (verified: testnet=tmt, mainnet=mtc). Null on other +// networks - address correction then stays off and the daemon validates. +const mlHrp = process.env.NETWORK === 'testnet' ? 'tmt' : process.env.NETWORK === 'mainnet' ? 'mtc' : null; + let mlBalance: string | null = null; let mlLocked: string | null = null; let tokenBalances: Record = {}; @@ -297,9 +301,15 @@ function nftIconUrl(info: TokenInfo | undefined): string | null { - +
@@ -352,6 +362,7 @@ function nftIconUrl(info: TokenInfo | undefined): string | null { + + {bitcoinEnabled && ( +
+

Bitcoin

+

+ Optional BTC wallet backed by your own Bitcoin node. Node and wallet services are + managed from the host with docker compose --profile bitcoin. +

+ + {result.action === 'success' && result.section === 'bitcoin' && ( +
+ {result.message} +
+ )} + +
+ Checking node status… +
+ +
+ + + +
+ + +
+ )} +

Telegram Notifications

diff --git a/app/src/pages/setup.astro b/app/src/pages/setup.astro index 788a2ca..44103e0 100644 --- a/app/src/pages/setup.astro +++ b/app/src/pages/setup.astro @@ -1,6 +1,7 @@ --- import Layout from '@/layouts/Layout.astro'; import { openWallet, createWallet, recoverWallet } from '@/lib/wallet-rpc'; +import { isBitcoinEnabled, createBitcoinWallet } from '@/lib/bitcoin-wallet'; import SeedBackupStep from '@/components/SeedBackupStep'; import { createRequire } from 'node:module'; import { join } from 'node:path'; @@ -33,13 +34,29 @@ const WALLET_PATH = '/home/mintlayer/mintlayer.wallet'; type SetupResult = | { action: 'none' } | { action: 'error'; message: string } - | { action: 'create_success'; mnemonic: string; walletPath: string } - | { action: 'import_success' } + | { action: 'create_success'; mnemonic: string; walletPath: string; btcNote?: string } + | { action: 'import_success'; btcNote?: string } | { action: 'upload_success' } | { action: 'upload_confirm_needed' }; let result: SetupResult = { action: 'none' }; +// One seed for both chains: when the optional bitcoin profile is enabled, +// initialize the BTC wallet from the same mnemonic the ML wallet was created +// (or restored) with. Non-fatal - setup still succeeds when the sidecar is +// unreachable; the Bitcoin page offers its own create/restore as a fallback. +async function initBtcWalletFromSeed(mnemonic: string): Promise { + if (!isBitcoinEnabled()) return undefined; + try { + await createBitcoinWallet(mnemonic); + return 'The same seed was configured for the BTC wallet (see the Bitcoin page).'; + } catch (err) { + const msg = (err as Error).message; + if (/already exists/i.test(msg)) return 'BTC wallet already configured.'; + return `BTC wallet setup failed: ${msg}`; + } +} + // ── POST handlers ───────────────────────────────────────────────────────────── if (Astro.request.method === 'POST') { @@ -62,7 +79,8 @@ if (Astro.request.method === 'POST') { const res = await createWallet(WALLET_PATH, storeSeed, undefined, noPassword ? undefined : passphrase); const mnemonicStr = res.mnemonic.type === 'NewlyGenerated' ? res.mnemonic.content.mnemonic : ''; - result = { action: 'create_success', mnemonic: mnemonicStr, walletPath: WALLET_PATH }; + const btcNote = await initBtcWalletFromSeed(mnemonicStr); + result = { action: 'create_success', mnemonic: mnemonicStr, walletPath: WALLET_PATH, btcNote }; } catch (err) { result = { action: 'error', message: (err as Error).message }; } @@ -81,7 +99,8 @@ if (Astro.request.method === 'POST') { } else { try { await recoverWallet(WALLET_PATH, mnemonic, storeSeed, passphrase); - result = { action: 'import_success' }; + const btcNote = await initBtcWalletFromSeed(mnemonic); + result = { action: 'import_success', btcNote }; } catch (err) { result = { action: 'error', message: (err as Error).message }; } @@ -128,7 +147,10 @@ Astro.response.headers.set('Cache-Control', 'no-store'); {/* When create succeeds, show ONLY the seed backup wizard - nothing else */} {result.action === 'create_success' ? ( <> -

New Wallet Created

+

New Wallet Created

+ {result.btcNote && ( +

{result.btcNote}

+ )} Wallet imported successfully. {' '}
Go to dashboard → + {result.btcNote &&

{result.btcNote}

}
)} diff --git a/bdk-wallet/Cargo.lock b/bdk-wallet/Cargo.lock new file mode 100644 index 0000000..405b8bb --- /dev/null +++ b/bdk-wallet/Cargo.lock @@ -0,0 +1,2138 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bdk" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc1fc1a92e0943bfbcd6eb7d32c1b2a79f2f1357eb1e2eee9d7f36d6d7ca44a" +dependencies = [ + "ahash 0.7.8", + "async-trait", + "bdk-macros", + "bitcoin", + "core-rpc", + "electrum-client", + "getrandom 0.2.17", + "js-sys", + "log", + "miniscript", + "rand 0.8.8", + "rusqlite", + "serde", + "serde_json", + "sled", + "tokio", +] + +[[package]] +name = "bdk-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81c1980e50ae23bb6efa9283ae8679d6ea2c6fa6a99fe62533f65f4a25a1a56c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bdk-wallet" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64 0.22.1", + "bdk", + "bip39", + "log", + "rand 0.8.8", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin" +version = "0.30.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f50161c4b69a2c8fd7d5d7d8b58bb83900a7ba7080010014cca7a80fe56d1d" +dependencies = [ + "base64 0.13.1", + "bech32", + "bitcoin-private", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + +[[package]] +name = "bitcoin_hashes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" +dependencies = [ + "bitcoin-private", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "core-rpc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d77079e1b71c2778d6e1daf191adadcd4ff5ec3ccad8298a79061d865b235b" +dependencies = [ + "bitcoin-private", + "core-rpc-json", + "jsonrpc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "core-rpc-json" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581898ed9a83f31c64731b1d8ca2dfffcfec14edf1635afacd5234cddbde3a41" +dependencies = [ + "bitcoin", + "bitcoin-private", + "serde", + "serde_json", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "electrum-client" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bc133f1c8d829d254f013f946653cbeb2b08674b960146361d1e9b67733ad19" +dependencies = [ + "bitcoin", + "bitcoin-private", + "byteorder", + "libc", + "log", + "rustls 0.21.12", + "serde", + "serde_json", + "webpki", + "webpki-roots 0.22.6", + "winapi", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.43", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpc" +version = "0.13.0" +dependencies = [ + "base64 0.13.1", + "serde", + "serde_json", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniscript" +version = "10.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e88ef03cc0ce21bcf584da157890b292fc4b69bfeaa4e9d41bbf492da59b22f" +dependencies = [ + "bitcoin", + "bitcoin-private", + "serde", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.43", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.43", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.43", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a" +dependencies = [ + "bitflags 1.3.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.15", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "secp256k1" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.8", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.43", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bdk-wallet/Cargo.toml b/bdk-wallet/Cargo.toml new file mode 100644 index 0000000..38f670f --- /dev/null +++ b/bdk-wallet/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "bdk-wallet" +version = "0.1.0" +edition = "2021" +description = "Minimal BDK-based Bitcoin wallet HTTP sidecar for mintlayer web-gui" +publish = false + +[dependencies] +anyhow = "1" +axum = "0.7" +base64 = "0.22" +bip39 = "2" +rand = "0.8" +bdk = { version = "0.29", features = ["sqlite", "rpc"] } +log = "0.4" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[profile.release] +strip = true +lto = true + +# Vendored jsonrpc 0.13.0 with the per-request timeout raised from 15s to 120s. +# bdk 0.29's rpc backend imports ~200 watch-only descriptors (its hardcoded +# 100-per-keychain cache) in a single `importdescriptors` call; on modest +# hardware that exceeds 15s, so the sync would abort before ever completing. +[patch.crates-io] +jsonrpc = { path = "vendor/jsonrpc" } diff --git a/bdk-wallet/Dockerfile b/bdk-wallet/Dockerfile new file mode 100644 index 0000000..e610bb7 --- /dev/null +++ b/bdk-wallet/Dockerfile @@ -0,0 +1,28 @@ +# ── Build stage ──────────────────────────────────────────────────────────────── +FROM rust:1-slim AS builder + +WORKDIR /build +RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY vendor ./vendor +RUN cargo build --release + +# ── Runtime stage ────────────────────────────────────────────────────────────── +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libsqlite3-0 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --uid 1000 --create-home wallet + +COPY --from=builder /build/target/release/bdk-wallet /usr/local/bin/bdk-wallet + +USER wallet +ENV DATA_DIR=/data \ + BIND_ADDR=0.0.0.0:8080 +VOLUME /data +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/bdk-wallet"] diff --git a/bdk-wallet/src/main.rs b/bdk-wallet/src/main.rs new file mode 100644 index 0000000..ad8dcc8 --- /dev/null +++ b/bdk-wallet/src/main.rs @@ -0,0 +1,708 @@ +use anyhow::{anyhow, bail, Context, Result}; +use axum::{ + extract::State, + http::{header, HeaderMap}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use bdk::blockchain::rpc::{Auth, RpcBlockchain, RpcConfig, RpcSyncParams}; +use bdk::blockchain::{Blockchain, ConfigurableBlockchain}; +use bdk::database::any::SqliteDbConfiguration; +use bdk::database::{AnyDatabase, AnyDatabaseConfig, ConfigurableDatabase}; +use bdk::wallet::AddressIndex; +use bdk::{FeeRate, SignOptions, SyncOptions, TransactionDetails, Wallet}; +use bdk::bitcoin::{bip32::ExtendedPrivKey, Address, Network}; +use bip39::{Language, Mnemonic}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::{ + fs, + path::PathBuf, + str::FromStr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, +}; +use tokio::{sync::Mutex as AsyncMutex, task::spawn_blocking}; +use tracing::{error, info, warn}; + +// ── Configuration (env) ──────────────────────────────────────────────────────── + +struct Config { + network: Network, + data_dir: PathBuf, + rpc_url: String, + rpc_user: String, + rpc_pass: String, + http_user: String, + http_pass: String, +} + +impl Config { + fn from_env() -> Result { + let network = match std::env::var("BITCOIN_NETWORK").unwrap_or_default().as_str() { + "testnet" => Network::Testnet, + "regtest" => Network::Regtest, + "signet" => Network::Signet, + _ => Network::Bitcoin, + }; + Ok(Self { + network, + data_dir: PathBuf::from(std::env::var("DATA_DIR").unwrap_or_else(|_| "/data".into())), + rpc_url: require_env("BITCOIN_RPC_URL")?, + rpc_user: require_env("BITCOIN_RPC_USERNAME")?, + rpc_pass: require_env("BITCOIN_RPC_PASSWORD")?, + http_user: require_env("WALLET_HTTP_USERNAME")?, + http_pass: require_env("WALLET_HTTP_PASSWORD")?, + }) + } + + /// bitcoind connection config for the BDK rpc backend. + /// + /// `start_time` bounds how far back the node scans for the imported + /// watch-only descriptors: for a fresh wallet we use the wallet's creation + /// time (persisted in seed.json) so the first sync does not walk the whole + /// chain. Subsequent syncs continue from the node's own recorded sync time. + fn rpc_config(&self, sync_start_time: u64) -> RpcConfig { + RpcConfig { + url: self.rpc_url.clone(), + auth: Auth::UserPass { + username: self.rpc_user.clone(), + password: self.rpc_pass.clone(), + }, + network: self.network, + wallet_name: "bdk-sidecar".to_string(), + sync_params: Some(RpcSyncParams { + start_time: sync_start_time, + // Matches INITIAL_SCRIPT_CACHE below: the initial importdescriptors + // batch must stay small, because the jsonrpc client enforces a hard + // 15s request timeout and a large import triggers a rescan that + // exceeds it (sync would then never complete). + start_script_count: INITIAL_SCRIPT_CACHE, + ..Default::default() + }), + } + } +} + +fn require_env(name: &str) -> Result { + std::env::var(name) + .ok() + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow!("missing required env var {name}")) +} + +// ── State ────────────────────────────────────────────────────────────────────── + +type BdkWallet = Wallet; + +/// How many addresses per keychain to derive and cache up front. Keep this +/// small - see the comment on `start_script_count` in `Config::rpc_config`. +const INITIAL_SCRIPT_CACHE: usize = 20; + +struct AppState { + config: Arc, + wallet: AsyncMutex>, + /// Wallet creation unix time - sync horizon for freshly created wallets. + sync_start_time: Mutex, + syncing: AtomicBool, + node: reqwest::Client, +} + +impl AppState { + fn seed_path(&self) -> PathBuf { + self.config.data_dir.join("seed.json") + } +} + +// ── Seed persistence ─────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +struct SeedFile { + mnemonic: String, + network: String, + created_at: u64, +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn save_seed(path: &PathBuf, mnemonic: &str, network: Network) -> Result<()> { + let file = SeedFile { + mnemonic: mnemonic.to_string(), + network: network_name(network).to_string(), + created_at: now_unix(), + }; + fs::write(path, serde_json::to_string_pretty(&file)?)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +fn network_name(n: Network) -> &'static str { + match n { + Network::Bitcoin => "mainnet", + Network::Testnet => "testnet", + Network::Regtest => "regtest", + Network::Signet => "signet", + _ => "unknown", + } +} + +// ── Wallet construction ──────────────────────────────────────────────────────── + +/// Open (or create) the wallet database for the given mnemonic. +fn build_wallet(config: &Config, mnemonic: &str) -> Result { + let m = Mnemonic::parse_in(Language::English, mnemonic).context("invalid mnemonic")?; + let seed = m.to_seed_normalized(""); + let xprv = ExtendedPrivKey::new_master(config.network, &seed)?; + + // BIP84: account 0, coin type 0' on mainnet / 1' on test networks + let coin = if config.network == Network::Bitcoin { "0" } else { "1" }; + let external = format!("wpkh({xprv}/84'/{coin}'/0'/0/*)"); + let internal = format!("wpkh({xprv}/84'/{coin}'/0'/1/*)"); + + let database = AnyDatabase::from_config(&AnyDatabaseConfig::Sqlite(SqliteDbConfiguration { + path: config.data_dir.join("wallet.sqlite").display().to_string(), + }))?; + + let wallet = Wallet::new(&external, Some(&internal), config.network, database) + .context("failed to open wallet")?; + + // The rpc backend refuses to sync until enough scriptPubKeys are cached + // (start_script_count). Pre-derive them; a no-op on existing DBs. + for index in 0..INITIAL_SCRIPT_CACHE as u32 { + wallet + .get_address(AddressIndex::Peek(index)) + .context("deriving initial addresses")?; + } + + Ok(wallet) +} + +fn generate_mnemonic() -> Result { + let entropy: [u8; 16] = rand::random(); + Ok(Mnemonic::from_entropy_in(Language::English, &entropy)?.to_string()) +} + +// ── bitcoind JSON-RPC helpers ────────────────────────────────────────────────── + +async fn node_rpc( + state: &AppState, + method: &str, + params: serde_json::Value, +) -> Result { + let res = state + .node + .post(&state.config.rpc_url) + .basic_auth(&state.config.rpc_user, Some(&state.config.rpc_pass)) + .json(&json!({ "jsonrpc": "1.0", "id": "bdk", "method": method, "params": params })) + .send() + .await + .context("bitcoind unreachable")?; + let body: serde_json::Value = res.json().await.context("bad JSON-RPC response")?; + if let Some(err) = body.get("error") { + if !err.is_null() { + bail!("bitcoind RPC error: {err}"); + } + } + Ok(body.get("result").cloned().unwrap_or(serde_json::Value::Null)) +} + +// ── BTC decimal parsing (string -> sats, no floats) ──────────────────────────── + +fn parse_btc_to_sats(amount: &str) -> Result { + let trimmed = amount.trim(); + if trimmed.is_empty() || !trimmed.chars().all(|c| c.is_ascii_digit() || c == '.') { + bail!("amount must contain only digits and a decimal point"); + } + let (whole, frac) = match trimmed.split_once('.') { + None => (trimmed, ""), + Some((w, f)) => (w, f), + }; + if frac.len() > 8 { + bail!("amount supports at most 8 decimal places"); + } + let whole: u64 = if whole.is_empty() { + 0 + } else { + whole.parse().context("bad integer part")? + }; + let mut sats: u64 = whole.checked_mul(100_000_000).context("amount overflow")?; + let mut scale = 10_000_000u64; // first fractional digit is worth 1e7 sats + for c in frac.chars() { + sats = sats + .checked_add((c as u8 - b'0') as u64 * scale) + .context("amount overflow")?; + scale /= 10; + } + Ok(sats) +} + +// ── Auth middleware ──────────────────────────────────────────────────────────── + +async fn auth_middleware( + State(state): State>, + headers: HeaderMap, + request: axum::extract::Request, + next: Next, +) -> Response { + if request.uri().path() == "/health" { + return next.run(request).await; + } + let ok = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Basic ")) + .and_then(|b64| { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(b64) + .ok() + .and_then(|raw| String::from_utf8(raw).ok()) + }) + .map(|creds| { + creds + .split_once(':') + .map(|(u, p)| u == state.config.http_user && p == state.config.http_pass) + .unwrap_or(false) + }) + .unwrap_or(false); + if ok { + next.run(request).await + } else { + (axum::http::StatusCode::UNAUTHORIZED, "unauthorized").into_response() + } +} + +// ── Handlers ─────────────────────────────────────────────────────────────────── + +fn internal_error(e: anyhow::Error) -> Response { + error!("{e:#}"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "ok": false, "error": e.to_string() })), + ) + .into_response() +} + +/// Load the wallet in a blocking task and store it in the shared state. +async fn open_wallet(state: &Arc, mnemonic: &str, created_at: u64) -> Result<()> { + let config = Arc::clone(&state.config); + let mnemonic = mnemonic.to_string(); + let wallet = spawn_blocking(move || build_wallet(&config, &mnemonic)) + .await + .map_err(|e| anyhow!("join error: {e}"))??; + *state.sync_start_time.lock().unwrap() = created_at; + *state.wallet.lock().await = Some(wallet); + Ok(()) +} + +#[derive(Deserialize)] +struct CreateWalletReq { + /// Optional BIP39 mnemonic to restore. Generated when absent. + seed: Option, +} + +/// POST /wallet - create the wallet from an optional seed. +/// Returns the mnemonic ONLY on creation - it is never served again. +async fn create_wallet( + State(state): State>, + Json(req): Json, +) -> Response { + if state.seed_path().exists() { + return ( + axum::http::StatusCode::CONFLICT, + Json(json!({ "ok": false, "error": "wallet already exists" })), + ) + .into_response(); + } + + let mnemonic = match req.seed { + Some(s) => { + let normalized = s.split_whitespace().collect::>().join(" ").to_lowercase(); + if let Err(e) = Mnemonic::parse_in(Language::English, &normalized) { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(json!({ "ok": false, "error": format!("invalid seed: {e}") })), + ) + .into_response(); + } + normalized + } + None => match generate_mnemonic() { + Ok(m) => m, + Err(e) => return internal_error(e.context("mnemonic generation")), + }, + }; + + if let Err(e) = save_seed(&state.seed_path(), &mnemonic, state.config.network) { + return internal_error(e.context("persisting seed")); + } + + match open_wallet(&state, &mnemonic, now_unix()).await { + Ok(()) => Json(json!({ + "ok": true, + "created": true, + "network": network_name(state.config.network), + "mnemonic": mnemonic, + })) + .into_response(), + Err(e) => internal_error(e.context("opening wallet after creation")), + } +} + +/// POST /sync - sync the wallet against bitcoind (no-op while one runs). +async fn sync(State(state): State>) -> Response { + if state.syncing.swap(true, Ordering::SeqCst) { + return Json(json!({ "ok": true, "syncStarted": false })).into_response(); + } + let st = Arc::clone(&state); + tokio::spawn(async move { + let res = spawn_blocking(move || -> Result<()> { + let start_time = *st.sync_start_time.lock().unwrap(); + let chain = RpcBlockchain::from_config(&st.config.rpc_config(start_time))?; + let mut guard = st.wallet.blocking_lock(); + if let Some(w) = guard.as_mut() { + w.sync(&chain, SyncOptions::default())?; + } + Ok(()) + }) + .await; + match res { + Ok(Ok(())) => info!("wallet sync complete"), + Ok(Err(e)) => warn!("wallet sync failed: {e:#}"), + Err(e) => warn!("sync task join error: {e}"), + } + state.syncing.store(false, Ordering::SeqCst); + }); + Json(json!({ "ok": true, "syncStarted": true })).into_response() +} + +/// GET /status - node + wallet overview for the Settings page. +async fn status(State(state): State>) -> Response { + let chain = node_rpc(&state, "getblockchaininfo", json!([])).await; + let (blocks, headers, ibd) = match &chain { + Ok(info) => ( + info.get("blocks").and_then(|b| b.as_u64()).unwrap_or(0), + info.get("headers").and_then(|b| b.as_u64()).unwrap_or(0), + info.get("initialblockdownload") + .and_then(|b| b.as_bool()) + .unwrap_or(false), + ), + Err(_) => (0, 0, false), + }; + + let wallet_exists = state.seed_path().exists(); + let wallet = state.wallet.lock().await; + let (wallet_loaded, balance) = match wallet.as_ref() { + Some(w) => match w.get_balance() { + Ok(b) => ( + true, + Some(json!({ + "confirmed": b.confirmed.to_string(), + "trustedPending": b.trusted_pending.to_string(), + "untrustedPending": b.untrusted_pending.to_string(), + "immature": b.immature.to_string(), + })), + ), + Err(e) => { + warn!("get_balance failed: {e}"); + (true, None) + } + }, + None => (false, None), + }; + + Json(json!({ + "ok": true, + "network": network_name(state.config.network), + "walletExists": wallet_exists, + "walletLoaded": wallet_loaded, + "node": { + "reachable": chain.is_ok(), + "blocks": blocks, + "headers": headers, + "synced": !ibd && blocks == headers, + "initialBlockDownload": ibd, + }, + "balance": balance, + })) + .into_response() +} + +/// GET /balance - totals in satoshis (strings). +async fn balance(State(state): State>) -> Response { + let wallet = state.wallet.lock().await; + let Some(w) = wallet.as_ref() else { + return ( + axum::http::StatusCode::CONFLICT, + Json(json!({ "ok": false, "error": "wallet not created yet - POST /wallet first" })), + ) + .into_response(); + }; + match w.get_balance() { + Ok(b) => Json(json!({ + "ok": true, + "confirmed": b.confirmed.to_string(), + "trustedPending": b.trusted_pending.to_string(), + "untrustedPending": b.untrusted_pending.to_string(), + "immature": b.immature.to_string(), + "total": (b.confirmed + b.trusted_pending + b.untrusted_pending).to_string(), + })) + .into_response(), + Err(e) => internal_error(e.into()), + } +} + +/// POST /address/new - derive a fresh receive address. +async fn new_address(State(state): State>) -> Response { + let mut wallet = state.wallet.lock().await; + match wallet.as_mut() { + Some(w) => match w.get_address(AddressIndex::New) { + Ok(info) => Json(json!({ "ok": true, "address": info.address.to_string() })).into_response(), + Err(e) => internal_error(e.into()), + }, + None => ( + axum::http::StatusCode::CONFLICT, + Json(json!({ "ok": false, "error": "wallet not created yet - POST /wallet first" })), + ) + .into_response(), + } +} + +/// GET /address/current - last unused address (for the Receive view). +async fn current_address(State(state): State>) -> Response { + let mut wallet = state.wallet.lock().await; + match wallet.as_mut() { + Some(w) => match w.get_address(AddressIndex::LastUnused) { + Ok(info) => Json(json!({ "ok": true, "address": info.address.to_string() })).into_response(), + Err(e) => internal_error(e.into()), + }, + None => ( + axum::http::StatusCode::CONFLICT, + Json(json!({ "ok": false, "error": "wallet not created yet - POST /wallet first" })), + ) + .into_response(), + } +} + +/// GET /txs?limit=50 - wallet transaction history, newest first. +#[derive(Deserialize)] +struct TxsQuery { + limit: Option, +} + +async fn txs( + State(state): State>, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + let wallet = state.wallet.lock().await; + let Some(w) = wallet.as_ref() else { + return ( + axum::http::StatusCode::CONFLICT, + Json(json!({ "ok": false, "error": "wallet not created yet - POST /wallet first" })), + ) + .into_response(); + }; + let limit = q.limit.unwrap_or(50).min(500); + let tx_list = w.list_transactions(false).unwrap_or_default(); + let mut list: Vec<&TransactionDetails> = tx_list.iter().collect(); + list.sort_by_key(|t| { + std::cmp::Reverse(t.confirmation_time.as_ref().map(|c| c.height).unwrap_or(u32::MAX)) + }); + let txs: Vec = list + .into_iter() + .take(limit) + .map(|t| { + json!({ + "txid": t.txid.to_string(), + "received": t.received.to_string(), + "sent": t.sent.to_string(), + "fee": t.fee.map(|f| f.to_string()), + "confirmed": t.confirmation_time.is_some(), + "height": t.confirmation_time.as_ref().map(|c| c.height), + "timestamp": t.confirmation_time.as_ref().map(|c| c.timestamp), + }) + }) + .collect(); + Json(json!({ "ok": true, "transactions": txs })).into_response() +} + +#[derive(Deserialize)] +struct SendReq { + address: String, + /// BTC amount as a decimal string (max 8 decimals) - never a float. + amount_btc: String, + /// Optional fee rate in sat/vB. + fee_rate_sat_vb: Option, +} + +/// POST /send - build, sign and broadcast a transaction. +async fn send(State(state): State>, Json(req): Json) -> Response { + let address = match Address::from_str(&req.address) { + Ok(a) => a, + Err(_) => { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(json!({ "ok": false, "error": "invalid bitcoin address" })), + ) + .into_response() + } + }; + if !address.is_valid_for_network(state.config.network) { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(json!({ "ok": false, "error": format!( + "address is not valid for {}", + network_name(state.config.network) + ) })), + ) + .into_response(); + } + + let sats = match parse_btc_to_sats(&req.amount_btc) { + Ok(s) => s, + Err(e) => { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(json!({ "ok": false, "error": e.to_string() })), + ) + .into_response() + } + }; + if sats == 0 { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(json!({ "ok": false, "error": "amount must be greater than zero" })), + ) + .into_response(); + } + + let st = Arc::clone(&state); + let res = spawn_blocking(move || -> Result { + let start_time = *st.sync_start_time.lock().unwrap(); + let chain = RpcBlockchain::from_config(&st.config.rpc_config(start_time))?; + let mut guard = st.wallet.blocking_lock(); + let Some(w) = guard.as_mut() else { + bail!("wallet not created yet - POST /wallet first"); + }; + let mut builder = w.build_tx(); + builder.add_recipient(address.payload.script_pubkey(), sats); + if let Some(rate) = req.fee_rate_sat_vb { + builder.fee_rate(FeeRate::from_sat_per_vb(rate)); + } + let (mut psbt, _details) = builder.finish()?; + let finalized = w + .sign(&mut psbt, SignOptions::default()) + .context("signing failed")?; + if !finalized { + bail!("could not finalize transaction"); + } + let tx = psbt.extract_tx(); + let txid = tx.txid().to_string(); + chain.broadcast(&tx)?; + Ok(txid) + }) + .await; + + match res { + Ok(Ok(txid)) => Json(json!({ "ok": true, "txid": txid })).into_response(), + Ok(Err(e)) => internal_error(e), + Err(e) => internal_error(anyhow!("join error: {e}")), + } +} + +/// GET /fee-estimate - smart fee estimates (sat/vB) for common targets. +async fn fee_estimate(State(state): State>) -> Response { + let mut estimates = serde_json::Map::new(); + for target in [1u64, 3, 6, 12, 25] { + match node_rpc(&state, "estimatesmartfee", json!([target])).await { + Ok(v) => { + // btc/kB -> sat/vB = btc * 1e8 / 1000 + if let Some(btc_per_kb) = v.get("feerate").and_then(|f| f.as_f64()) { + estimates.insert( + target.to_string(), + json!((btc_per_kb * 100_000_000f64 / 1000f64).ceil() as u64), + ); + } + } + Err(e) => warn!("estimatesmartfee({target}) failed: {e:#}"), + } + } + Json(json!({ "ok": true, "satPerVb": estimates })).into_response() +} + +async fn health() -> &'static str { + "ok" +} + +// ── Main ─────────────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + let config = Arc::new(Config::from_env()?); + info!( + "starting bdk-wallet sidecar (network: {})", + network_name(config.network) + ); + + fs::create_dir_all(&config.data_dir)?; + + let state = Arc::new(AppState { + config, + wallet: AsyncMutex::new(None), + sync_start_time: Mutex::new(now_unix()), + syncing: AtomicBool::new(false), + node: reqwest::Client::new(), + }); + + // Auto-load the wallet if a seed was previously persisted. + match fs::read_to_string(state.seed_path()) { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(file) => match open_wallet(&state, &file.mnemonic, file.created_at).await { + Ok(()) => info!("wallet loaded from persisted seed"), + Err(e) => error!("failed to load persisted wallet: {e:#}"), + }, + Err(e) => error!("seed file unreadable: {e}"), + }, + Err(_) => info!("no seed found - POST /wallet to create or restore"), + } + + let app = Router::new() + .route("/wallet", post(create_wallet)) + .route("/status", get(status)) + .route("/sync", post(sync)) + .route("/balance", get(balance)) + .route("/address/new", post(new_address)) + .route("/address/current", get(current_address)) + .route("/txs", get(txs)) + .route("/send", post(send)) + .route("/fee-estimate", get(fee_estimate)) + .route("/health", get(health)) + .layer(middleware::from_fn_with_state(Arc::clone(&state), auth_middleware)) + .with_state(state); + + let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".into()); + let listener = tokio::net::TcpListener::bind(&addr).await?; + info!("listening on {addr}"); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/bdk-wallet/vendor/jsonrpc/.editorconfig b/bdk-wallet/vendor/jsonrpc/.editorconfig new file mode 100644 index 0000000..3178a1e --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/.editorconfig @@ -0,0 +1,5 @@ +# see https://editorconfig.org for more options, and setup instructions for yours editor + +[*] +indent_style = space +indent_size = 4 diff --git a/bdk-wallet/vendor/jsonrpc/.github/workflows/rust.yml b/bdk-wallet/vendor/jsonrpc/.github/workflows/rust.yml new file mode 100644 index 0000000..c54f010 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/.github/workflows/rust.yml @@ -0,0 +1,67 @@ +on: [push, pull_request] + +name: Continuous integration + +jobs: + Tests: + name: Tests + strategy: + matrix: + os: + - ubuntu-latest + - macOS-latest + - windows-latest + toolchain: + - 1.41.1 + - stable + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Crate + uses: actions/checkout@v2 + - name: Checkout Toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.toolchain }} + override: true + - name: Running tests on ${{ matrix.toolchain }} + env: + DO_FEATURE_MATRIX: true + run: cargo test --verbose --all-features + + Nightly: + name: Nightly - Docs + Fmt + runs-on: ubuntu-latest + steps: + - name: Checkout Crate + uses: actions/checkout@v2 + - name: Checkout Toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: nightly + override: true + - name: Building docs + env: + DO_DOCS: true + run: ./contrib/test.sh + - name: Run the formatter + env: + DO_FMT: true + run: ./contrib/test.sh + + Clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + - run: rustup component add clippy + - uses: actions-rs/cargo@v1 + with: + command: clippy + args: --all-features -- -D warnings diff --git a/bdk-wallet/vendor/jsonrpc/.gitignore b/bdk-wallet/vendor/jsonrpc/.gitignore new file mode 100644 index 0000000..46bf68e --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/.gitignore @@ -0,0 +1,3 @@ +target +Cargo.lock + diff --git a/bdk-wallet/vendor/jsonrpc/.rustfmt.toml b/bdk-wallet/vendor/jsonrpc/.rustfmt.toml new file mode 100644 index 0000000..da5f6e9 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/.rustfmt.toml @@ -0,0 +1 @@ +use_small_heuristics = "Off" diff --git a/bdk-wallet/vendor/jsonrpc/CHANGELOG.md b/bdk-wallet/vendor/jsonrpc/CHANGELOG.md new file mode 100644 index 0000000..bae6c0e --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/CHANGELOG.md @@ -0,0 +1,35 @@ +# 0.13.0 - 2022-17-21 "Edition 2018 Release" + +This release increases the MSRV to 1.41.1, bringing with it a bunch of new language features. + +Some highlights: + +- The MSRV bump [#58](https://github.com/apoelstra/rust-jsonrpc/pull/58) +- Add IPv6 support [#63](https://github.com/apoelstra/rust-jsonrpc/pull/63) +- Remove `serder_derive` dependency [#61](https://github.com/apoelstra/rust-jsonrpc/pull/61) + +# 0.12.1 - 2022-01-20 + +## Features + +* A new set of transports were added for JSONRPC over raw TCP sockets (one using `SocketAddr`, and + one UNIX-only using Unix Domain Sockets) + +## Bug fixes + +* The `Content-Type` HTTP header is now correctly set to `application/json` +* The `Connection: Close` HTTP header is now sent for requests + +# 0.12.0 - 2020-12-16 + +* Remove `http` and `hyper` dependencies +* Implement our own simple HTTP transport for Bitcoin Core +* But allow use of generic transports + +# 0.11.0 - 2019-04-05 + +* [Clean up the API](https://github.com/apoelstra/rust-jsonrpc/pull/19) +* [Set the content-type header to json]((https://github.com/apoelstra/rust-jsonrpc/pull/21) +* [Allow no `result` field in responses](https://github.com/apoelstra/rust-jsonrpc/pull/16) +* [Add batch request support](https://github.com/apoelstra/rust-jsonrpc/pull/24) + diff --git a/bdk-wallet/vendor/jsonrpc/Cargo.toml b/bdk-wallet/vendor/jsonrpc/Cargo.toml new file mode 100644 index 0000000..59ee070 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/Cargo.toml @@ -0,0 +1,44 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2018" +name = "jsonrpc" +version = "0.13.0" +authors = ["Andrew Poelstra "] +description = "Rust support for the JSON-RPC 2.0 protocol" +homepage = "https://github.com/apoelstra/rust-jsonrpc/" +documentation = "https://docs.rs/jsonrpc/" +readme = "README.md" +keywords = ["protocol", "json", "http", "jsonrpc"] +license = "CC0-1.0" +repository = "https://github.com/apoelstra/rust-jsonrpc/" + +[lib] +name = "jsonrpc" +path = "src/lib.rs" +[dependencies.base64] +version = "0.13.0" +optional = true + +[dependencies.serde] +version = "1" +features = ["derive"] + +[dependencies.serde_json] +version = "1" +features = ["raw_value"] + +[features] +default = ["simple_http", "simple_tcp"] +simple_http = ["base64"] +simple_tcp = [] +simple_uds = [] diff --git a/bdk-wallet/vendor/jsonrpc/Cargo.toml.orig b/bdk-wallet/vendor/jsonrpc/Cargo.toml.orig new file mode 100644 index 0000000..9bf6ce2 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/Cargo.toml.orig @@ -0,0 +1,32 @@ +[package] +name = "jsonrpc" +version = "0.13.0" +authors = ["Andrew Poelstra "] +license = "CC0-1.0" +homepage = "https://github.com/apoelstra/rust-jsonrpc/" +repository = "https://github.com/apoelstra/rust-jsonrpc/" +documentation = "https://docs.rs/jsonrpc/" +description = "Rust support for the JSON-RPC 2.0 protocol" +keywords = [ "protocol", "json", "http", "jsonrpc" ] +readme = "README.md" +edition = "2018" + +[lib] +name = "jsonrpc" +path = "src/lib.rs" + +[features] +default = [ "simple_http", "simple_tcp" ] +# A bare-minimum HTTP transport. +simple_http = [ "base64" ] +# Basic transport over a raw TcpListener +simple_tcp = [] +# Basic transport over a raw UnixStream +simple_uds = [] + + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = [ "raw_value" ] } + +base64 = { version = "0.13.0", optional = true } diff --git a/bdk-wallet/vendor/jsonrpc/LICENSE b/bdk-wallet/vendor/jsonrpc/LICENSE new file mode 100644 index 0000000..6ca207e --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/LICENSE @@ -0,0 +1,122 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. + diff --git a/bdk-wallet/vendor/jsonrpc/README.md b/bdk-wallet/vendor/jsonrpc/README.md new file mode 100644 index 0000000..8143bee --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/README.md @@ -0,0 +1,47 @@ +[![Status](https://travis-ci.org/apoelstra/rust-jsonrpc.png?branch=master)](https://travis-ci.org/apoelstra/rust-jsonrpc) + +# Rust Version compatibility + +This library is compatible with Rust **1.41.1** or higher. + +# Rust JSONRPC Client + +Rudimentary support for sending JSONRPC 2.0 requests and receiving responses. + +As an example, hit a local bitcoind JSON-RPC endpoint and call the `uptime` command. + +```rust +use jsonrpc::Client; +use jsonrpc::simple_http::{self, SimpleHttpTransport}; + +fn client(url: &str, user: &str, pass: &str) -> Result { + let t = SimpleHttpTransport::builder() + .url(url)? + .auth(user, Some(pass)) + .build(); + + Ok(Client::with_transport(t)) +} + +// Demonstrate an example JSON-RCP call against bitcoind. +fn main() { + let client = client("localhost:18443", "user", "pass").expect("failed to create client"); + let request = client.build_request("uptime", &[]); + let response = client.send_request(request).expect("send_request failed"); + + // For other commands this would be a struct matching the returned json. + let result: u64 = response.result().expect("response is an error, use check_error"); + println!("bitcoind uptime: {}", result); +} +``` + +## Githooks + +To assist devs in catching errors _before_ running CI we provide some githooks. If you do not +already have locally configured githooks you can use the ones in this repository by running, in the +root directory of the repository: +``` +git config --local core.hooksPath githooks/ +``` + +Alternatively add symlinks in your `.git/hooks` directory to any of the githooks we provide. diff --git a/bdk-wallet/vendor/jsonrpc/clippy.toml b/bdk-wallet/vendor/jsonrpc/clippy.toml new file mode 100644 index 0000000..799264e --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/clippy.toml @@ -0,0 +1 @@ +msrv = "1.41.1" diff --git a/bdk-wallet/vendor/jsonrpc/contrib/test.sh b/bdk-wallet/vendor/jsonrpc/contrib/test.sh new file mode 100755 index 0000000..6dcd3c5 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/contrib/test.sh @@ -0,0 +1,43 @@ +#!/bin/sh -ex + +FEATURES="simple_http simple_tcp simple_uds" + +cargo --version +rustc --version + +# Some tests require certain toolchain types. +NIGHTLY=false +if cargo --version | grep nightly; then + NIGHTLY=true +fi + +# Defaults / sanity checks +cargo build --all +cargo test --all + +if [ "$DO_FEATURE_MATRIX" = true ]; then + cargo build --no-default-features + cargo test --no-default-features + + # All features + cargo build --no-default-features --features="$FEATURES" + cargo test --no-default-features --features="$FEATURES" + + # Single features + for feature in ${FEATURES} + do + cargo test --no-default-features --features="$feature" + done +fi + +# Build docs if told to, only works with nightly toolchain. +if [ "$DO_DOCS" = true ]; then + if [ "$NIGHTLY" = false ]; then + echo "DO_DOCS requires a nightly toolchain (consider using RUSTUP_TOOLCHAIN)" + exit 1 + fi + + RUSTDOCFLAGS="--cfg docsrs" cargo rustdoc --features="$FEATURES" -- -D rustdoc::broken-intra-doc-links +fi + +exit 0 diff --git a/bdk-wallet/vendor/jsonrpc/githooks/pre-commit b/bdk-wallet/vendor/jsonrpc/githooks/pre-commit new file mode 100755 index 0000000..1e9c693 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/githooks/pre-commit @@ -0,0 +1,54 @@ +#!/bin/sh +# +# Verify what is about to be committed. Called by "git commit" with no +# arguments. The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +git diff-index --check --cached $against || exit 1 + +# Check that code lints cleanly. +cargo clippy --all-features --all-targets -- -D warnings || exit 1 + +# Check that there are no formatting issues. +cargo +nightly fmt -- --check || exit 1 + +exit 0 diff --git a/bdk-wallet/vendor/jsonrpc/rustfmt.toml b/bdk-wallet/vendor/jsonrpc/rustfmt.toml new file mode 100644 index 0000000..6e22ad2 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/rustfmt.toml @@ -0,0 +1,79 @@ +ignore = [] + +hard_tabs = false +tab_spaces = 4 +newline_style = "Auto" +indent_style = "Block" + +max_width = 100 # This is number of characters. +# `use_small_heuristics` is ignored if the granular width config values are explicitly set. +use_small_heuristics = "Max" # "Max" == All granular width settings same as `max_width`. +# # Granular width configuration settings. These are percentages of `max_width`. +# fn_call_width = 60 +# attr_fn_like_width = 70 +# struct_lit_width = 18 +# struct_variant_width = 35 +# array_width = 60 +# chain_width = 60 +# single_line_if_else_max_width = 50 + +wrap_comments = false +format_code_in_doc_comments = false +comment_width = 100 # Default 80 +normalize_comments = false +normalize_doc_attributes = false +format_strings = false +format_macro_matchers = false +format_macro_bodies = true +hex_literal_case = "Preserve" +empty_item_single_line = true +struct_lit_single_line = true +fn_single_line = true # Default false +where_single_line = false +imports_indent = "Block" +imports_layout = "Mixed" +imports_granularity = "Module" # Default "Preserve" +group_imports = "StdExternalCrate" # Default "Preserve" +reorder_imports = true +reorder_modules = true +reorder_impl_items = false +type_punctuation_density = "Wide" +space_before_colon = false +space_after_colon = true +spaces_around_ranges = false +binop_separator = "Front" +remove_nested_parens = true +combine_control_expr = true +overflow_delimited_expr = false +struct_field_align_threshold = 0 +enum_discrim_align_threshold = 0 +match_arm_blocks = false # Default true +match_arm_leading_pipes = "Never" +force_multiline_blocks = false +fn_args_layout = "Tall" +brace_style = "SameLineWhere" +control_brace_style = "AlwaysSameLine" +trailing_semicolon = true +trailing_comma = "Vertical" +match_block_trailing_comma = false +blank_lines_upper_bound = 1 +blank_lines_lower_bound = 0 +edition = "2018" +version = "One" +inline_attribute_width = 0 +format_generated_files = true +merge_derives = true +use_try_shorthand = false +use_field_init_shorthand = false +force_explicit_abi = true +condense_wildcard_suffixes = false +color = "Auto" +required_version = "1.5.1" +unstable_features = false +disable_all_formatting = false +skip_children = false +hide_parse_errors = false +error_on_line_overflow = false +error_on_unformatted = false +emit_mode = "Files" +make_backup = false diff --git a/bdk-wallet/vendor/jsonrpc/src/client.rs b/bdk-wallet/vendor/jsonrpc/src/client.rs new file mode 100644 index 0000000..3b2c350 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/src/client.rs @@ -0,0 +1,182 @@ +// Rust JSON-RPC Library +// Written in 2015 by +// Andrew Poelstra +// +// To the extent possible under law, the author(s) have dedicated all +// copyright and related and neighboring rights to this software to +// the public domain worldwide. This software is distributed without +// any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication +// along with this software. +// If not, see . +// + +//! # Client support +//! +//! Support for connecting to JSONRPC servers over HTTP, sending requests, +//! and parsing responses +//! + +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic; + +use serde; +use serde_json; +use serde_json::value::RawValue; + +use super::{Request, Response}; +use crate::error::Error; +use crate::util::HashableValue; + +/// An interface for a transport over which to use the JSONRPC protocol. +pub trait Transport: Send + Sync + 'static { + /// Send an RPC request over the transport. + fn send_request(&self, _: Request) -> Result; + /// Send a batch of RPC requests over the transport. + fn send_batch(&self, _: &[Request]) -> Result, Error>; + /// Format the target of this transport. + /// I.e. the URL/socket/... + fn fmt_target(&self, f: &mut fmt::Formatter) -> fmt::Result; +} + +/// A JSON-RPC client. +/// +/// Create a new Client using one of the transport-specific constructors: +/// - [Client::simple_http] for the built-in bare-minimum HTTP transport +pub struct Client { + pub(crate) transport: Box, + nonce: atomic::AtomicUsize, +} + +impl Client { + /// Creates a new client with the given transport. + pub fn with_transport(transport: T) -> Client { + Client { + transport: Box::new(transport), + nonce: atomic::AtomicUsize::new(1), + } + } + + /// Builds a request. + /// + /// To construct the arguments, one can use one of the shorthand methods + /// [`crate::arg`] or [`crate::try_arg`]. + pub fn build_request<'a>(&self, method: &'a str, params: &'a [Box]) -> Request<'a> { + let nonce = self.nonce.fetch_add(1, atomic::Ordering::Relaxed); + Request { + method, + params, + id: serde_json::Value::from(nonce), + jsonrpc: Some("2.0"), + } + } + + /// Sends a request to a client + pub fn send_request(&self, request: Request) -> Result { + self.transport.send_request(request) + } + + /// Sends a batch of requests to the client. The return vector holds the response + /// for the request at the corresponding index. If no response was provided, it's [None]. + /// + /// Note that the requests need to have valid IDs, so it is advised to create the requests + /// with [`Client::build_request`]. + pub fn send_batch(&self, requests: &[Request]) -> Result>, Error> { + if requests.is_empty() { + return Err(Error::EmptyBatch); + } + + // If the request body is invalid JSON, the response is a single response object. + // We ignore this case since we are confident we are producing valid JSON. + let responses = self.transport.send_batch(requests)?; + if responses.len() > requests.len() { + return Err(Error::WrongBatchResponseSize); + } + + //TODO(stevenroose) check if the server preserved order to avoid doing the mapping + + // First index responses by ID and catch duplicate IDs. + let mut by_id = HashMap::with_capacity(requests.len()); + for resp in responses.into_iter() { + let id = HashableValue(Cow::Owned(resp.id.clone())); + if let Some(dup) = by_id.insert(id, resp) { + return Err(Error::BatchDuplicateResponseId(dup.id)); + } + } + // Match responses to the requests. + let results = + requests.iter().map(|r| by_id.remove(&HashableValue(Cow::Borrowed(&r.id)))).collect(); + + // Since we're also just producing the first duplicate ID, we can also just produce the + // first incorrect ID in case there are multiple. + if let Some(id) = by_id.keys().next() { + return Err(Error::WrongBatchResponseId((*id.0).clone())); + } + + Ok(results) + } + + /// Make a request and deserialize the response. + /// + /// To construct the arguments, one can use one of the shorthand methods + /// [`crate::arg`] or [`crate::try_arg`]. + pub fn call serde::de::Deserialize<'a>>( + &self, + method: &str, + args: &[Box], + ) -> Result { + let request = self.build_request(method, args); + let id = request.id.clone(); + + let response = self.send_request(request)?; + if response.jsonrpc != None && response.jsonrpc != Some(From::from("2.0")) { + return Err(Error::VersionMismatch); + } + if response.id != id { + return Err(Error::NonceMismatch); + } + + response.result() + } +} + +impl fmt::Debug for crate::Client { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "jsonrpc::Client(")?; + self.transport.fmt_target(f)?; + write!(f, ")") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync; + + struct DummyTransport; + impl Transport for DummyTransport { + fn send_request(&self, _: Request) -> Result { + Err(Error::NonceMismatch) + } + fn send_batch(&self, _: &[Request]) -> Result, Error> { + Ok(vec![]) + } + fn fmt_target(&self, _: &mut fmt::Formatter) -> fmt::Result { + Ok(()) + } + } + + #[test] + fn sanity() { + let client = Client::with_transport(DummyTransport); + assert_eq!(client.nonce.load(sync::atomic::Ordering::Relaxed), 1); + let req1 = client.build_request("test", &[]); + assert_eq!(client.nonce.load(sync::atomic::Ordering::Relaxed), 2); + let req2 = client.build_request("test", &[]); + assert_eq!(client.nonce.load(sync::atomic::Ordering::Relaxed), 3); + assert!(req1.id != req2.id); + } +} diff --git a/bdk-wallet/vendor/jsonrpc/src/error.rs b/bdk-wallet/vendor/jsonrpc/src/error.rs new file mode 100644 index 0000000..d66797a --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/src/error.rs @@ -0,0 +1,256 @@ +// Rust JSON-RPC Library +// Written in 2015 by +// Andrew Poelstra +// +// To the extent possible under law, the author(s) have dedicated all +// copyright and related and neighboring rights to this software to +// the public domain worldwide. This software is distributed without +// any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication +// along with this software. +// If not, see . +// + +//! # Error handling +//! +//! Some useful methods for creating Error objects +//! + +use std::{error, fmt}; + +use serde::{Deserialize, Serialize}; +use serde_json; + +use crate::Response; + +/// A library error +#[derive(Debug)] +pub enum Error { + /// A transport error + Transport(Box), + /// Json error + Json(serde_json::Error), + /// Error response + Rpc(RpcError), + /// Response to a request did not have the expected nonce + NonceMismatch, + /// Response to a request had a jsonrpc field other than "2.0" + VersionMismatch, + /// Batches can't be empty + EmptyBatch, + /// Too many responses returned in batch + WrongBatchResponseSize, + /// Batch response contained a duplicate ID + BatchDuplicateResponseId(serde_json::Value), + /// Batch response contained an ID that didn't correspond to any request ID + WrongBatchResponseId(serde_json::Value), +} + +impl From for Error { + fn from(e: serde_json::Error) -> Error { + Error::Json(e) + } +} + +impl From for Error { + fn from(e: RpcError) -> Error { + Error::Rpc(e) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Error::Transport(ref e) => write!(f, "transport error: {}", e), + Error::Json(ref e) => write!(f, "JSON decode error: {}", e), + Error::Rpc(ref r) => write!(f, "RPC error response: {:?}", r), + Error::BatchDuplicateResponseId(ref v) => { + write!(f, "duplicate RPC batch response ID: {}", v) + } + Error::WrongBatchResponseId(ref v) => write!(f, "wrong RPC batch response ID: {}", v), + Error::NonceMismatch => write!(f, "Nonce of response did not match nonce of request"), + Error::VersionMismatch => write!(f, "`jsonrpc` field set to non-\"2.0\""), + Error::EmptyBatch => write!(f, "batches can't be empty"), + Error::WrongBatchResponseSize => write!(f, "too many responses returned in batch"), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use self::Error::*; + + match *self { + Rpc(_) + | NonceMismatch + | VersionMismatch + | EmptyBatch + | WrongBatchResponseSize + | BatchDuplicateResponseId(_) + | WrongBatchResponseId(_) => None, + Transport(ref e) => Some(&**e), + Json(ref e) => Some(e), + } + } +} + +/// Standard error responses, as described at at +/// +/// +/// # Documentation Copyright +/// Copyright (C) 2007-2010 by the JSON-RPC Working Group +/// +/// This document and translations of it may be used to implement JSON-RPC, it +/// may be copied and furnished to others, and derivative works that comment +/// on or otherwise explain it or assist in its implementation may be prepared, +/// copied, published and distributed, in whole or in part, without restriction +/// of any kind, provided that the above copyright notice and this paragraph +/// are included on all such copies and derivative works. However, this document +/// itself may not be modified in any way. +/// +/// The limited permissions granted above are perpetual and will not be revoked. +/// +/// This document and the information contained herein is provided "AS IS" and +/// ALL WARRANTIES, EXPRESS OR IMPLIED are DISCLAIMED, INCLUDING BUT NOT LIMITED +/// TO ANY WARRANTY THAT THE USE OF THE INFORMATION HEREIN WILL NOT INFRINGE ANY +/// RIGHTS OR ANY IMPLIED WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A +/// PARTICULAR PURPOSE. +/// +#[derive(Debug)] +pub enum StandardError { + /// Invalid JSON was received by the server. + /// An error occurred on the server while parsing the JSON text. + ParseError, + /// The JSON sent is not a valid Request object. + InvalidRequest, + /// The method does not exist / is not available. + MethodNotFound, + /// Invalid method parameter(s). + InvalidParams, + /// Internal JSON-RPC error. + InternalError, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +/// A JSONRPC error object +pub struct RpcError { + /// The integer identifier of the error + pub code: i32, + /// A string describing the error + pub message: String, + /// Additional data specific to the error + pub data: Option>, +} + +/// Create a standard error responses +pub fn standard_error( + code: StandardError, + data: Option>, +) -> RpcError { + match code { + StandardError::ParseError => RpcError { + code: -32700, + message: "Parse error".to_string(), + data, + }, + StandardError::InvalidRequest => RpcError { + code: -32600, + message: "Invalid Request".to_string(), + data, + }, + StandardError::MethodNotFound => RpcError { + code: -32601, + message: "Method not found".to_string(), + data, + }, + StandardError::InvalidParams => RpcError { + code: -32602, + message: "Invalid params".to_string(), + data, + }, + StandardError::InternalError => RpcError { + code: -32603, + message: "Internal error".to_string(), + data, + }, + } +} + +/// Converts a Rust `Result` to a JSONRPC response object +pub fn result_to_response( + result: Result, + id: serde_json::Value, +) -> Response { + match result { + Ok(data) => Response { + result: Some( + serde_json::value::RawValue::from_string(serde_json::to_string(&data).unwrap()) + .unwrap(), + ), + error: None, + id, + jsonrpc: Some(String::from("2.0")), + }, + Err(err) => Response { + result: None, + error: Some(err), + id, + jsonrpc: Some(String::from("2.0")), + }, + } +} + +#[cfg(test)] +mod tests { + use super::StandardError::{ + InternalError, InvalidParams, InvalidRequest, MethodNotFound, ParseError, + }; + use super::{result_to_response, standard_error}; + use serde_json; + + #[test] + fn test_parse_error() { + let resp = result_to_response(Err(standard_error(ParseError, None)), From::from(1)); + assert!(resp.result.is_none()); + assert!(resp.error.is_some()); + assert_eq!(resp.id, serde_json::Value::from(1)); + assert_eq!(resp.error.unwrap().code, -32700); + } + + #[test] + fn test_invalid_request() { + let resp = result_to_response(Err(standard_error(InvalidRequest, None)), From::from(1)); + assert!(resp.result.is_none()); + assert!(resp.error.is_some()); + assert_eq!(resp.id, serde_json::Value::from(1)); + assert_eq!(resp.error.unwrap().code, -32600); + } + + #[test] + fn test_method_not_found() { + let resp = result_to_response(Err(standard_error(MethodNotFound, None)), From::from(1)); + assert!(resp.result.is_none()); + assert!(resp.error.is_some()); + assert_eq!(resp.id, serde_json::Value::from(1)); + assert_eq!(resp.error.unwrap().code, -32601); + } + + #[test] + fn test_invalid_params() { + let resp = result_to_response(Err(standard_error(InvalidParams, None)), From::from("123")); + assert!(resp.result.is_none()); + assert!(resp.error.is_some()); + assert_eq!(resp.id, serde_json::Value::from("123")); + assert_eq!(resp.error.unwrap().code, -32602); + } + + #[test] + fn test_internal_error() { + let resp = result_to_response(Err(standard_error(InternalError, None)), From::from(-1)); + assert!(resp.result.is_none()); + assert!(resp.error.is_some()); + assert_eq!(resp.id, serde_json::Value::from(-1)); + assert_eq!(resp.error.unwrap().code, -32603); + } +} diff --git a/bdk-wallet/vendor/jsonrpc/src/lib.rs b/bdk-wallet/vendor/jsonrpc/src/lib.rs new file mode 100644 index 0000000..4c898c2 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/src/lib.rs @@ -0,0 +1,229 @@ +// Rust JSON-RPC Library +// Written in 2015 by +// Andrew Poelstra +// +// To the extent possible under law, the author(s) have dedicated all +// copyright and related and neighboring rights to this software to +// the public domain worldwide. This software is distributed without +// any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication +// along with this software. +// If not, see . +// + +//! # Rust JSON-RPC Library +//! +//! Rust support for the JSON-RPC 2.0 protocol. +//! + +// Coding conventions +#![deny(non_upper_case_globals)] +#![deny(non_camel_case_types)] +#![deny(non_snake_case)] +#![deny(unused_mut)] +#![warn(missing_docs)] + +use serde::{Deserialize, Serialize}; + +extern crate serde; +pub extern crate serde_json; + +#[cfg(feature = "base64-compat")] +pub extern crate base64; + +pub mod client; +pub mod error; +mod util; + +#[cfg(feature = "simple_http")] +pub mod simple_http; + +#[cfg(feature = "simple_tcp")] +pub mod simple_tcp; + +#[cfg(all(feature = "simple_uds", not(windows)))] +pub mod simple_uds; + +// Re-export error type +pub use crate::client::{Client, Transport}; +pub use crate::error::Error; + +use serde_json::value::RawValue; + +/// Shorthand method to convert an argument into a [Box]. +/// Since serializers rarely fail, it's probably easier to use [arg] instead. +pub fn try_arg(arg: T) -> Result, serde_json::Error> { + RawValue::from_string(serde_json::to_string(&arg)?) +} + +/// Shorthand method to convert an argument into a [Box]. +/// +/// This conversion should not fail, so to avoid returning a [Result], +/// in case of an error, the error is serialized as the return value. +pub fn arg(arg: T) -> Box { + match try_arg(arg) { + Ok(v) => v, + Err(e) => RawValue::from_string(format!("<>", e)) + .unwrap_or_else(|_| { + RawValue::from_string("<>".to_owned()).unwrap() + }), + } +} + +#[derive(Debug, Clone, Serialize)] +/// A JSONRPC request object +pub struct Request<'a> { + /// The name of the RPC call + pub method: &'a str, + /// Parameters to the RPC call + pub params: &'a [Box], + /// Identifier for this Request, which should appear in the response + pub id: serde_json::Value, + /// jsonrpc field, MUST be "2.0" + pub jsonrpc: Option<&'a str>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +/// A JSONRPC response object +pub struct Response { + /// A result if there is one, or null + pub result: Option>, + /// An error if there is one, or null + pub error: Option, + /// Identifier for this Request, which should match that of the request + pub id: serde_json::Value, + /// jsonrpc field, MUST be "2.0" + pub jsonrpc: Option, +} + +impl Response { + /// Extract the result from a response + pub fn result serde::de::Deserialize<'a>>(&self) -> Result { + if let Some(ref e) = self.error { + return Err(Error::Rpc(e.clone())); + } + + if let Some(ref res) = self.result { + serde_json::from_str(res.get()).map_err(Error::Json) + } else { + serde_json::from_value(serde_json::Value::Null).map_err(Error::Json) + } + } + + /// Return the RPC error, if there was one, but do not check the result + pub fn check_error(self) -> Result<(), Error> { + if let Some(e) = self.error { + Err(Error::Rpc(e)) + } else { + Ok(()) + } + } + + /// Returns whether or not the `result` field is empty + pub fn is_none(&self) -> bool { + self.result.is_none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::value::RawValue; + + #[test] + fn response_is_none() { + let joanna = Response { + result: Some(RawValue::from_string(serde_json::to_string(&true).unwrap()).unwrap()), + error: None, + id: From::from(81), + jsonrpc: Some(String::from("2.0")), + }; + + let bill = Response { + result: None, + error: None, + id: From::from(66), + jsonrpc: Some(String::from("2.0")), + }; + + assert!(!joanna.is_none()); + assert!(bill.is_none()); + } + + #[test] + fn response_extract() { + let obj = vec!["Mary", "had", "a", "little", "lamb"]; + let response = Response { + result: Some(RawValue::from_string(serde_json::to_string(&obj).unwrap()).unwrap()), + error: None, + id: serde_json::Value::Null, + jsonrpc: Some(String::from("2.0")), + }; + let recovered1: Vec = response.result().unwrap(); + assert!(response.clone().check_error().is_ok()); + let recovered2: Vec = response.result().unwrap(); + assert_eq!(obj, recovered1); + assert_eq!(obj, recovered2); + } + + #[test] + fn null_result() { + let s = r#"{"result":null,"error":null,"id":"test"}"#; + let response: Response = serde_json::from_str(s).unwrap(); + let recovered1: Result<(), _> = response.result(); + let recovered2: Result<(), _> = response.result(); + assert!(recovered1.is_ok()); + assert!(recovered2.is_ok()); + + let recovered1: Result = response.result(); + let recovered2: Result = response.result(); + assert!(recovered1.is_err()); + assert!(recovered2.is_err()); + } + + #[test] + fn batch_response() { + // from the jsonrpc.org spec example + let s = r#"[ + {"jsonrpc": "2.0", "result": 7, "id": "1"}, + {"jsonrpc": "2.0", "result": 19, "id": "2"}, + {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, + {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "5"}, + {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"} + ]"#; + let batch_response: Vec = serde_json::from_str(s).unwrap(); + assert_eq!(batch_response.len(), 5); + } + + #[test] + fn test_arg() { + macro_rules! test_arg { + ($val:expr, $t:ty) => {{ + let val1: $t = $val; + let arg = super::arg(val1.clone()); + let val2: $t = serde_json::from_str(arg.get()).expect(stringify!($val)); + assert_eq!(val1, val2, "failed test for {}", stringify!($val)); + }}; + } + + test_arg!(true, bool); + test_arg!(42, u8); + test_arg!(42, usize); + test_arg!(42, isize); + test_arg!(vec![42, 35], Vec); + test_arg!(String::from("test"), String); + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + struct Test { + v: String, + } + test_arg!( + Test { + v: String::from("test"), + }, + Test + ); + } +} diff --git a/bdk-wallet/vendor/jsonrpc/src/simple_http.rs b/bdk-wallet/vendor/jsonrpc/src/simple_http.rs new file mode 100644 index 0000000..0662694 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/src/simple_http.rs @@ -0,0 +1,451 @@ +//! This module implements a minimal and non standard conforming HTTP 1.0 +//! round-tripper that works with the bitcoind RPC server. This can be used +//! if minimal dependencies are a goal and synchronous communication is ok. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::{Duration, Instant}; +use std::{error, fmt, io, net, thread}; + +use base64; +use serde; +use serde_json; + +use crate::client::Transport; +use crate::{Request, Response}; + +/// The default TCP port to use for connections. +/// Set to 8332, the default RPC port for bitcoind. +pub const DEFAULT_PORT: u16 = 8332; + +/// Simple HTTP transport that implements the necessary subset of HTTP for +/// running a bitcoind RPC client. +#[derive(Clone, Debug)] +pub struct SimpleHttpTransport { + addr: net::SocketAddr, + path: String, + timeout: Duration, + /// The value of the `Authorization` HTTP header. + basic_auth: Option, +} + +impl Default for SimpleHttpTransport { + fn default() -> Self { + SimpleHttpTransport { + addr: net::SocketAddr::new( + net::IpAddr::V4(net::Ipv4Addr::new(127, 0, 0, 1)), + DEFAULT_PORT, + ), + path: "/".to_owned(), + // PATCHED (mintlayer web-gui): 15s default aborts large `importdescriptors` + // batches (bdk caches 100 scripts per keychain) on low-end hardware. + // See bdk-wallet README. Everything else is stock jsonrpc 0.13.0. + timeout: Duration::from_secs(120), + basic_auth: None, + } + } +} + +impl SimpleHttpTransport { + /// Construct a new `SimpleHttpTransport` with default parameters + pub fn new() -> Self { + SimpleHttpTransport::default() + } + + /// Returns a builder for `SimpleHttpTransport` + pub fn builder() -> Builder { + Builder::new() + } + + fn request(&self, req: impl serde::Serialize) -> Result + where + R: for<'a> serde::de::Deserialize<'a>, + { + // Open connection + let request_deadline = Instant::now() + self.timeout; + let mut sock = TcpStream::connect_timeout(&self.addr, self.timeout)?; + + sock.set_read_timeout(Some(self.timeout))?; + sock.set_write_timeout(Some(self.timeout))?; + + // Serialize the body first so we can set the Content-Length header. + let body = serde_json::to_vec(&req)?; + + // Send HTTP request + sock.write_all(b"POST ")?; + sock.write_all(self.path.as_bytes())?; + sock.write_all(b" HTTP/1.1\r\n")?; + // Write headers + sock.write_all(b"Connection: Close\r\n")?; + sock.write_all(b"Content-Type: application/json\r\n")?; + sock.write_all(b"Content-Length: ")?; + sock.write_all(body.len().to_string().as_bytes())?; + sock.write_all(b"\r\n")?; + if let Some(ref auth) = self.basic_auth { + sock.write_all(b"Authorization: ")?; + sock.write_all(auth.as_ref())?; + sock.write_all(b"\r\n")?; + } + // Write body + sock.write_all(b"\r\n")?; + sock.write_all(&body)?; + sock.flush()?; + + // Receive response + let mut reader = BufReader::new(sock); + + // Parse first HTTP response header line + let http_response = get_line(&mut reader, request_deadline)?; + if http_response.len() < 12 || !http_response.starts_with("HTTP/1.1 ") { + return Err(Error::HttpParseError); + } + let response_code = match http_response[9..12].parse::() { + Ok(n) => n, + Err(_) => return Err(Error::HttpParseError), + }; + + // Skip response header fields + while get_line(&mut reader, request_deadline)? != "\r\n" {} + + if response_code == 401 { + // There is no body in a 401 response, so don't try to read it + return Err(Error::HttpErrorCode(response_code)); + } + + // Even if it's != 200, we parse the response as we may get a JSONRPC error instead + // of the less meaningful HTTP error code. + let resp_body = get_line(&mut reader, request_deadline)?; + match serde_json::from_str(&resp_body) { + Ok(s) => Ok(s), + Err(e) => { + if response_code != 200 { + Err(Error::HttpErrorCode(response_code)) + } else { + // If it was 200 then probably it was legitimately a parse error + Err(e.into()) + } + } + } + } +} + +/// Error that can happen when sending requests +#[derive(Debug)] +pub enum Error { + /// An invalid URL was passed. + InvalidUrl { + /// The URL passed. + url: String, + /// The reason the URL is invalid. + reason: &'static str, + }, + /// An error occurred on the socket layer + SocketError(io::Error), + /// The HTTP header of the response couldn't be parsed + HttpParseError, + /// Unexpected HTTP error code (non-200) + HttpErrorCode(u16), + /// We didn't receive a complete response till the deadline ran out + Timeout, + /// JSON parsing error. + Json(serde_json::Error), +} + +impl Error { + /// Utility method to create [Error::InvalidUrl] variants. + fn url>(url: U, reason: &'static str) -> Error { + Error::InvalidUrl { + url: url.into(), + reason, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + match *self { + Error::InvalidUrl { + ref url, + ref reason, + } => write!(f, "invalid URL '{}': {}", url, reason), + Error::SocketError(ref e) => write!(f, "Couldn't connect to host: {}", e), + Error::HttpParseError => f.write_str("Couldn't parse response header."), + Error::HttpErrorCode(c) => write!(f, "unexpected HTTP code: {}", c), + Error::Timeout => f.write_str("Didn't receive response data in time, timed out."), + Error::Json(ref e) => write!(f, "JSON error: {}", e), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use self::Error::*; + + match *self { + InvalidUrl { + .. + } + | HttpParseError + | HttpErrorCode(_) + | Timeout => None, + SocketError(ref e) => Some(e), + Json(ref e) => Some(e), + } + } +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::SocketError(e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Json(e) + } +} + +impl From for crate::Error { + fn from(e: Error) -> crate::Error { + match e { + Error::Json(e) => crate::Error::Json(e), + e => crate::Error::Transport(Box::new(e)), + } + } +} + +/// Try to read a line from a buffered reader. If no line can be read till the deadline is reached +/// return a timeout error. +fn get_line(reader: &mut R, deadline: Instant) -> Result { + let mut line = String::new(); + while deadline > Instant::now() { + match reader.read_line(&mut line) { + // EOF reached for now, try again later + Ok(0) => thread::sleep(Duration::from_millis(5)), + // received useful data, return it + Ok(_) => return Ok(line), + // io error occurred, abort + Err(e) => return Err(Error::SocketError(e)), + } + } + Err(Error::Timeout) +} + +impl Transport for SimpleHttpTransport { + fn send_request(&self, req: Request) -> Result { + Ok(self.request(req)?) + } + + fn send_batch(&self, reqs: &[Request]) -> Result, crate::Error> { + Ok(self.request(reqs)?) + } + + fn fmt_target(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "http://{}:{}{}", self.addr.ip(), self.addr.port(), self.path) + } +} + +/// Builder for simple bitcoind `SimpleHttpTransport`s +#[derive(Clone, Debug)] +pub struct Builder { + tp: SimpleHttpTransport, +} + +impl Builder { + /// Construct new `Builder` with default configuration + pub fn new() -> Builder { + Builder { + tp: SimpleHttpTransport::new(), + } + } + + /// Sets the timeout after which requests will abort if they aren't finished + pub fn timeout(mut self, timeout: Duration) -> Self { + self.tp.timeout = timeout; + self + } + + /// Set the URL of the server to the transport. + pub fn url(mut self, url: &str) -> Result { + // Do some very basic manual URL parsing because the uri/url crates + // all have unicode-normalization as a dependency and that's broken. + + // The fallback port in case no port was provided. + // This changes when the http or https scheme was provided. + let mut fallback_port = DEFAULT_PORT; + + // We need to get the hostname and the port. + // (1) Split scheme + let after_scheme = { + let mut split = url.splitn(2, "://"); + let s = split.next().unwrap(); + match split.next() { + None => s, // no scheme present + Some(after) => { + // Check if the scheme is http or https. + if s == "http" { + fallback_port = 80; + } else if s == "https" { + fallback_port = 443; + } else { + return Err(Error::url(url, "scheme schould be http or https")); + } + after + } + } + }; + // (2) split off path + let (before_path, path) = { + if let Some(slash) = after_scheme.find('/') { + (&after_scheme[0..slash], &after_scheme[slash..]) + } else { + (after_scheme, "/") + } + }; + // (3) split off auth part + let after_auth = { + let mut split = before_path.splitn(2, '@'); + let s = split.next().unwrap(); + split.next().unwrap_or(s) + }; + + // (4) Parse into socket address. + // At this point we either have or : + // `std::net::ToSocketAddrs` requires `&str` to have : format. + let mut addr = match after_auth.to_socket_addrs() { + Ok(addr) => addr, + Err(_) => { + // Invalid socket address. Try to add port. + format!("{}:{}", after_auth, fallback_port).to_socket_addrs()? + } + }; + + self.tp.addr = match addr.next() { + Some(a) => a, + None => { + return Err(Error::url(url, "invalid hostname: error extracting socket address")) + } + }; + self.tp.path = path.to_owned(); + Ok(self) + } + + /// Add authentication information to the transport. + pub fn auth>(mut self, user: S, pass: Option) -> Self { + let mut auth = user.as_ref().to_owned(); + auth.push(':'); + if let Some(ref pass) = pass { + auth.push_str(pass.as_ref()); + } + self.tp.basic_auth = Some(format!("Basic {}", &base64::encode(auth.as_bytes()))); + self + } + + /// Add authentication information to the transport using a cookie string ('user:pass') + pub fn cookie_auth>(mut self, cookie: S) -> Self { + self.tp.basic_auth = Some(format!("Basic {}", &base64::encode(cookie.as_ref().as_bytes()))); + self + } + + /// Builds the final `SimpleHttpTransport` + pub fn build(self) -> SimpleHttpTransport { + self.tp + } +} + +impl Default for Builder { + fn default() -> Self { + Builder::new() + } +} + +impl crate::Client { + /// Create a new JSON-RPC client using a bare-minimum HTTP transport. + pub fn simple_http( + url: &str, + user: Option, + pass: Option, + ) -> Result { + let mut builder = Builder::new().url(url)?; + if let Some(user) = user { + builder = builder.auth(user, pass); + } + Ok(crate::Client::with_transport(builder.build())) + } +} + +#[cfg(test)] +mod tests { + use std::net; + + use super::*; + use crate::Client; + + #[test] + fn test_urls() { + let addr: net::SocketAddr = ("localhost", 22).to_socket_addrs().unwrap().next().unwrap(); + let urls = [ + "localhost:22", + "http://localhost:22/", + "https://localhost:22/walletname/stuff?it=working", + "http://me:weak@localhost:22/wallet", + ]; + for u in &urls { + let tp = Builder::new().url(*u).unwrap().build(); + assert_eq!(tp.addr, addr); + } + + // Default port and 80 and 443 fill-in. + let addr: net::SocketAddr = ("localhost", 80).to_socket_addrs().unwrap().next().unwrap(); + let tp = Builder::new().url("http://localhost/").unwrap().build(); + assert_eq!(tp.addr, addr); + let addr: net::SocketAddr = ("localhost", 443).to_socket_addrs().unwrap().next().unwrap(); + let tp = Builder::new().url("https://localhost/").unwrap().build(); + assert_eq!(tp.addr, addr); + let addr: net::SocketAddr = + ("localhost", super::DEFAULT_PORT).to_socket_addrs().unwrap().next().unwrap(); + let tp = Builder::new().url("localhost").unwrap().build(); + assert_eq!(tp.addr, addr); + + let valid_urls = [ + "localhost", + "127.0.0.1:8080", + "http://127.0.0.1:8080/", + "http://127.0.0.1:8080/rpc/test", + "https://127.0.0.1/rpc/test", + "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8300", + "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", + ]; + for u in &valid_urls { + Builder::new().url(*u).unwrap_or_else(|_| panic!("error for: {}", u)); + } + + let invalid_urls = [ + "127.0.0.1.0:8080", + "httpx://127.0.0.1:8080/", + "ftp://127.0.0.1:8080/rpc/test", + "http://127.0.0./rpc/test", + // NB somehow, Rust's IpAddr accepts "127.0.0" and adds the extra 0.. + ]; + for u in &invalid_urls { + if let Ok(b) = Builder::new().url(*u) { + let tp = b.build(); + panic!("expected error for url {}, got {:?}", u, tp); + } + } + } + + #[test] + fn construct() { + let tp = Builder::new() + .timeout(Duration::from_millis(100)) + .url("localhost:22") + .unwrap() + .auth("user", None) + .build(); + let _ = Client::with_transport(tp); + + let _ = Client::simple_http("localhost:22", None, None).unwrap(); + } +} diff --git a/bdk-wallet/vendor/jsonrpc/src/simple_tcp.rs b/bdk-wallet/vendor/jsonrpc/src/simple_tcp.rs new file mode 100644 index 0000000..accc27c --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/src/simple_tcp.rs @@ -0,0 +1,173 @@ +//! This module implements a synchronous transport over a raw TcpListener. Note that +//! it does not handle TCP over Unix Domain Sockets, see `simple_uds` for this. + +use std::{error, fmt, io, net, time}; + +use serde; +use serde_json; + +use crate::client::Transport; +use crate::{Request, Response}; + +/// Error that can occur while using the TCP transport. +#[derive(Debug)] +pub enum Error { + /// An error occurred on the socket layer + SocketError(io::Error), + /// We didn't receive a complete response till the deadline ran out + Timeout, + /// JSON parsing error. + Json(serde_json::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + match *self { + Error::SocketError(ref e) => write!(f, "Couldn't connect to host: {}", e), + Error::Timeout => f.write_str("Didn't receive response data in time, timed out."), + Error::Json(ref e) => write!(f, "JSON error: {}", e), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use self::Error::*; + + match *self { + SocketError(ref e) => Some(e), + Timeout => None, + Json(ref e) => Some(e), + } + } +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::SocketError(e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Json(e) + } +} + +impl From for crate::Error { + fn from(e: Error) -> crate::Error { + match e { + Error::Json(e) => crate::Error::Json(e), + e => crate::Error::Transport(Box::new(e)), + } + } +} + +/// Simple synchronous TCP transport. +#[derive(Debug, Clone)] +pub struct TcpTransport { + /// The internet socket address to connect to + pub addr: net::SocketAddr, + /// The read and write timeout to use for this connection + pub timeout: Option, +} + +impl TcpTransport { + /// Create a new TcpTransport without timeouts + pub fn new(addr: net::SocketAddr) -> TcpTransport { + TcpTransport { + addr, + timeout: None, + } + } + + fn request(&self, req: impl serde::Serialize) -> Result + where + R: for<'a> serde::de::Deserialize<'a>, + { + let mut sock = net::TcpStream::connect(&self.addr)?; + sock.set_read_timeout(self.timeout)?; + sock.set_write_timeout(self.timeout)?; + + serde_json::to_writer(&mut sock, &req)?; + + // NOTE: we don't check the id there, so it *must* be synchronous + let resp: R = serde_json::Deserializer::from_reader(&mut sock) + .into_iter() + .next() + .ok_or(Error::Timeout)??; + Ok(resp) + } +} + +impl Transport for TcpTransport { + fn send_request(&self, req: Request) -> Result { + Ok(self.request(req)?) + } + + fn send_batch(&self, reqs: &[Request]) -> Result, crate::Error> { + Ok(self.request(reqs)?) + } + + fn fmt_target(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.addr) + } +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + thread, + }; + + use super::*; + use crate::Client; + + // Test a dummy request / response over a raw TCP transport + #[test] + fn sanity_check_tcp_transport() { + let addr: net::SocketAddr = + net::SocketAddrV4::new(net::Ipv4Addr::new(127, 0, 0, 1), 0).into(); + let server = net::TcpListener::bind(&addr).unwrap(); + let addr = server.local_addr().unwrap(); + let dummy_req = Request { + method: "arandommethod", + params: &[], + id: serde_json::Value::Number(4242242.into()), + jsonrpc: Some("2.0"), + }; + let dummy_req_ser = serde_json::to_vec(&dummy_req).unwrap(); + let dummy_resp = Response { + result: None, + error: None, + id: serde_json::Value::Number(4242242.into()), + jsonrpc: Some("2.0".into()), + }; + let dummy_resp_ser = serde_json::to_vec(&dummy_resp).unwrap(); + + let client_thread = thread::spawn(move || { + let transport = TcpTransport { + addr, + timeout: Some(time::Duration::from_secs(5)), + }; + let client = Client::with_transport(transport); + + client.send_request(dummy_req.clone()).unwrap() + }); + + let (mut stream, _) = server.accept().unwrap(); + stream.set_read_timeout(Some(time::Duration::from_secs(5))).unwrap(); + let mut recv_req = vec![0; dummy_req_ser.len()]; + let mut read = 0; + while read < dummy_req_ser.len() { + read += stream.read(&mut recv_req[read..]).unwrap(); + } + assert_eq!(recv_req, dummy_req_ser); + + stream.write_all(&dummy_resp_ser).unwrap(); + stream.flush().unwrap(); + let recv_resp = client_thread.join().unwrap(); + assert_eq!(serde_json::to_vec(&recv_resp).unwrap(), dummy_resp_ser); + } +} diff --git a/bdk-wallet/vendor/jsonrpc/src/simple_uds.rs b/bdk-wallet/vendor/jsonrpc/src/simple_uds.rs new file mode 100644 index 0000000..e958ab1 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/src/simple_uds.rs @@ -0,0 +1,181 @@ +//! This module implements a synchronous transport over a raw TcpListener. + +use std::os::unix::net::UnixStream; +use std::{error, fmt, io, path, time}; + +use serde; +use serde_json; + +use crate::client::Transport; +use crate::{Request, Response}; + +/// Error that can occur while using the UDS transport. +#[derive(Debug)] +pub enum Error { + /// An error occurred on the socket layer + SocketError(io::Error), + /// We didn't receive a complete response till the deadline ran out + Timeout, + /// JSON parsing error. + Json(serde_json::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + match *self { + Error::SocketError(ref e) => write!(f, "Couldn't connect to host: {}", e), + Error::Timeout => f.write_str("Didn't receive response data in time, timed out."), + Error::Json(ref e) => write!(f, "JSON error: {}", e), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use self::Error::*; + + match *self { + SocketError(ref e) => Some(e), + Timeout => None, + Json(ref e) => Some(e), + } + } +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::SocketError(e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Json(e) + } +} + +impl From for crate::error::Error { + fn from(e: Error) -> crate::error::Error { + match e { + Error::Json(e) => crate::error::Error::Json(e), + e => crate::error::Error::Transport(Box::new(e)), + } + } +} + +/// Simple synchronous UDS transport. +#[derive(Debug, Clone)] +pub struct UdsTransport { + /// The path to the Unix Domain Socket + pub sockpath: path::PathBuf, + /// The read and write timeout to use + pub timeout: Option, +} + +impl UdsTransport { + /// Create a new UdsTransport without timeouts to use + pub fn new>(sockpath: P) -> UdsTransport { + UdsTransport { + sockpath: sockpath.as_ref().to_path_buf(), + timeout: None, + } + } + + fn request(&self, req: impl serde::Serialize) -> Result + where + R: for<'a> serde::de::Deserialize<'a>, + { + let mut sock = UnixStream::connect(&self.sockpath)?; + sock.set_read_timeout(self.timeout)?; + sock.set_write_timeout(self.timeout)?; + + serde_json::to_writer(&mut sock, &req)?; + + // NOTE: we don't check the id there, so it *must* be synchronous + let resp: R = serde_json::Deserializer::from_reader(&mut sock) + .into_iter() + .next() + .ok_or(Error::Timeout)??; + Ok(resp) + } +} + +impl Transport for UdsTransport { + fn send_request(&self, req: Request) -> Result { + Ok(self.request(req)?) + } + + fn send_batch(&self, reqs: &[Request]) -> Result, crate::error::Error> { + Ok(self.request(reqs)?) + } + + fn fmt_target(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.sockpath.to_string_lossy()) + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + io::{Read, Write}, + os::unix::net::UnixListener, + process, thread, + }; + + use super::*; + use crate::Client; + + // Test a dummy request / response over an UDS + #[test] + fn sanity_check_uds_transport() { + let socket_path: path::PathBuf = format!("uds_scratch_{}.socket", process::id()).into(); + // Any leftover? + fs::remove_file(&socket_path).unwrap_or(()); + + let server = UnixListener::bind(&socket_path).unwrap(); + let dummy_req = Request { + method: "getinfo", + params: &[], + id: serde_json::Value::Number(111.into()), + jsonrpc: Some("2.0"), + }; + let dummy_req_ser = serde_json::to_vec(&dummy_req).unwrap(); + let dummy_resp = Response { + result: None, + error: None, + id: serde_json::Value::Number(111.into()), + jsonrpc: Some("2.0".into()), + }; + let dummy_resp_ser = serde_json::to_vec(&dummy_resp).unwrap(); + + let cli_socket_path = socket_path.clone(); + let client_thread = thread::spawn(move || { + let transport = UdsTransport { + sockpath: cli_socket_path, + timeout: Some(time::Duration::from_secs(5)), + }; + let client = Client::with_transport(transport); + + client.send_request(dummy_req.clone()).unwrap() + }); + + let (mut stream, _) = server.accept().unwrap(); + stream.set_read_timeout(Some(time::Duration::from_secs(5))).unwrap(); + let mut recv_req = vec![0; dummy_req_ser.len()]; + let mut read = 0; + while read < dummy_req_ser.len() { + read += stream.read(&mut recv_req[read..]).unwrap(); + } + assert_eq!(recv_req, dummy_req_ser); + + stream.write_all(&dummy_resp_ser).unwrap(); + stream.flush().unwrap(); + let recv_resp = client_thread.join().unwrap(); + assert_eq!(serde_json::to_vec(&recv_resp).unwrap(), dummy_resp_ser); + + // Clean up + drop(server); + fs::remove_file(&socket_path).unwrap(); + } +} diff --git a/bdk-wallet/vendor/jsonrpc/src/util.rs b/bdk-wallet/vendor/jsonrpc/src/util.rs new file mode 100644 index 0000000..3886f48 --- /dev/null +++ b/bdk-wallet/vendor/jsonrpc/src/util.rs @@ -0,0 +1,113 @@ +// Rust JSON-RPC Library +// Written in 2019 by +// Andrew Poelstra +// +// To the extent possible under law, the author(s) have dedicated all +// copyright and related and neighboring rights to this software to +// the public domain worldwide. This software is distributed without +// any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication +// along with this software. +// If not, see . +// + +use std::borrow::Cow; +use std::hash::{Hash, Hasher}; + +use serde_json::Value; + +/// Newtype around `Value` which allows hashing for use as hashmap keys +/// This is needed for batch requests. +/// +/// The reason `Value` does not support `Hash` or `Eq` by itself +/// is that it supports `f64` values; but for batch requests we +/// will only be hashing the "id" field of the request/response +/// pair, which should never need decimal precision and therefore +/// never use `f64`. +#[derive(Clone, PartialEq, Debug)] +pub struct HashableValue<'a>(pub Cow<'a, Value>); + +impl<'a> Eq for HashableValue<'a> {} + +impl<'a> Hash for HashableValue<'a> { + fn hash(&self, state: &mut H) { + match *self.0.as_ref() { + Value::Null => "null".hash(state), + Value::Bool(false) => "false".hash(state), + Value::Bool(true) => "true".hash(state), + Value::Number(ref n) => { + "number".hash(state); + if let Some(n) = n.as_i64() { + n.hash(state); + } else if let Some(n) = n.as_u64() { + n.hash(state); + } else { + n.to_string().hash(state); + } + } + Value::String(ref s) => { + "string".hash(state); + s.hash(state); + } + Value::Array(ref v) => { + "array".hash(state); + v.len().hash(state); + for obj in v { + HashableValue(Cow::Borrowed(obj)).hash(state); + } + } + Value::Object(ref m) => { + "object".hash(state); + m.len().hash(state); + for (key, val) in m { + key.hash(state); + HashableValue(Cow::Borrowed(val)).hash(state); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + use std::collections::HashSet; + use std::str::FromStr; + + use super::*; + + #[test] + fn hash_value() { + let val = HashableValue(Cow::Owned(Value::from_str("null").unwrap())); + let t = HashableValue(Cow::Owned(Value::from_str("true").unwrap())); + let f = HashableValue(Cow::Owned(Value::from_str("false").unwrap())); + let ns = + HashableValue(Cow::Owned(Value::from_str("[0, -0, 123.4567, -100000000]").unwrap())); + let m = + HashableValue(Cow::Owned(Value::from_str("{ \"field\": 0, \"field\": -0 }").unwrap())); + + let mut coll = HashSet::new(); + + assert!(!coll.contains(&val)); + coll.insert(val.clone()); + assert!(coll.contains(&val)); + + assert!(!coll.contains(&t)); + assert!(!coll.contains(&f)); + coll.insert(t.clone()); + assert!(coll.contains(&t)); + assert!(!coll.contains(&f)); + coll.insert(f.clone()); + assert!(coll.contains(&t)); + assert!(coll.contains(&f)); + + assert!(!coll.contains(&ns)); + coll.insert(ns.clone()); + assert!(coll.contains(&ns)); + + assert!(!coll.contains(&m)); + coll.insert(m.clone()); + assert!(coll.contains(&m)); + } +} diff --git a/btc-explorer/Dockerfile b/btc-explorer/Dockerfile new file mode 100644 index 0000000..6c344df --- /dev/null +++ b/btc-explorer/Dockerfile @@ -0,0 +1,24 @@ +# btc-rpc-explorer — self-hosted Bitcoin block explorer. +# +# Built from the upstream source (janoside/btc-rpc-explorer) because no +# official Docker image is published. BTC_RPC_EXPLORER_REF pins the version; +# bump deliberately. +# +# syntax=docker/dockerfile:1 +FROM node:22-alpine + +ARG BTC_RPC_EXPLORER_REF=v3.5.1 + +RUN apk add --no-cache git + +WORKDIR /app +RUN git clone --depth 1 --branch ${BTC_RPC_EXPLORER_REF} \ + https://github.com/janoside/btc-rpc-explorer.git . \ + && npm ci --omit=dev && npm cache clean --force + +ENV BTCEXP_HOST=0.0.0.0 \ + BTCEXP_PORT=3002 \ + NODE_ENV=production + +EXPOSE 3002 +CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml index 6d48cee..45205a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,10 +111,102 @@ services: SESSION_SECRET: "${SESSION_SECRET}" WALLET_RPC_CMD: "${WALLET_RPC_CMD:-}" INDEXER_ENABLED: "${INDEXER_ENABLED:-false}" + BITCOIN_ENABLED: "${BITCOIN_ENABLED:-false}" + BITCOIN_NETWORK: "${BITCOIN_NETWORK:-}" + BITCOIN_WALLET_URL: "http://bdk-wallet:8080" + BITCOIN_WALLET_USERNAME: "${BITCOIN_WALLET_HTTP_USERNAME:-}" + BITCOIN_WALLET_PASSWORD: "${BITCOIN_WALLET_HTTP_PASSWORD:-}" + BITCOIN_EXPLORER_URL: "${BITCOIN_EXPLORER_URL:-}" HOST: "0.0.0.0" PORT: "4321" restart: unless-stopped + # ───────────────────────────────────────── + # Optional: Bitcoin Core node (profile: bitcoin) + # + # Chain data + tx broadcast for the BTC wallet. No host ports by default. + # Pinned to Core 25: bdk's rpc backend (core-rpc 0.17) cannot parse the + # `warnings` array format introduced in Core 26. + # Start with: docker compose --profile bitcoin up -d + # ───────────────────────────────────────── + bitcoind: + image: "${BITCOIND_IMAGE:-bitcoin/bitcoin:25}" + volumes: + - "./bitcoin-data:/home/bitcoin/.bitcoin" + command: + - "-printtoconsole=1" + - "-chain=${BITCOIN_NETWORK:-mainnet}" + - "-rpcbind=0.0.0.0:8332" + - "-rpcallowip=0.0.0.0/0" + - "-rpcuser=${BITCOIN_RPC_USERNAME}" + - "-rpcpassword=${BITCOIN_RPC_PASSWORD}" + # txindex is required by the BDK wallet to resolve historical + # transactions; pruning is incompatible with the wallet history sync. + - "-txindex=${BITCOIN_TXINDEX:-1}" + - "-prune=${BITCOIN_PRUNE:-0}" + - "-fallbackfee=0.0002" + # Uncomment to expose the Bitcoin RPC to the host + # ports: + # - "8332:8332" + profiles: + - bitcoin + restart: unless-stopped + + # ───────────────────────────────────────── + # Optional: BTC block explorer (profile: bitcoin) + # + # btc-rpc-explorer, wired to the local bitcoind. Gives the web-gui + # something to link txs/addresses to on regtest, where no public + # explorer exists. Published to loopback only; public networks default + # to mempool.space links instead (override with BITCOIN_EXPLORER_URL). + # ───────────────────────────────────────── + btc-explorer: + build: + context: ./btc-explorer + args: + BTC_RPC_EXPLORER_REF: "${BTC_RPC_EXPLORER_REF:-v3.5.1}" + depends_on: + - bitcoind + environment: + BTCEXP_BITCOIND_URI: "bitcoin://${BITCOIN_RPC_USERNAME}:${BITCOIN_RPC_PASSWORD}@bitcoind:8332?timeout=15000" + BTCEXP_HOST: "0.0.0.0" + BTCEXP_PORT: "3002" + BTCEXP_ADDRESS_API: "none" + ports: + - "127.0.0.1:${BITCOIN_EXPLORER_PORT:-3002}:3002" + profiles: + - bitcoin + restart: unless-stopped + # ───────────────────────────────────────── + # Optional: BDK Bitcoin wallet sidecar (profile: bitcoin) + # + # Light wallet (BIP84) holding the BTC keys; talks to bitcoind for chain + # data and broadcasting. The web-gui proxies all requests to its HTTP API. + # Secrets never leave this container; only the compose network can reach it. + # ───────────────────────────────────────── + bdk-wallet: + build: ./bdk-wallet + image: web-gui-bdk-wallet:latest + # Match the bind-mount owner (init.sh pre-creates ./bitcoin-wallet-data + # as the host user; ML_USER_ID/GROUP_ID follow the host user like web-gui). + user: "${ML_USER_ID:-1000}:${ML_GROUP_ID:-1000}" + depends_on: + - bitcoind + volumes: + - "./bitcoin-wallet-data:/data" + environment: + BITCOIN_NETWORK: "${BITCOIN_NETWORK:-mainnet}" + BITCOIN_RPC_URL: "http://bitcoind:8332" + BITCOIN_RPC_USERNAME: "${BITCOIN_RPC_USERNAME}" + BITCOIN_RPC_PASSWORD: "${BITCOIN_RPC_PASSWORD}" + WALLET_HTTP_USERNAME: "${BITCOIN_WALLET_HTTP_USERNAME}" + WALLET_HTTP_PASSWORD: "${BITCOIN_WALLET_HTTP_PASSWORD}" + DATA_DIR: "/data" + RUST_LOG: "${RUST_LOG:-info}" + profiles: + - bitcoin + restart: unless-stopped + # ───────────────────────────────────────── # Optional: Watchtower — auto-update Mintlayer images (profile: watchtower) # Checks Docker Hub daily at 04:00 and restarts any container whose image diff --git a/init.sh b/init.sh index da44bed..b2b983d 100755 --- a/init.sh +++ b/init.sh @@ -466,6 +466,58 @@ fi divider +# ───────────────────────────────────────────────────────────────────────────── +# Step — Bitcoin node + BTC wallet (optional) +# ───────────────────────────────────────────────────────────────────────────── +step "Bitcoin node + BTC wallet (optional)" +hint "Adds a Bitcoin Core node and a built-in BTC wallet to the web UI" +hint "(balance, receive, send). The wallet keys are held by a light-wallet" +hint "sidecar; the node only provides chain data and broadcasts transactions." +hint "" +hint "Requirements: extra disk space (mainnet ~700 GB) and a full initial" +hint "sync that can take days. The BTC wallet is a HOT wallet - keep only" +hint "spending amounts there." +hint "" + +ENABLE_BITCOIN="no" +confirm ENABLE_BITCOIN "Enable the Bitcoin node + BTC wallet?" "N" + +BITCOIN_RPC_USERNAME="bitcoin_user" +BITCOIN_RPC_PASSWORD="" +BITCOIN_WALLET_HTTP_USERNAME="btcwallet_user" +BITCOIN_WALLET_HTTP_PASSWORD="" +BITCOIN_NETWORK="" +if [[ "$ENABLE_BITCOIN" == "yes" ]]; then + if [[ "$USE_RANDOM_PASSWORDS" == "yes" ]]; then + BITCOIN_RPC_PASSWORD=$(rand_pass) + BITCOIN_WALLET_HTTP_PASSWORD=$(rand_pass) + ok "Generated random Bitcoin RPC passwords (saved to .env)" + else + ask "Bitcoin node RPC password" + prompt_secret BITCOIN_RPC_PASSWORD "Password:" + while [[ ${#BITCOIN_RPC_PASSWORD} -lt 8 ]]; do + printf "${CYAN}│${RESET} ${RED}Password must be at least 8 characters${RESET}\n" + prompt_secret BITCOIN_RPC_PASSWORD "Password:" + done + ask "BTC wallet API password" + prompt_secret BITCOIN_WALLET_HTTP_PASSWORD "Password:" + while [[ ${#BITCOIN_WALLET_HTTP_PASSWORD} -lt 8 ]]; do + printf "${CYAN}│${RESET} ${RED}Password must be at least 8 characters${RESET}\n" + prompt_secret BITCOIN_WALLET_HTTP_PASSWORD "Password:" + done + fi + + ask "Bitcoin network" + hint "Leave default to follow the Mintlayer network (${NETWORK})." + prompt BITCOIN_NETWORK "Network (mainnet/testnet/regtest/signet):" "" + case "$BITCOIN_NETWORK" in + ""|"mainnet"|"testnet"|"regtest"|"signet") ;; + *) hint "Unknown network '${BITCOIN_NETWORK}' - following Mintlayer network instead."; BITCOIN_NETWORK="" ;; + esac +fi + +divider + # ───────────────────────────────────────────────────────────────────────────── # Step — Auto-update (Watchtower) # ───────────────────────────────────────────────────────────────────────────── @@ -592,6 +644,7 @@ printf "${CYAN}│${RESET} %-22s %s\n" "Web UI auth:" "${BOLD}password + TOTP printf "${CYAN}│${RESET} %-22s %s\n" "Web GUI:" "${BOLD}${PASSKEY_ORIGIN}${RESET}" printf "${CYAN}│${RESET} %-22s %s\n" "Passkeys:" "${BOLD}$([ "$WEB_GUI_HOST" != "localhost" ] && echo "enabled (${WEB_GUI_HOST})" || echo "localhost only")${RESET}" printf "${CYAN}│${RESET} %-22s %s\n" "Indexer:" "${BOLD}$([ "$ENABLE_INDEXER" == "yes" ] && echo "enabled (port ${API_WEB_SERVER_PORT}) — Token Management + Trading active" || echo "disabled — Token Management + Trading hidden")${RESET}" +printf "${CYAN}│${RESET} %-22s %s\n" "Bitcoin:" "${BOLD}$([ "$ENABLE_BITCOIN" == "yes" ] && echo "enabled (node + BTC wallet)" || echo "disabled")${RESET}" printf "${CYAN}│${RESET} %-22s %s\n" "IPFS storage:" "${BOLD}$([ -n "$IPFS_PROVIDER" ] && echo "$IPFS_PROVIDER" || echo "disabled — configure later in Settings")${RESET}" printf "${CYAN}│${RESET} %-22s %s\n" "Telegram:" "${BOLD}$([ -n "$TELEGRAM_BOT_TOKEN" ] && echo "configured" || echo "disabled — configure later in Settings")${RESET}" printf "${CYAN}│${RESET} %-22s %s\n" "Auto-update:" "${BOLD}$([ "$ENABLE_WATCHTOWER" == "yes" ] && echo "enabled (daily at 04:00)" || echo "disabled")${RESET}" @@ -628,6 +681,7 @@ fi # Derive boolean flags INDEXER_ENABLED=$([ "$ENABLE_INDEXER" == "yes" ] && echo "true" || echo "false") +BITCOIN_ENABLED=$([ "$ENABLE_BITCOIN" == "yes" ] && echo "true" || echo "false") # Build the full wallet-rpc-daemon command (avoids shell expansion tricks in docker-compose) WALLET_RPC_CMD="wallet-rpc-daemon ${NETWORK}" @@ -683,6 +737,16 @@ POSTGRES_USER=mintlayer POSTGRES_PASSWORD=${POSTGRES_PASSWORD} POSTGRES_DB=mintlayer API_WEB_SERVER_PORT=${API_WEB_SERVER_PORT} + +# Bitcoin node + BTC wallet (only used with --profile bitcoin) +BITCOIN_ENABLED=${BITCOIN_ENABLED} +BITCOIN_NETWORK=${BITCOIN_NETWORK} +BITCOIN_RPC_USERNAME=${BITCOIN_RPC_USERNAME} +BITCOIN_RPC_PASSWORD=${BITCOIN_RPC_PASSWORD} +BITCOIN_WALLET_HTTP_USERNAME=${BITCOIN_WALLET_HTTP_USERNAME} +BITCOIN_WALLET_HTTP_PASSWORD=${BITCOIN_WALLET_HTTP_PASSWORD} +BITCOIN_TXINDEX=1 +BITCOIN_PRUNE=0 EOF ok ".env written" @@ -704,10 +768,17 @@ mkdir -p mintlayer-data/prefs mintlayer-data/plugins sh -c 'apk add -q --no-progress sqlite >/dev/null 2>&1 && sqlite3 /prefs/mintlayer_prefs.sqlite' ok "Credentials written to mintlayer-data/prefs/mintlayer_prefs.sqlite" -# ── Create data directory ───────────────────────────────────────────────────── +# ── Create data directories ────────────────────────────────────────────────── +# Pre-created by the host user so bind mounts are not root-owned when the +# containers mount them (Docker creates missing dirs as root). mkdir -p mintlayer-data ok "mintlayer-data/ directory ready" +if [[ "$ENABLE_BITCOIN" == "yes" ]]; then + mkdir -p bitcoin-data bitcoin-wallet-data + ok "bitcoin-data/ and bitcoin-wallet-data/ ready" +fi + # ───────────────────────────────────────────────────────────────────────────── # Start services? # ───────────────────────────────────────────────────────────────────────────── @@ -726,6 +797,9 @@ if [[ "$START" == "yes" ]]; then if [[ "$ENABLE_INDEXER" == "yes" ]]; then PROFILES="$PROFILES --profile indexer" fi + if [[ "$ENABLE_BITCOIN" == "yes" ]]; then + PROFILES="$PROFILES --profile bitcoin" + fi if [[ "$ENABLE_WATCHTOWER" == "yes" ]]; then PROFILES="$PROFILES --profile watchtower" fi @@ -757,6 +831,12 @@ printf " ${GRAY}${COMPOSE} down # stop everything${RESET printf "\n" printf " ${DIM}Note: mainnet sync takes hours on first run.${RESET}\n" printf " ${DIM}Balance and history appear once the node is fully synced.${RESET}\n" +if [[ "$ENABLE_BITCOIN" == "yes" ]]; then + printf "\n" + printf " ${DIM}Bitcoin: the BTC node syncs independently (days on mainnet).${RESET}\n" + printf " ${DIM}Open the Bitcoin page in the web UI to create your BTC wallet${RESET}\n" + printf " ${DIM}and back up its seed phrase when prompted.${RESET}\n" +fi printf "\n" # ── Open browser ──────────────────────────────────────────────────────────────