diff --git a/.agents/swap.md b/.agents/swap.md index b18c2d6d..d741e6ab 100644 --- a/.agents/swap.md +++ b/.agents/swap.md @@ -5,6 +5,7 @@ Cross-chain transfer system enabling asset swaps between different networks via ## Architecture ### TransferServiceManager (`shared/services/transfer-service-manager.ts`) + Wraps N `ITransferService` implementations behind a single `ITransferService` interface. Zero UI changes needed when adding/removing providers. - `getAvailableAssets()` — union of all services' assets, deduplicated @@ -16,6 +17,7 @@ Wraps N `ITransferService` implementations behind a single `ITransferService` in Singleton via `useTransferService(storage)` hook (`shared/hooks/useTransferService.ts`). ### ITransferService Interface (`shared/types/transfer.ts`) + ``` readonly name: string getSupportedPairs(): TransferPair[] @@ -31,17 +33,20 @@ getTrackingUrl?(execution): string | undefined ``` ### Key Types (`shared/types/transfer.ts`, `shared/types/asset.ts`) + - **AssetId** — strict union: `native:bitcoin`, `token:spark:usdb`, etc. - **AssetInfo** — resolved metadata: network, ticker, decimals, tokenId - **TransferQuote** — quote with `serviceName`, `serviceErrors?` - **TransferExecution** — persisted transfer state with `depositAddress`, `depositTxid`, `confirmations`, `claimSwapJson`, `providerId`. Three variants: `DepositAddressExecution`, `NativeClaimExecution`, `InstantSwapExecution` - **NativeClaimExecution** — extends base with `claimTxid`, `receiveTransferId`, `autoClaim`, `autoClaimAttempts`, `autoClaimError`, `lastAutoClaimAt`, `claimSwapJson` +- **SparkExitExecution** — extends base with `coopExitRequestId` (SSP request id), `coopExitTxid` (L1 txid once broadcast), `exitSpeed` - **TransferStatus** — `waiting | pending | confirming | claimable | completed | failed | refunded | expired` -- **getRelatedTxids(exec)** — collects `depositTxid` + `claimTxid` + `receiveTransferId` for tx history deduplication +- **getRelatedTxids(exec)** — collects `depositTxid` + `claimTxid` + `receiveTransferId` + `coopExitTxid` for tx history deduplication ## Providers ### SideShift (`shared/services/transfer-service-sideshift.ts`) + - **Pairs**: BTC, Liquid BTC, Liquid USDT, Rootstock RBTC, Stacks STX — all cross-pairs - **Model**: Fixed quotes only. Deposit address flow. 15-min quote expiry. - **API**: `shared/services/sideshift-api.ts` — `sideshift.ai/api/v2` @@ -51,6 +56,7 @@ getTrackingUrl?(execution): string | undefined - Affiliate ID: `uYB9AagC9` ### Garden Finance (`shared/services/transfer-service-garden.ts`) + - **Pairs**: BTC → Botanix only (reverse requires EVM tx signing — deferred) - **Model**: Atomic swap deposit. Requires `fromAddress` for HTLC refund. - **API**: `shared/services/garden-api.ts` — `api.garden.finance/v2`, auth via `garden-app-id` header @@ -60,6 +66,7 @@ getTrackingUrl?(execution): string | undefined - Conditional on `EXPO_PUBLIC_GARDEN_APP_ID` env var ### Symbiosis (`shared/services/transfer-service-symbiosis.ts`) + - **Pairs**: BTC → Rootstock (working), BTC → Citrea (registered, no route yet) - **Model**: Combined quote+execute API (`/v1/swap`). Deposit address with expiration. - **API**: `shared/services/symbiosis-api.ts` — `api.symbiosis.finance/crosschain`, no auth @@ -67,15 +74,32 @@ getTrackingUrl?(execution): string | undefined - **Tracking**: `explorer.symbiosis.finance/transactions/bitcoin/{txHash}` ### Flashnet AMM (`shared/services/transfer-service-flashnet.ts`) + - **Pairs**: BTC <-> USDB on Spark (both directions) -- **Model**: Two-phase instant swap. `executeTransfer()` stages params in `pendingSwaps` and returns `status: 'pending'` *without* moving funds. `executeInstantSwap(executionId)` then runs `FlashnetClient.executeSwap()` and returns `status: 'completed'`. This split lets the UI / MCP show fee + impact before commit. +- **Model**: Two-phase instant swap. `executeTransfer()` stages params in `pendingSwaps` and returns `status: 'pending'` _without_ moving funds. `executeInstantSwap(executionId)` then runs `FlashnetClient.executeSwap()` and returns `status: 'completed'`. This split lets the UI / MCP show fee + impact before commit. - **API**: `FlashnetClient.simulateSwap()` for quotes, `executeSwap()` for execution - **Fees**: Derived from the pool's configured `lpFeeBps + hostFeeBps` (read from the cached `AmmPool` after `listPools`), as `amountIn × totalFeeBps / 10000`. `TransferQuote.feeBaseUnits` is then in the input asset's smallest units. **We deliberately do NOT use `SimulateSwapResponse.feePaidAssetIn`** — despite the name suggesting input-asset units, empirically the field is denominated in the OUTPUT asset's smallest units, which on a real BTC→USDB swap caused us to report a ~38% fee on a pool actually configured for 5 bps. The pool-bps approach is unit-unambiguous and direction-symmetric. **Price impact is NOT a fee** — exposed separately on `TransferQuote.priceImpactPct`. - **Slippage**: `maxSlippageBps: 300` + hard `minAmountOut = receiveAmount * 0.97`. - **SparkWallet access**: `SparkWallet.getSDKWalletForAccount(accountNumber)` static getter - No tracking URL (instant) +### SparkExit (`shared/services/transfer-service-spark-exit.ts`) + +- **Pairs**: `native:spark` → `native:bitcoin` (one-way — BTC→Spark is handled by NativeDeposit) +- **Model**: Two-phase stage-then-commit, like Flashnet, but the commit is **async** rather than instant. `executeTransfer()` stages the quote's `feeQuoteId` / `feeAmountSats` / `exitSpeed` / destination address in memory and returns `status: 'pending'` with no `coopExitRequestId` yet. `executeInstantSwap(executionId)` calls the SDK's `wallet.withdraw()` — _this_ is the point of no return (funds commit on Spark side). After commit, the SSP signs and broadcasts an L1 transaction asynchronously; `refreshTransferStatus()` polls `wallet.getCoopExitRequest(coopExitRequestId)` and walks the execution through `pending → confirming → completed`. +- **API**: `SparkWallet` SDK — `getWithdrawalFeeQuote()` for quotes, `withdraw()` for the irreversible commit, `getCoopExitRequest()` for status polling. +- **Exit speed**: Hard-coded to `'MEDIUM'` for v1. The SDK returns FAST/MEDIUM/SLOW tiers in a single quote; if we ever surface a speed picker, just stash all three on the staged quote and pick at commit time. +- **Fees**: `userFeeMedium + l1BroadcastFeeMedium` from the SDK quote, both in sats. We use `deductFeeFromWithdrawalAmount: true` in `withdraw()` so the recipient receives exactly the `receiveAmount` we showed in the quote. +- **Quote-time side effect**: `getWithdrawalFeeQuote()` may restructure the wallet's leaves via an SSP swap to produce correctly-denominated leaves for `amountSats`. This is unavoidable per SDK design — relied on so the fee quote reflects what `withdraw()` will actually consume. Mitigation: index.tsx's 500ms debounce ensures we only quote on stable amounts. +- **Quote-time destination address**: `getQuote()` runs _before_ the user reaches `confirm.tsx`, so we don't have their real BTC address yet. We use the BIP173 spec P2WPKH test vector (`bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4`) as a placeholder — the SDK fee is amount-bound, not address-bound (output script size is identical for any real P2WPKH/P2TR/P2WSH receive address), so the fee shown matches the actual withdrawal within sub-sat noise. The real address is bound at `withdraw()` time via `onchainAddress`. +- **SDK status → TransferStatus mapping**: `INITIATED`/`INBOUND_TRANSFER_CHECKED`/`TX_SIGNED` → `pending` · `TX_BROADCASTED`/`WAITING_ON_TX_CONFIRMATIONS` → `confirming` · `SUCCEEDED` → `completed` · `EXPIRED` → `expired` · `FAILED` → `failed` +- **Execution variant**: `SparkExitExecution` (new `EXECUTION_SPARK_EXIT` discriminant) with `coopExitRequestId`, `coopExitTxid`, `exitSpeed`. `getRelatedTxids()` includes `coopExitTxid` so the L1 tx deduplicates against Bitcoin tx history. +- **SparkWallet access**: same getter as Flashnet — `SparkWallet.getSDKWalletForAccount(accountNumber)` +- **Tracking URL**: mempool.space (`AllNetworkInfos[NETWORK_BITCOIN].explorerUrl + '/tx/' + coopExitTxid`) once the SSP broadcasts. +- **MCP**: not exposed. The async lifecycle doesn't fit `execute_swap`'s instant-swap contract. A separate `request_btc_withdrawal` MCP tool is the natural extension if needed. + ### NativeDeposit (`shared/services/transfer-service-native-deposit.ts`) + - **Pairs**: BTC → Ark, BTC → Spark - **Model**: 1:1 quotes. Wallet-driven status via `swapsFetcher`. Boarding/deposit address as deposit. - **Status flow**: `waiting → confirming → claimable → completed` (or `→ refunded`) @@ -88,6 +112,7 @@ getTrackingUrl?(execution): string | undefined - No tracking URL ### Fake (`shared/services/transfer-service-fake.ts`) + - **Pairs**: Liquid Testnet BTC <-> Botanix Testnet BTC - **Model**: Dev/test stub. Instant completion. Throws error when amount=1. - Only available in `__DEV__` mode @@ -97,12 +122,14 @@ getTrackingUrl?(execution): string | undefined **Entry**: "Transfer" button on Home → `/transfer` ### Screens (`mobile/app/transfer/`) + 1. **`index.tsx`** — Input screen. Bidirectional quote (type in either field). 500ms debounce. Min/max validation via `getPairInfo`. Balance check before confirm (skipped for testnets). Shows `serviceErrors` warnings for partial provider failures. 2. **`select-asset.tsx`** — Asset picker modal. Filters testnet assets via settings. -3. **`confirm.tsx`** — Auto-prepares on mount (`executeTransfer` + `getSendQuote`). Shows rate, fee, est. time, expiry countdown, provider. Single "Confirm" tap. NativeDeposit: uses boarding address, auto/manual claim toggle (hidden for ARK — always auto). Flashnet: no deposit address, instant swap on prepare. +3. **`confirm.tsx`** — Auto-prepares on mount (`executeTransfer` + `getSendQuote`). Shows rate, fee, est. time, expiry countdown, provider. Single "Confirm" tap. NativeDeposit: uses boarding address, auto/manual claim toggle (hidden for ARK — always auto). Flashnet / SparkExit: no deposit address, staged params on prepare; Confirm tap calls `transferService.executeInstantSwap(execution.id)` (the manager routes by execution id). For Flashnet this is the synchronous AMM trade; for SparkExit this is the SDK `withdraw()` that initiates the L1 broadcast, after which the transfer enters the ongoing list in `pending`/`confirming` and polls to completion. 4. **`success.tsx`** — Pull-to-dismiss modal with checkmark animation. ### Components (`mobile/components/transfer/`) + - `TransferAmountSection.tsx` — send/receive input with fiat toggle - `TransferAssetIcon.tsx` — colored icon with network badge - `AssetSelectorPill.tsx` — `[icon] [ticker] [chevron]` or "Select >" @@ -111,16 +138,20 @@ getTrackingUrl?(execution): string | undefined - `OngoingTransferItem.tsx` — status display with fiat values ### Detail Screen + - `mobile/app/TransferDetails.tsx` — Timeline from `getTimelineSteps()`. Detail rows: provider, status, transfer ID, addresses, deposit/claim txids. Claim button for NativeDeposit (disabled during auto-claim). "View Online" button when tracking URL available. ## Shared Hooks -- `useTransferService(storage)` — singleton TransferServiceManager (`shared/hooks/useTransferService.ts`). Also exports: `setNativeDepositSwapsFetcher`, `setNativeDepositClaimExecutor`, `startAutoClaimMonitor`, `stopAutoClaimMonitor`, `processAutoClaimsNow`, `setFlashnetAccountNumber`, `getTransferServiceManager` (non-hook singleton accessor — used by MCP). + +- `useTransferService(storage)` — singleton TransferServiceManager (`shared/hooks/useTransferService.ts`). Also exports: `setNativeDepositSwapsFetcher`, `setNativeDepositClaimExecutor`, `startAutoClaimMonitor`, `stopAutoClaimMonitor`, `processAutoClaimsNow`, `setFlashnetAccountNumber`, `setSparkExitAccountNumber`, `getTransferServiceManager` (non-hook singleton accessor — used by MCP). - `useTransactionHistory(network, account)` — merges transfers into tx list, deduplicates (`shared/hooks/useTransactionHistory.ts`) - `useAssetExchangeRate(assetId)` — fiat rate for transfer assets (`shared/hooks/useAssetExchangeRate.ts`) - `useAssetBalance(assetId, account, bg)` — unified native/token balance (`shared/hooks/useAssetBalance.ts`) ## Wallet Send Quote API + 2-step API for sending on-chain funds to deposit addresses: + - **Types**: `SendQuoteRequest`, `SendQuote` (`shared/types/send-quote.ts`) - **Interface**: `InterfaceSendQuotable` (`shared/class/wallets/interface-send-quotable.ts`) - **Implementations**: `EvmWallet`, `BreezWallet` @@ -130,15 +161,17 @@ getTrackingUrl?(execution): string | undefined Two tools expose Flashnet to remote AI agents. They run on `MCP_BALANCE_ACCOUNT_NUMBER` (= 4) so they don't touch the user's primary account. - **`get_swap_quote(send_asset, receive_asset, send_amount_base_units)`** — `send_asset` / `receive_asset` are strict `AssetId` strings (currently `native:spark` / `token:spark:usdb`). Internally: `lazyInitWallet(NETWORK_SPARK, 4)` → `setFlashnetAccountNumber(4)` → `manager.getQuote()` → `manager.executeTransfer()` (Flashnet: stages params, no funds movement — in-memory only, NOT persisted). Returns `{ quote_id, send_amount_base_units, receive_amount_base_units, fee_base_units, fee_asset, fee_ticker, price_impact_pct, rate, estimated_time_seconds, expires_at_unix, service }`. -- **`execute_swap(quote_id)`** — `manager.executeInstantSwap(quote_id)` → `commitTransfer()` (persists completed row). The manager looks up the owning service from `executionOwners` (populated in `executeTransfer`), so the agent only needs `quote_id`. Idempotency: the manager pops the owner entry on execute and the owning service pops the quote from its pending map; replay fails with *"No pending swap found"*. Quote expiry is enforced by Flashnet's internal `PENDING_SWAP_TTL` (5 min) on top of `TransferQuote.expiresAt` (60 s). +- **`execute_swap(quote_id)`** — `manager.executeInstantSwap(quote_id)` → `commitTransfer()` (persists completed row). The manager looks up the owning service from `executionOwners` (populated in `executeTransfer`), so the agent only needs `quote_id`. Idempotency: the manager pops the owner entry on execute and the owning service pops the quote from its pending map; replay fails with _"No pending swap found"_. Quote expiry is enforced by Flashnet's internal `PENDING_SWAP_TTL` (5 min) on top of `TransferQuote.expiresAt` (60 s). Adding more pairs is purely additive: extend `MCP_SWAP_ASSET_IDS` and ensure the relevant provider quotes the pair and implements `executeInstantSwap`. The manager routes by `executionOwners` so any such provider works without touching the MCP layer. ## Tests + - `shared/tests/unit-vi/transfer-service-sideshift.test.ts` - `shared/tests/unit-vi/transfer-service-garden.test.ts` - `shared/tests/unit-vi/transfer-service-symbiosis.test.ts` - `shared/tests/unit-vi/transfer-service-flashnet.test.ts` +- `shared/tests/unit-vi/transfer-service-spark-exit.test.ts` - `shared/tests/unit-vi/transfer-service-manager.test.ts` - `shared/tests/unit-vi/transfer-service-native-deposit.test.ts` - `shared/tests/unit-vi/sideshift-mappings.test.ts` diff --git a/mobile/app/TransferDetails.tsx b/mobile/app/TransferDetails.tsx index 63291497..e41d30af 100644 --- a/mobile/app/TransferDetails.tsx +++ b/mobile/app/TransferDetails.tsx @@ -14,7 +14,7 @@ import { LayerzStorage } from '@/src/class/layerz-storage'; import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; import { useTransferService } from '@shared/hooks/useTransferService'; import { getAssetInfo } from '@shared/models/asset-info'; -import { EXECUTION_CLAIM, getStatusLabel, isActiveStatus, isTerminalStatus, TransferExecution } from '@shared/types/transfer'; +import { EXECUTION_CLAIM, EXECUTION_SPARK_EXIT, getStatusLabel, isActiveStatus, isTerminalStatus, TransferExecution } from '@shared/types/transfer'; const POLL_INTERVAL = 10_000; @@ -180,6 +180,9 @@ export default function TransferDetails() { if (execution.type === EXECUTION_CLAIM && execution.claimTxid) { rows.push({ label: 'Claim Txid', value: execution.claimTxid, copyable: true }); } + if (execution.type === EXECUTION_SPARK_EXIT && execution.coopExitTxid) { + rows.push({ label: 'Exit Txid', value: execution.coopExitTxid, copyable: true }); + } return rows; }, [execution]); diff --git a/mobile/app/transfer/confirm.tsx b/mobile/app/transfer/confirm.tsx index cc77d35c..f7d4df9e 100644 --- a/mobile/app/transfer/confirm.tsx +++ b/mobile/app/transfer/confirm.tsx @@ -21,7 +21,7 @@ import { sleep } from '@shared/modules/sleep'; import { TSupportedLazyInitWalletNetworks } from '@shared/modules/wallet-utils'; import type { AssetId } from '@shared/types/asset'; import type { SendQuote } from '@shared/types/send-quote'; -import { EXECUTION_CLAIM, EXECUTION_INSTANT, type TransferExecution } from '@shared/types/transfer'; +import { EXECUTION_CLAIM, EXECUTION_INSTANT, EXECUTION_SPARK_EXIT, type TransferExecution } from '@shared/types/transfer'; import { NETWORK_SPARK } from '@shared/types/networks'; import { useTransferFlow } from '@/src/transfer/TransferFlowContext'; @@ -185,11 +185,14 @@ export default function TransferConfirm() { return; } - // Instant swap (e.g. Flashnet): execute the actual swap now, then commit - if (execution.type === EXECUTION_INSTANT) { - const completed = await transferService.executeInstantSwap(execution.id); - executionRef.current = completed; - await transferService.commitTransfer(completed); + // Staged-execution providers (Flashnet AMM swap, Spark cooperative exit): the actual + // irreversible op happens here, on user confirm. For Flashnet this is the AMM trade + // (sync — returns 'completed'); for SparkExit this is the SDK `withdraw()` call (async — + // returns 'pending' or 'confirming', then polls to 'completed' via getOngoingTransfers). + if (execution.type === EXECUTION_INSTANT || execution.type === EXECUTION_SPARK_EXIT) { + const committed = await transferService.executeInstantSwap(execution.id); + executionRef.current = committed; + await transferService.commitTransfer(committed); setPreparedExecution(undefined); setCommitted(true); router.replace('/modals/transfer-success'); diff --git a/mobile/src/transfer/TransferFlowContext.tsx b/mobile/src/transfer/TransferFlowContext.tsx index 6a6ca79a..8ebd72eb 100644 --- a/mobile/src/transfer/TransferFlowContext.tsx +++ b/mobile/src/transfer/TransferFlowContext.tsx @@ -5,7 +5,7 @@ import { LayerzStorage } from '@/src/class/layerz-storage'; import { BackgroundExecutor } from '@/src/modules/background-executor'; import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; import { EStep, InitializationContext } from '@shared/hooks/InitializationContext'; -import { setFlashnetAccountNumber, setNativeDepositSwapsFetcher, useTransferService } from '@shared/hooks/useTransferService'; +import { setFlashnetAccountNumber, setNativeDepositSwapsFetcher, setSparkExitAccountNumber, useTransferService } from '@shared/hooks/useTransferService'; import { swapFetcher } from '@shared/hooks/useSwaps'; import { TransferServiceManager } from '@shared/services/transfer-service-manager'; import { AssetId } from '@shared/types/asset'; @@ -55,10 +55,11 @@ export function TransferFlowProvider({ children }: { children: ReactNode }) { setNativeDepositSwapsFetcher((network, acct) => swapFetcher({ cacheKey: 'ndSwapFetcher', accountNumber: acct, network, backgroundCaller: BackgroundExecutor })); }, [step]); - // Ensure Spark wallet is initialized so Flashnet swaps can work + // Ensure Spark wallet is initialized so Flashnet swaps and SparkExit withdrawals can work useEffect(() => { if (step !== EStep.READY) return; setFlashnetAccountNumber(accountNumber); + setSparkExitAccountNumber(accountNumber); BackgroundExecutor.lazyInitWallet(NETWORK_SPARK, accountNumber).catch(() => {}); }, [accountNumber, step]); diff --git a/shared/hooks/useTransferService.ts b/shared/hooks/useTransferService.ts index 33f413fc..f05fd215 100644 --- a/shared/hooks/useTransferService.ts +++ b/shared/hooks/useTransferService.ts @@ -5,6 +5,7 @@ import { GardenTransferService } from '../services/transfer-service-garden'; import { TransferServiceManager } from '../services/transfer-service-manager'; import { NativeDepositClaimExecutor, NativeDepositSwapsFetcher, NativeDepositTransferService } from '../services/transfer-service-native-deposit'; import { SideshiftTransferService } from '../services/transfer-service-sideshift'; +import { SparkExitTransferService } from '../services/transfer-service-spark-exit'; import { SymbiosisTransferService } from '../services/transfer-service-symbiosis'; import { IStorage } from '../types/IStorage'; import { ITransferService } from '../types/transfer'; @@ -12,6 +13,7 @@ import { ITransferService } from '../types/transfer'; let _instance: TransferServiceManager | undefined; let _nativeDepositService: NativeDepositTransferService | undefined; let _flashnetService: FlashnetTransferService | undefined; +let _sparkExitService: SparkExitTransferService | undefined; export function setNativeDepositSwapsFetcher(fn: NativeDepositSwapsFetcher): void { _nativeDepositService?.setSwapsFetcher(fn); @@ -37,6 +39,10 @@ export function setFlashnetAccountNumber(accountNumber: number): void { _flashnetService?.setCurrentAccountNumber(accountNumber); } +export function setSparkExitAccountNumber(accountNumber: number): void { + _sparkExitService?.setCurrentAccountNumber(accountNumber); +} + /** Returns the singleton TransferServiceManager if it's been constructed yet. Module-level singleton; MCP and other non-hook callers should use this after the app boot has run `useTransferService`. */ export function getTransferServiceManager(): TransferServiceManager | undefined { return _instance; @@ -55,6 +61,8 @@ export function useTransferService(storage: IStorage): TransferServiceManager { services.push(new SymbiosisTransferService(storage)); _flashnetService = new FlashnetTransferService(storage, (accountNumber) => SparkWallet.getSDKWalletForAccount(accountNumber)); services.push(_flashnetService); + _sparkExitService = new SparkExitTransferService(storage, (accountNumber) => SparkWallet.getSDKWalletForAccount(accountNumber)); + services.push(_sparkExitService); _nativeDepositService = new NativeDepositTransferService(storage); services.push(_nativeDepositService); services.push(new FakeTransferService()); diff --git a/shared/services/transfer-service-spark-exit.ts b/shared/services/transfer-service-spark-exit.ts new file mode 100644 index 00000000..2479fcf7 --- /dev/null +++ b/shared/services/transfer-service-spark-exit.ts @@ -0,0 +1,603 @@ +import BigNumber from 'bignumber.js'; + +import type { SparkSDKWallet } from '../class/wallets/spark-wallet'; +import { AllNetworkInfos } from '../models/all-network-infos'; +import { getAssetInfo } from '../models/asset-info'; +import { IStorage, STORAGE_KEY_SPARK_EXIT_TRANSFERS } from '../types/IStorage'; +import { AssetId } from '../types/asset'; +import { NETWORK_BITCOIN } from '../types/networks'; +import { + EXECUTION_SPARK_EXIT, + isTerminalStatus, + ITransferService, + SparkExitExecution, + TimelineStep, + TransferExecution, + TransferPair, + TransferPairInfo, + TransferQuote, + TransferStatus, +} from '../types/transfer'; + +// Derived from public `SparkSDKWallet` signatures: the SDK doesn't re-export these types from +// its package root, and a `dist/` subpath import would couple `shared/` to its build layout. +type CoopExitFeeQuote = NonNullable>>; +type CoopExitRequestStatus = NonNullable>>['status']; + +const PRUNE_AGE_SECONDS = 7 * 24 * 60 * 60; +const PENDING_QUOTE_TTL = 5 * 60; // 5 minutes — matches typical CoopExitFeeQuote expiry +const PENDING_EXIT_TTL = 5 * 60; + +/** Default exit speed for v1. The SDK quote returns fees for FAST/MEDIUM/SLOW; we pick MEDIUM. */ +const DEFAULT_EXIT_SPEED: 'FAST' | 'MEDIUM' | 'SLOW' = 'MEDIUM'; + +/** + * BIP173 spec test-vector P2WPKH address. Used as a placeholder destination for + * `getWithdrawalFeeQuote()` because the user's real Bitcoin address isn't known at + * quote time (the interface signature `getQuote(send, receive, amount)` doesn't carry + * a destination, and quoting happens in `index.tsx` before the user reaches `confirm.tsx`). + * + * Why this is safe: the SDK's fee depends on (a) Spark-side userFee (independent of + * destination), and (b) L1 broadcast fee, which depends on tx vbytes. P2WPKH outputs + * are 22 bytes — the same as any sensible mainnet receive address — so the L1 fee + * estimate using this placeholder matches the actual withdrawal within sub-sat noise. + * + * The real destination address is bound at `withdraw()` time via the `onchainAddress` + * parameter; the `feeQuoteId` from the quote is amount-bound, not address-bound. + */ +const QUOTE_PLACEHOLDER_ADDRESS = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + +const SUPPORTED_PAIRS: TransferPair[] = [{ sendAssetId: 'native:spark', receiveAssetId: 'native:bitcoin' }]; + +interface SparkExitPersistedTransfer { + execution: SparkExitExecution; +} + +interface PendingQuoteParams { + amountSats: number; + feeQuoteId: string; + feeAmountSats: number; + exitSpeed: 'FAST' | 'MEDIUM' | 'SLOW'; + /** + * The account active at quote time — the SDK's `feeQuoteId` is bound to the wallet session + * that produced it, so `executeTransfer` must reject if the caller's `accountNumber` doesn't + * match. Without this check, an account switch between quote and Continue tap would push a + * cross-account feeQuoteId into `withdraw()` and either be rejected by the SSP (best case) + * or debit the wrong account (worst case). + */ + accountNumber: number; + quote: TransferQuote; + createdAt: number; +} + +interface PendingExitParams { + amountSats: number; + receiveAmountSats: number; + feeQuoteId: string; + feeAmountSats: number; + exitSpeed: 'FAST' | 'MEDIUM' | 'SLOW'; + onchainAddress: string; + accountNumber: number; + sendAsset: AssetId; + receiveAsset: AssetId; + sendAmount: string; + receiveAmount: string; + createdAt: number; +} + +export class SparkExitTransferService implements ITransferService { + readonly name = 'SparkExit'; + private storage: IStorage; + private getSparkWallet: (accountNumber: number) => SparkSDKWallet | undefined; + private currentAccountNumber: number = 0; + private pendingQuotes: Map = new Map(); + private pendingExits: Map = new Map(); + /** + * IDs whose `executeInstantSwap` call is currently in-flight (SDK `withdraw()` is awaiting). + * A second concurrent call for the same id throws "already in progress" rather than entering + * `withdraw()` twice. Independent of `pendingExits` because we want concurrent-call protection + * to be orthogonal to the success/failure cleanup of the staging map. + */ + private inFlightExits: Set = new Set(); + + constructor(storage: IStorage, getSparkWallet: (accountNumber: number) => SparkSDKWallet | undefined) { + this.storage = storage; + this.getSparkWallet = getSparkWallet; + } + + setCurrentAccountNumber(accountNumber: number): void { + this.currentAccountNumber = accountNumber; + } + + getSupportedPairs(): TransferPair[] { + return SUPPORTED_PAIRS; + } + + async getPairInfo(_send: AssetId, _receive: AssetId): Promise { + // Min is the Spark→BTC effective dust limit: anything smaller would be eaten by fees. + // ~1000 sats is a reasonable floor that covers L1 dust (546 sats) + Spark userFee headroom. + // Max is an arbitrary safety cap; raise if users hit it. + return { min: '0.00001', max: '1', rate: '1' }; + } + + async getQuote(sendAsset: AssetId, receiveAsset: AssetId, sendAmount: string): Promise { + const sendInfo = getAssetInfo(sendAsset); + const receiveInfo = getAssetInfo(receiveAsset); + + const wallet = this.requireWallet(); + const amountSats = new BigNumber(sendAmount).times(new BigNumber(10).pow(sendInfo.decimals)).integerValue(BigNumber.ROUND_FLOOR).toNumber(); + + // SIDE EFFECT: per SDK docs, getWithdrawalFeeQuote may restructure the wallet's leaves + // via an SSP swap so they exactly match `amountSats`. This is unavoidable — it's how + // the SDK guarantees the fee quote reflects the actual leaves the subsequent withdraw() + // will consume. Documented in `.agents/swap.md`. + const feeQuote = await wallet.getWithdrawalFeeQuote({ amountSats, withdrawalAddress: QUOTE_PLACEHOLDER_ADDRESS }); + if (!feeQuote) { + throw new Error('Spark withdrawal fee quote unavailable'); + } + + const exitSpeed = DEFAULT_EXIT_SPEED; + const userFeeSats = pickUserFee(feeQuote, exitSpeed); + const l1BroadcastFeeSats = pickL1Fee(feeQuote, exitSpeed); + const feeAmountSats = userFeeSats + l1BroadcastFeeSats; + + // Receive amount = send amount minus the total fee (deductFeeFromWithdrawalAmount=false at withdraw time + // would charge the fee separately, but for the UI we present a net-of-fee receive figure so the user can + // see exactly what they'll get on L1). + const receiveAmountSats = Math.max(0, amountSats - feeAmountSats); + + const sendAmountHuman = new BigNumber(amountSats).div(new BigNumber(10).pow(sendInfo.decimals)).toFixed(sendInfo.decimals); + const receiveAmountHuman = new BigNumber(receiveAmountSats).div(new BigNumber(10).pow(receiveInfo.decimals)).toFixed(receiveInfo.decimals); + const feeHuman = new BigNumber(feeAmountSats).div(new BigNumber(10).pow(sendInfo.decimals)).toFixed(sendInfo.decimals); + + // 1:1 rate makes sense for BTC-on-Spark → BTC-on-Bitcoin; both sides are denominated in BTC. + const rate = `1 ${sendInfo.ticker} = 1 ${receiveInfo.ticker} (minus ${feeHuman} BTC fee)`; + + const now = Math.floor(Date.now() / 1000); + const quoteId = `spark-exit-${now}-${Math.random().toString(36).slice(2, 8)}`; + + const quote: TransferQuote = { + id: quoteId, + sendAsset, + receiveAsset, + sendAmount: sendAmountHuman, + receiveAmount: receiveAmountHuman, + rate, + fee: feeHuman, + feeTicker: sendInfo.ticker, + feeBaseUnits: feeAmountSats.toFixed(0), + estimatedTime: 30 * 60, // ~30 min ballpark; depends on Bitcoin confirmation time at chosen speed + expiresAt: quoteExpiresAt(feeQuote, now), + serviceName: this.name, + }; + + this.prunePendingQuotes(now); + this.pendingQuotes.set(quoteId, { + amountSats, + feeQuoteId: feeQuote.id, + feeAmountSats, + exitSpeed, + // Capture the account the SDK quote was bound to. executeTransfer will reject a mismatch. + accountNumber: this.currentAccountNumber, + quote, + createdAt: now, + }); + + return quote; + } + + async executeTransfer(quote: TransferQuote, accountNumber: number, settleAddress: string, _fromAddress?: string): Promise { + if (!settleAddress) { + throw new Error('Bitcoin destination address is required for Spark→BTC withdrawal'); + } + + const pending = this.pendingQuotes.get(quote.id); + if (!pending) { + throw new Error('Quote not found or expired. Please re-quote and try again.'); + } + // The SDK's `feeQuoteId` is bound to the wallet that produced it. If the user switched + // accounts between getQuote and Continue, the staged feeQuoteId would belong to the wrong + // wallet — and would either be rejected by the SSP or, worse, debit the wrong account. + // Fail loudly and force a re-quote on the new account. + if (pending.accountNumber !== accountNumber) { + this.pendingQuotes.delete(quote.id); + throw new Error('Account changed since the quote was generated. Please re-quote and try again.'); + } + this.pendingQuotes.delete(quote.id); + + const sendInfo = getAssetInfo(quote.sendAsset); + const receiveInfo = getAssetInfo(quote.receiveAsset); + const receiveAmountSats = new BigNumber(quote.receiveAmount).times(new BigNumber(10).pow(receiveInfo.decimals)).integerValue(BigNumber.ROUND_FLOOR).toNumber(); + + const now = Math.floor(Date.now() / 1000); + const executionId = `spark-exit-${now}-${Math.random().toString(36).slice(2, 8)}`; + + this.prunePendingExits(now); + this.pendingExits.set(executionId, { + amountSats: pending.amountSats, + receiveAmountSats, + feeQuoteId: pending.feeQuoteId, + feeAmountSats: pending.feeAmountSats, + exitSpeed: pending.exitSpeed, + onchainAddress: settleAddress, + accountNumber, + sendAsset: quote.sendAsset, + receiveAsset: quote.receiveAsset, + sendAmount: quote.sendAmount, + receiveAmount: quote.receiveAmount, + createdAt: now, + }); + + // Return a transient (not yet persisted) execution. The actual withdraw() happens in + // executeInstantSwap when the user taps Confirm. We return EXECUTION_SPARK_EXIT with + // no coopExitRequestId yet — confirm.tsx routes this through executeInstantSwap because + // TransferServiceManager.executionOwners is populated for any service implementing + // executeInstantSwap (we do). + const execution: SparkExitExecution = { + type: EXECUTION_SPARK_EXIT, + id: executionId, + status: 'pending', + sendAmount: quote.sendAmount, + receiveAmount: quote.receiveAmount, + sendAsset: quote.sendAsset, + receiveAsset: quote.receiveAsset, + createdAt: now, + updatedAt: now, + settleAddress, + accountNumber, + serviceName: this.name, + coopExitRequestId: '', // populated in executeInstantSwap + exitSpeed: pending.exitSpeed, + }; + void sendInfo; // satisfy linter — keeping for future use if we add validation here + return execution; + } + + /** + * Commits the irreversible Spark SDK `withdraw()` call. The contract mirrors Flashnet's + * `executeInstantSwap`: the manager routes here by execution id when the user confirms. + * + * Lifecycle invariants (these are why this method is more elaborate than the Flashnet equivalent): + * + * 1. **Concurrent-call protection.** `inFlightExits` blocks a second call for the same id while + * the SDK promise is awaiting. A double-tap on Confirm therefore cannot enter `withdraw()` + * twice and produce two cooperative exits. + * + * 2. **Account is taken from staged params, NOT `currentAccountNumber`.** The user may switch + * accounts between Continue and Confirm; we must always withdraw from the account that + * produced the `feeQuoteId`. Using `currentAccountNumber` would resolve the wrong wallet + * and either get rejected by the SSP or, worse, debit the wrong account. + * + * 3. **Persist before returning, atomically with success.** The reviewer pointed out that the + * previous version popped `pendingExits` and then called `withdraw()`: if the SDK promise + * rejected after the SSP had accepted, or if the caller's subsequent `commitTransfer` threw, + * the `coopExitRequestId` was lost and the user had no row to poll. We now persist inline + * after a successful SDK response and only delete `pendingExits` after persistence succeeds. + * + * 4. **Conservative cleanup on error.** Because the SDK does not expose an idempotency token at + * the public `withdraw()` surface, an SDK rejection is ambiguous — the SSP may or may not + * have committed. We pop `pendingExits` on error to prevent a second `withdraw()` that would + * create a duplicate cooperative exit. The user must re-quote to retry; if the first call + * actually succeeded server-side, the L1 broadcast will surface in their tx history shortly. + */ + async executeInstantSwap(executionId: string): Promise { + const params = this.pendingExits.get(executionId); + if (!params) { + throw new Error('No pending exit found for this execution. It may have expired or already been executed.'); + } + if (this.inFlightExits.has(executionId)) { + throw new Error('Withdrawal already in progress for this execution. Wait for it to settle before retrying.'); + } + + // Resolve the wallet for the *staged* account (invariant 2 above). Falling back to + // `currentAccountNumber` would be a fund-safety bug if the user switched accounts mid-flow. + const wallet = this.getSparkWallet(params.accountNumber); + if (!wallet) { + throw new Error(`Spark wallet for account ${params.accountNumber} is not initialized. Open that account first, then retry.`); + } + + this.inFlightExits.add(executionId); + try { + // `exitSpeed` is a string-valued SDK enum (`ExitSpeed`). We don't import the enum at module + // load because it would couple `shared/` to a Spark SDK subpath; the string value `'MEDIUM'` + // is exactly what the SDK expects at runtime, so we cast at this single boundary. + const coopExit = await wallet.withdraw({ + onchainAddress: params.onchainAddress, + exitSpeed: params.exitSpeed as unknown as Parameters[0]['exitSpeed'], + amountSats: params.amountSats, + feeQuoteId: params.feeQuoteId, + feeAmountSats: params.feeAmountSats, + // Fee deducted from withdrawal amount so the recipient gets `amountSats - feeAmountSats`, + // which matches what we displayed as `receiveAmount` in the quote. + deductFeeFromWithdrawalAmount: true, + }); + + if (!coopExit) { + // SDK returned null — SSP rejected upfront, no funds moved. Safe to pop and surface. + this.pendingExits.delete(executionId); + throw new Error('Spark withdrawal failed: SSP returned no exit request'); + } + + const now = Math.floor(Date.now() / 1000); + const execution: SparkExitExecution = { + type: EXECUTION_SPARK_EXIT, + id: executionId, + status: mapSdkStatus(coopExit.status), + sendAmount: params.sendAmount, + receiveAmount: params.receiveAmount, + sendAsset: params.sendAsset, + receiveAsset: params.receiveAsset, + createdAt: params.createdAt, + updatedAt: now, + settleAddress: params.onchainAddress, + accountNumber: params.accountNumber, + serviceName: this.name, + coopExitRequestId: coopExit.id, + coopExitTxid: coopExit.coopExitTxid || undefined, + exitSpeed: params.exitSpeed, + }; + + // Persist BEFORE clearing pending and returning. If this throws after a successful + // `withdraw()` the SDK has committed the funds — we log loudly so the lost `coopExitRequestId` + // is at least visible in the device logs, and re-throw so the UI surfaces the failure. + try { + await this.commitTransfer(execution); + } catch (persistErr) { + console.error(`[SparkExit] CRITICAL: SDK withdraw succeeded (coopExitRequestId=${coopExit.id}) but persist failed. The withdrawal will still execute on L1.`, persistErr); + this.pendingExits.delete(executionId); + throw persistErr; + } + + this.pendingExits.delete(executionId); + return execution; + } catch (e) { + // Conservative cleanup: pop pending so a UI retry cannot trigger a second `withdraw()`. + // See invariant 4 above. `pendingExits.delete` is idempotent so it's safe to call again + // even if a more specific catch above already deleted. + this.pendingExits.delete(executionId); + throw e; + } finally { + this.inFlightExits.delete(executionId); + } + } + + async commitTransfer(execution: TransferExecution): Promise { + if (execution.type !== EXECUTION_SPARK_EXIT) return; + const transfers = await this.loadTransfers(); + const idx = transfers.findIndex((t) => t.execution.id === execution.id); + if (idx >= 0) { + transfers[idx].execution = { ...transfers[idx].execution, ...execution }; + } else { + transfers.push({ execution }); + } + await this.saveTransfers(transfers); + } + + async refreshTransferStatus(executionId: string, _accountNumber: number): Promise { + const transfers = await this.loadTransfers(); + const entry = transfers.find((t) => t.execution.id === executionId); + if (!entry) { + throw new Error(`SparkExit transfer ${executionId} not found`); + } + const refreshed = await this.refreshOne(entry.execution); + if (refreshed !== entry.execution) { + entry.execution = refreshed; + await this.saveTransfers(transfers); + } + return entry.execution; + } + + async getOngoingTransfers(accountNumber: number): Promise { + const transfers = await this.loadTransfers(); + const now = Math.floor(Date.now() / 1000); + + // Prune fully-terminal old transfers + const active: SparkExitPersistedTransfer[] = []; + for (const t of transfers) { + if (isTerminalStatus(t.execution.status) && now - t.execution.createdAt > PRUNE_AGE_SECONDS) continue; + active.push(t); + } + if (active.length !== transfers.length) { + await this.saveTransfers(active); + } + + // Opportunistically refresh non-terminal transfers for the active account. + // We refresh inline so the UI sees fresh status without a separate polling layer. + let changed = false; + for (const t of active) { + if (t.execution.accountNumber !== accountNumber) continue; + if (isTerminalStatus(t.execution.status)) continue; + try { + const refreshed = await this.refreshOne(t.execution); + if (refreshed !== t.execution) { + t.execution = refreshed; + changed = true; + } + } catch { + // Network/SSP hiccup — leave status as-is and try next poll. + } + } + if (changed) { + await this.saveTransfers(active); + } + + return active.filter((t) => t.execution.accountNumber === accountNumber).map((t) => t.execution); + } + + getTimelineSteps(execution: TransferExecution): TimelineStep[] { + if (execution.type !== EXECUTION_SPARK_EXIT) return []; + const { status, createdAt, updatedAt, coopExitTxid } = execution; + + const step1Done = status === 'pending' || status === 'confirming' || status === 'completed' || isTerminalStatus(status); + const step2Done = status === 'confirming' || status === 'completed'; + const step3Done = status === 'completed'; + + return [ + { + title: 'Exit initiated', + description: 'Spark cooperative exit requested', + status: step1Done ? 'completed' : 'active', + timestamp: createdAt, + }, + { + title: 'Broadcasted to Bitcoin', + description: coopExitTxid ? `L1 txid: ${coopExitTxid.slice(0, 12)}…` : 'Waiting for SSP to sign and broadcast', + status: step3Done ? 'completed' : step2Done ? 'active' : 'upcoming', + timestamp: step2Done ? updatedAt : undefined, + }, + { + title: 'Confirmed on Bitcoin', + description: 'Funds settled on L1', + status: step3Done ? 'completed' : 'upcoming', + timestamp: step3Done ? updatedAt : undefined, + }, + ]; + } + + getTrackingUrl(execution: TransferExecution): string | undefined { + if (execution.type !== EXECUTION_SPARK_EXIT) return undefined; + if (!execution.coopExitTxid) return undefined; + const explorer = AllNetworkInfos[NETWORK_BITCOIN].explorerUrl; + return `${explorer}/tx/${execution.coopExitTxid}`; + } + + private async refreshOne(execution: SparkExitExecution): Promise { + if (!execution.coopExitRequestId) return execution; + const wallet = this.getSparkWallet(execution.accountNumber); + if (!wallet) return execution; // wallet not initialized yet — caller will retry on next poll + + const req = await wallet.getCoopExitRequest(execution.coopExitRequestId); + if (!req) return execution; + + const newStatus = mapSdkStatus(req.status); + const newTxid = req.coopExitTxid || execution.coopExitTxid; + if (newStatus === execution.status && newTxid === execution.coopExitTxid) { + return execution; + } + return { + ...execution, + status: newStatus, + coopExitTxid: newTxid || undefined, + updatedAt: Math.floor(Date.now() / 1000), + }; + } + + /** + * Resolves the SDK wallet for the *currently active* account. Only safe to call from + * `getQuote`, where the SDK fee quote must be bound to whichever account the user is on + * right now. **Do NOT use from `executeInstantSwap`** — that codepath must resolve the + * wallet from the staged `params.accountNumber` to avoid debiting the wrong account if + * the user switches accounts between Continue and Confirm. + */ + private requireWallet(): SparkSDKWallet { + const wallet = this.getSparkWallet(this.currentAccountNumber); + if (!wallet) { + throw new Error('Spark wallet not initialized. Please open your Spark wallet first.'); + } + return wallet; + } + + private prunePendingQuotes(now: number): void { + for (const [id, p] of this.pendingQuotes) { + if (now - p.createdAt > PENDING_QUOTE_TTL) this.pendingQuotes.delete(id); + } + } + + private prunePendingExits(now: number): void { + for (const [id, p] of this.pendingExits) { + if (now - p.createdAt > PENDING_EXIT_TTL) this.pendingExits.delete(id); + } + } + + private async loadTransfers(): Promise { + const raw = await this.storage.getItem(STORAGE_KEY_SPARK_EXIT_TRANSFERS); + if (!raw) return []; + try { + return JSON.parse(raw) as SparkExitPersistedTransfer[]; + } catch { + return []; + } + } + + private async saveTransfers(transfers: SparkExitPersistedTransfer[]): Promise { + await this.storage.setItem(STORAGE_KEY_SPARK_EXIT_TRANSFERS, JSON.stringify(transfers)); + } +} + +/** + * Reads a `CurrencyAmount` fee field as sats. We treat the value as sats, so the unit must be + * SATOSHI — reading e.g. a BITCOIN-denominated value as sats understates the fee by 1e8 and + * loses funds. Throw on a wrong unit or a missing field; both block the withdrawal, which is + * the fund-safe failure mode (a `?? 0` default would silently underprice it). + */ +function feeAmountToSats(amount: CoopExitFeeQuote['userFeeMedium'] | undefined, label: string): number { + if (!amount) { + throw new Error(`Spark fee quote is missing the "${label}" field — cannot price the withdrawal.`); + } + if (String(amount.originalUnit) !== 'SATOSHI') { + throw new Error(`Spark fee quote field "${label}" has unexpected unit "${String(amount.originalUnit)}" — expected SATOSHI. Refusing to withdraw to avoid mis-pricing the fee.`); + } + return Number(amount.originalValue); +} + +/** + * Quote expiry (epoch seconds) the UI counts down against. Use the SDK fee quote's own + * `expiresAt` — the real binding deadline for `feeQuoteId` — instead of a fixed window, so the + * countdown matches reality. Fall back to the staging TTL if it's missing/unparseable. + */ +function quoteExpiresAt(feeQuote: CoopExitFeeQuote, now: number): number { + const parsed = Math.floor(new Date(feeQuote.expiresAt).getTime() / 1000); + return Number.isFinite(parsed) ? parsed : now + PENDING_QUOTE_TTL; +} + +/** Pulls the user-fee sats value (independent of L1 broadcast fee) from a CoopExitFeeQuote. */ +function pickUserFee(feeQuote: CoopExitFeeQuote, speed: 'FAST' | 'MEDIUM' | 'SLOW'): number { + switch (speed) { + case 'FAST': + return feeAmountToSats(feeQuote.userFeeFast, 'userFeeFast'); + case 'MEDIUM': + return feeAmountToSats(feeQuote.userFeeMedium, 'userFeeMedium'); + case 'SLOW': + return feeAmountToSats(feeQuote.userFeeSlow, 'userFeeSlow'); + } +} + +/** Pulls the L1 broadcast fee sats value (independent of Spark userFee) from a CoopExitFeeQuote. */ +function pickL1Fee(feeQuote: CoopExitFeeQuote, speed: 'FAST' | 'MEDIUM' | 'SLOW'): number { + switch (speed) { + case 'FAST': + return feeAmountToSats(feeQuote.l1BroadcastFeeFast, 'l1BroadcastFeeFast'); + case 'MEDIUM': + return feeAmountToSats(feeQuote.l1BroadcastFeeMedium, 'l1BroadcastFeeMedium'); + case 'SLOW': + return feeAmountToSats(feeQuote.l1BroadcastFeeSlow, 'l1BroadcastFeeSlow'); + } +} + +/** + * Map `SparkCoopExitRequestStatus` to our internal `TransferStatus`. + * - INITIATED / INBOUND_TRANSFER_CHECKED / TX_SIGNED → pending (SSP processing, no L1 broadcast yet) + * - TX_BROADCASTED / WAITING_ON_TX_CONFIRMATIONS → confirming (L1 tx exists, waiting on confirmations) + * - SUCCEEDED → completed + * - EXPIRED / FAILED → terminal failure modes + */ +function mapSdkStatus(sdkStatus: CoopExitRequestStatus): TransferStatus { + // `sdkStatus` is a string-valued SDK enum; widen to string so the literal `case`s compare + // cleanly and the `default` still catches any FUTURE_VALUE the SDK adds. + switch (String(sdkStatus)) { + case 'SUCCEEDED': + return 'completed'; + case 'TX_BROADCASTED': + case 'WAITING_ON_TX_CONFIRMATIONS': + return 'confirming'; + case 'EXPIRED': + return 'expired'; + case 'FAILED': + return 'failed'; + case 'INITIATED': + case 'INBOUND_TRANSFER_CHECKED': + case 'TX_SIGNED': + default: + return 'pending'; + } +} diff --git a/shared/tests/unit-vi/transfer-service-spark-exit.test.ts b/shared/tests/unit-vi/transfer-service-spark-exit.test.ts new file mode 100644 index 00000000..392688e1 --- /dev/null +++ b/shared/tests/unit-vi/transfer-service-spark-exit.test.ts @@ -0,0 +1,705 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SparkExitTransferService } from '../../services/transfer-service-spark-exit'; +import { STORAGE_KEY_SPARK_EXIT_TRANSFERS } from '../../types/IStorage'; +import { EXECUTION_SPARK_EXIT, SparkExitExecution, TransferQuote } from '../../types/transfer'; + +const SPARK_BTC = 'native:spark' as const; +const BITCOIN = 'native:bitcoin' as const; +const USER_BTC_ADDR = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; // a different valid mainnet P2WPKH +const PLACEHOLDER_ADDR = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; // BIP173 spec test vector + +/** Build a CoopExitFeeQuote-shaped mock. Each `originalValue` is in sats. */ +function makeFeeQuoteFixture(): any { + return { + id: 'fee-quote-abc', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + network: 'MAINNET', + totalAmount: { originalValue: 100_000, originalUnit: 'SATOSHI' }, + userFeeFast: { originalValue: 500, originalUnit: 'SATOSHI' }, + userFeeMedium: { originalValue: 250, originalUnit: 'SATOSHI' }, + userFeeSlow: { originalValue: 100, originalUnit: 'SATOSHI' }, + l1BroadcastFeeFast: { originalValue: 4000, originalUnit: 'SATOSHI' }, + l1BroadcastFeeMedium: { originalValue: 2000, originalUnit: 'SATOSHI' }, + l1BroadcastFeeSlow: { originalValue: 1000, originalUnit: 'SATOSHI' }, + expiresAt: new Date(Date.now() + 300_000).toISOString(), + typename: 'CoopExitFeeQuote', + }; +} + +function makeStorage() { + const map = new Map(); + return { + map, + getItem: vi.fn(async (k: string) => map.get(k) ?? ''), + setItem: vi.fn(async (k: string, v: string) => { + map.set(k, v); + }), + }; +} + +function makeMockWallet() { + return { + getWithdrawalFeeQuote: vi.fn().mockResolvedValue(makeFeeQuoteFixture()), + withdraw: vi.fn().mockResolvedValue({ + id: 'coop-exit-req-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'INITIATED', + coopExitTxid: '', + fee: { originalValue: 250, originalUnit: 'SATOSHI' }, + l1BroadcastFee: { originalValue: 2000, originalUnit: 'SATOSHI' }, + network: 'MAINNET', + expiresAt: new Date(Date.now() + 300_000).toISOString(), + rawConnectorTransaction: '', + rawCoopExitTransaction: '', + typename: 'CoopExitRequest', + }), + getCoopExitRequest: vi.fn(), + }; +} + +describe('SparkExitTransferService', () => { + let service: SparkExitTransferService; + let storage: ReturnType; + let mockWallet: ReturnType; + + beforeEach(() => { + storage = makeStorage(); + mockWallet = makeMockWallet(); + service = new SparkExitTransferService(storage as any, () => mockWallet as any); + service.setCurrentAccountNumber(0); + }); + + describe('getSupportedPairs', () => { + it('returns only native:spark → native:bitcoin (Spark→BTC is one-way)', () => { + const pairs = service.getSupportedPairs(); + expect(pairs).toEqual([{ sendAssetId: SPARK_BTC, receiveAssetId: BITCOIN }]); + }); + }); + + describe('getPairInfo', () => { + it('returns sensible min/max for the Spark exit', async () => { + const info = await service.getPairInfo(SPARK_BTC, BITCOIN); + expect(info).toEqual({ min: '0.00001', max: '1', rate: '1' }); + }); + }); + + describe('getQuote', () => { + it('quotes by calling the SDK with the placeholder address and MEDIUM fees', async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + + // 0.001 BTC = 100,000 sats + expect(mockWallet.getWithdrawalFeeQuote).toHaveBeenCalledWith({ + amountSats: 100_000, + withdrawalAddress: PLACEHOLDER_ADDR, + }); + + // userFeeMedium (250) + l1BroadcastFeeMedium (2000) = 2250 sats + expect(quote.feeBaseUnits).toBe('2250'); + expect(quote.fee).toBe('0.00002250'); + expect(quote.feeTicker).toBe('BTC'); + + // receiveAmount = 100_000 − 2250 = 97_750 sats = 0.0009775 BTC + expect(quote.receiveAmount).toBe('0.00097750'); + expect(quote.sendAmount).toBe('0.00100000'); + expect(quote.sendAsset).toBe(SPARK_BTC); + expect(quote.receiveAsset).toBe(BITCOIN); + expect(quote.serviceName).toBe('SparkExit'); + expect(quote.id).toMatch(/^spark-exit-/); + }); + + it('throws when Spark wallet not initialized', async () => { + const noWalletService = new SparkExitTransferService(storage as any, () => undefined); + noWalletService.setCurrentAccountNumber(0); + await expect(noWalletService.getQuote(SPARK_BTC, BITCOIN, '0.001')).rejects.toThrow('Spark wallet not initialized'); + }); + + it('throws when SDK returns null fee quote (e.g. SSP rejected the amount)', async () => { + mockWallet.getWithdrawalFeeQuote = vi.fn().mockResolvedValue(null); + await expect(service.getQuote(SPARK_BTC, BITCOIN, '0.001')).rejects.toThrow('Spark withdrawal fee quote unavailable'); + }); + + it('does not move any funds — no withdraw() call during quoting', async () => { + await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + expect(mockWallet.withdraw).not.toHaveBeenCalled(); + }); + + it('throws when a userFee field uses a non-SATOSHI unit (guards against fee mis-pricing)', async () => { + // A BITCOIN-denominated value read as sats would understate the fee by 1e8 and lose user funds. + const badQuote = makeFeeQuoteFixture(); + badQuote.userFeeMedium = { originalValue: 0.0000025, originalUnit: 'BITCOIN' }; + mockWallet.getWithdrawalFeeQuote = vi.fn().mockResolvedValue(badQuote); + await expect(service.getQuote(SPARK_BTC, BITCOIN, '0.001')).rejects.toThrow(/unexpected unit/i); + }); + + it('throws when an l1BroadcastFee field uses a non-SATOSHI unit', async () => { + const badQuote = makeFeeQuoteFixture(); + badQuote.l1BroadcastFeeMedium = { originalValue: 0.00002, originalUnit: 'MILLIBITCOIN' }; + mockWallet.getWithdrawalFeeQuote = vi.fn().mockResolvedValue(badQuote); + await expect(service.getQuote(SPARK_BTC, BITCOIN, '0.001')).rejects.toThrow(/unexpected unit/i); + }); + + it('throws when a fee field is missing from the SDK quote (no silent zero-fee)', async () => { + const badQuote = makeFeeQuoteFixture(); + delete badQuote.userFeeMedium; + mockWallet.getWithdrawalFeeQuote = vi.fn().mockResolvedValue(badQuote); + await expect(service.getQuote(SPARK_BTC, BITCOIN, '0.001')).rejects.toThrow(/missing/i); + }); + + it('sets expiresAt from the SDK fee quote, not a fixed 60s window', async () => { + const sdkExpiry = new Date(Date.now() + 280_000).toISOString(); + const fq = makeFeeQuoteFixture(); + fq.expiresAt = sdkExpiry; + mockWallet.getWithdrawalFeeQuote = vi.fn().mockResolvedValue(fq); + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + expect(quote.expiresAt).toBe(Math.floor(new Date(sdkExpiry).getTime() / 1000)); + }); + + it('falls back to the staging TTL when the SDK quote expiry is unparseable', async () => { + const fq = makeFeeQuoteFixture(); + fq.expiresAt = 'not-a-date'; + mockWallet.getWithdrawalFeeQuote = vi.fn().mockResolvedValue(fq); + const before = Math.floor(Date.now() / 1000); + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + // PENDING_QUOTE_TTL is 5 minutes — the quote should advertise that, not a stale 60s. + expect(quote.expiresAt).toBeGreaterThanOrEqual(before + 5 * 60); + expect(quote.expiresAt).toBeLessThanOrEqual(before + 5 * 60 + 5); + }); + }); + + describe('executeTransfer', () => { + it('stages params in memory without calling withdraw() — funds do not move yet', async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + mockWallet.withdraw.mockClear(); + + const execution = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + + expect(execution.type).toBe(EXECUTION_SPARK_EXIT); + expect(execution.status).toBe('pending'); + expect(execution.depositAddress).toBeUndefined(); + expect(execution.settleAddress).toBe(USER_BTC_ADDR); + expect(execution.serviceName).toBe('SparkExit'); + expect((execution as SparkExitExecution).coopExitRequestId).toBe(''); + expect((execution as SparkExitExecution).exitSpeed).toBe('MEDIUM'); + + // Hard safety invariant: no SDK withdraw call until executeInstantSwap. + expect(mockWallet.withdraw).not.toHaveBeenCalled(); + }); + + it('throws when settleAddress is empty (otherwise the SDK throws a less helpful error later)', async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + await expect(service.executeTransfer(quote, 0, '')).rejects.toThrow('Bitcoin destination address is required'); + }); + + it('rejects an unknown / expired quote ID', async () => { + const fakeQuote: TransferQuote = { + id: 'never-issued', + sendAsset: SPARK_BTC, + receiveAsset: BITCOIN, + sendAmount: '0.001', + receiveAmount: '0.0009775', + rate: '1 BTC = 1 BTC', + fee: '0.0000225', + feeTicker: 'BTC', + estimatedTime: 1800, + expiresAt: Math.floor(Date.now() / 1000) + 60, + serviceName: 'SparkExit', + }; + await expect(service.executeTransfer(fakeQuote, 0, USER_BTC_ADDR)).rejects.toThrow('Quote not found or expired'); + }); + + it('consumes the quote — re-using it fails on the second call', async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + await service.executeTransfer(quote, 0, USER_BTC_ADDR); + await expect(service.executeTransfer(quote, 0, USER_BTC_ADDR)).rejects.toThrow('Quote not found or expired'); + }); + + // The SDK's feeQuoteId is bound to the wallet that produced it. If the user switches accounts + // between getQuote and Continue, the staged feeQuoteId belongs to the wrong wallet. We must + // reject loudly rather than carrying a cross-account feeQuoteId into withdraw(). + it('rejects with a clear "re-quote" error when the account changed since the quote', async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); // staged on account 0 + + service.setCurrentAccountNumber(1); // user switched accounts before tapping Continue + await expect(service.executeTransfer(quote, 1, USER_BTC_ADDR)).rejects.toThrow('Account changed since the quote'); + + // The quote must also be consumed so a subsequent attempt fresh-quote, not retry-the-mismatch. + await expect(service.executeTransfer(quote, 1, USER_BTC_ADDR)).rejects.toThrow('Quote not found or expired'); + }); + }); + + describe('executeInstantSwap', () => { + it('calls SDK withdraw with the user’s real BTC address and the staged fee quote ID', async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + + const committed = await service.executeInstantSwap(pending.id); + + expect(mockWallet.withdraw).toHaveBeenCalledWith({ + onchainAddress: USER_BTC_ADDR, // real address — NOT the placeholder used at quote time + exitSpeed: 'MEDIUM', + amountSats: 100_000, + feeQuoteId: 'fee-quote-abc', + feeAmountSats: 2250, + deductFeeFromWithdrawalAmount: true, + }); + + expect(committed.type).toBe(EXECUTION_SPARK_EXIT); + expect(committed.status).toBe('pending'); // SDK returned INITIATED + expect((committed as SparkExitExecution).coopExitRequestId).toBe('coop-exit-req-1'); + expect(committed.id).toBe(pending.id); + }); + + it('throws for unknown execution id', async () => { + await expect(service.executeInstantSwap('unknown')).rejects.toThrow('No pending exit found'); + }); + + it('cannot be replayed — second call is rejected (double-tap safety)', async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + + await service.executeInstantSwap(pending.id); + await expect(service.executeInstantSwap(pending.id)).rejects.toThrow('No pending exit found'); + expect(mockWallet.withdraw).toHaveBeenCalledTimes(1); + }); + + it('surfaces SDK null return as a clear error rather than silently dropping the operation', async () => { + mockWallet.withdraw = vi.fn().mockResolvedValue(null); + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + await expect(service.executeInstantSwap(pending.id)).rejects.toThrow('SSP returned no exit request'); + }); + + it('maps SDK status → TransferStatus and propagates coopExitTxid when present', async () => { + const cases: [string, string][] = [ + ['INITIATED', 'pending'], + ['INBOUND_TRANSFER_CHECKED', 'pending'], + ['TX_SIGNED', 'pending'], + ['TX_BROADCASTED', 'confirming'], + ['WAITING_ON_TX_CONFIRMATIONS', 'confirming'], + ['SUCCEEDED', 'completed'], + ['EXPIRED', 'expired'], + ['FAILED', 'failed'], + ]; + + for (const [sdkStatus, expected] of cases) { + const txid = sdkStatus === 'TX_BROADCASTED' ? 'abc123' : ''; + mockWallet.withdraw = vi.fn().mockResolvedValue({ + id: `req-${sdkStatus}`, + status: sdkStatus, + coopExitTxid: txid, + }); + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + const committed = await service.executeInstantSwap(pending.id); + expect(committed.status, `sdk status ${sdkStatus}`).toBe(expected); + expect((committed as SparkExitExecution).coopExitRequestId, `req id for ${sdkStatus}`).toBe(`req-${sdkStatus}`); + // coopExitTxid: empty string from SDK should become undefined (downstream code uses truthiness checks). + expect((committed as SparkExitExecution).coopExitTxid, `txid for ${sdkStatus}`).toBe(txid ? txid : undefined); + } + }); + + // ─── Fund-safety: account race between stage and confirm ─────────────────────────────────── + // The reviewer flagged that the previous implementation resolved the wallet via + // `this.currentAccountNumber`, which is mutated externally on every account switch. If the user + // staged on account 0 and switched to account 1 before tapping Confirm, funds would be debited + // from the WRONG wallet while the persisted row recorded account 0. The fix wires + // `executeInstantSwap` to resolve the wallet from `params.accountNumber`. This test would have + // caught that bug, and locks the invariant in for future refactors. + it('withdraws from the STAGED account, not the currently active account', async () => { + const walletAcct0 = makeMockWallet(); + const walletAcct1 = makeMockWallet(); + const walletsByAccount = new Map>([ + [0, walletAcct0], + [1, walletAcct1], + ]); + const multiAcctService = new SparkExitTransferService(storage as any, (acct) => walletsByAccount.get(acct) as any); + + // Quote + stage on account 0. + multiAcctService.setCurrentAccountNumber(0); + const quote = await multiAcctService.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await multiAcctService.executeTransfer(quote, 0, USER_BTC_ADDR); + + // User switches to account 1 before tapping Confirm. + multiAcctService.setCurrentAccountNumber(1); + + const committed = await multiAcctService.executeInstantSwap(pending.id); + + // The withdraw() must have hit account 0's wallet (the staged one), NOT account 1's. + expect(walletAcct0.withdraw).toHaveBeenCalledTimes(1); + expect(walletAcct1.withdraw).not.toHaveBeenCalled(); + expect((committed as SparkExitExecution).accountNumber).toBe(0); + }); + + it('throws when the staged account no longer has an initialized wallet', async () => { + const walletAcct0 = makeMockWallet(); + const walletsByAccount = new Map>([[0, walletAcct0]]); + const restrictedService = new SparkExitTransferService(storage as any, (acct) => walletsByAccount.get(acct) as any); + + restrictedService.setCurrentAccountNumber(0); + const quote = await restrictedService.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await restrictedService.executeTransfer(quote, 0, USER_BTC_ADDR); + + // Simulate the staged account's wallet being evicted between stage and confirm. + walletsByAccount.delete(0); + + await expect(restrictedService.executeInstantSwap(pending.id)).rejects.toThrow('Spark wallet for account 0 is not initialized'); + expect(walletAcct0.withdraw).not.toHaveBeenCalled(); + }); + + // ─── Fund-safety: persist BEFORE returning ───────────────────────────────────────────────── + // The reviewer flagged that the previous version popped pendingExits and returned WITHOUT + // persisting. If the caller's subsequent `commitTransfer` threw, the `coopExitRequestId` was + // lost forever. The fix persists inline. + it('persists the execution inside executeInstantSwap, before returning (caller does NOT need to commit)', async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + + // Clear the setItem spy's call history so we can isolate writes done by executeInstantSwap. + storage.setItem.mockClear(); + + const committed = await service.executeInstantSwap(pending.id); + + // The method MUST have written to storage on its own. If a future refactor moves persist + // back out to the caller, this assertion fails — even though the storage map would still + // be empty (because we never called commitTransfer in this test). + expect(storage.setItem, 'executeInstantSwap must persist internally').toHaveBeenCalledWith(STORAGE_KEY_SPARK_EXIT_TRANSFERS, expect.any(String)); + + const raw = storage.map.get(STORAGE_KEY_SPARK_EXIT_TRANSFERS); + const parsed = JSON.parse(raw!); + expect(parsed).toHaveLength(1); + expect(parsed[0].execution.id).toBe(committed.id); + expect(parsed[0].execution.coopExitRequestId).toBe('coop-exit-req-1'); + // Asserting the persisted shape would survive a JSON.stringify round-trip — guards against + // someone accidentally persisting a non-plain object (Map/class instance) that fails reload. + expect(parsed[0].execution.type).toBe(EXECUTION_SPARK_EXIT); + }); + + it("caller's subsequent commitTransfer is idempotent (upsert by id, no duplicate row)", async () => { + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + const committed = await service.executeInstantSwap(pending.id); + + await service.commitTransfer(committed); // simulate confirm.tsx's redundant commit + + const parsed = JSON.parse(storage.map.get(STORAGE_KEY_SPARK_EXIT_TRANSFERS)!); + expect(parsed).toHaveLength(1); // not 2 — upsert + }); + + // ─── Concurrent-call safety: in-flight guard ─────────────────────────────────────────────── + // A double-tap on the Confirm button (or a re-render that fires the handler twice) must NOT + // enter the SDK's `withdraw()` twice — that would produce two cooperative exits. + it('rejects a second executeInstantSwap call FAST while the first is still in-flight (no double-broadcast, no hang)', async () => { + // Make the first withdraw() hang on a manual promise so we can issue a second call concurrently. + let release!: (v: any) => void; + const hang = new Promise((resolve) => { + release = resolve; + }); + mockWallet.withdraw = vi.fn().mockReturnValue(hang); + + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + + const first = service.executeInstantSwap(pending.id); // intentionally not awaited + // Yield a microtask so the first call enters the try block and marks in-flight. + await Promise.resolve(); + + // The second call MUST reject synchronously (before reaching withdraw()), NOT hang waiting + // on the SDK. We race the call against a 250ms timeout to catch both possible regressions: + // - guard removed: call enters withdraw() and awaits the hang → timeout wins → fail + // - wrong error thrown: assertion fails on the message + // Without the timeout race, removing the guard would just hang the suite indefinitely. + const second = service.executeInstantSwap(pending.id); + const outcome = await Promise.race([ + second.then( + () => ({ kind: 'resolved' as const }), + (e: Error) => ({ kind: 'rejected' as const, message: e.message }) + ), + new Promise<{ kind: 'timeout' }>((r) => setTimeout(() => r({ kind: 'timeout' }), 250)), + ]); + + expect(outcome.kind, 'second call must reject synchronously, not hang on SDK').toBe('rejected'); + expect((outcome as { kind: 'rejected'; message: string }).message).toContain('Withdrawal already in progress'); + + // Resolve the hanging SDK call so the first promise can settle without leaking. + release({ id: 'coop-exit-req-1', status: 'INITIATED', coopExitTxid: '' }); + await first; + + // The killer assertion: withdraw() was called exactly once across two executeInstantSwap calls. + expect(mockWallet.withdraw).toHaveBeenCalledTimes(1); + }); + + // ─── Conservative cleanup on SDK error ──────────────────────────────────────────────────── + // SDK errors are ambiguous (SSP may or may not have committed). To prevent a UI-driven retry + // from creating a second cooperative exit, we pop pendingExits even on error. The user must + // re-quote to retry. + it('pops pendingExits on SDK error, so a retry surfaces "No pending exit found" rather than re-broadcasting', async () => { + mockWallet.withdraw = vi.fn().mockRejectedValue(new Error('SSP timed out')); + + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + + await expect(service.executeInstantSwap(pending.id)).rejects.toThrow('SSP timed out'); + + // Retry must not call withdraw() again — the user must re-quote instead. + await expect(service.executeInstantSwap(pending.id)).rejects.toThrow('No pending exit found'); + expect(mockWallet.withdraw).toHaveBeenCalledTimes(1); + }); + + // ─── Edge case: persist failure after successful withdraw ────────────────────────────────── + // Defense-in-depth: even though we log loudly and rethrow, the SDK has committed. Verify that + // the in-flight guard is cleared on this branch too (otherwise a fresh re-quote→re-confirm + // would be blocked). + it('clears in-flight marker when persist fails post-withdraw, so a fresh quote-cycle can proceed', async () => { + // First call: storage.setItem throws on commit. + const quote = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending = await service.executeTransfer(quote, 0, USER_BTC_ADDR); + + // Make setItem throw exactly once so persist-after-withdraw fails. + const realSetItem = storage.setItem.getMockImplementation()!; + let throwOnce = true; + storage.setItem.mockImplementation(async (k: string, v: string) => { + if (throwOnce) { + throwOnce = false; + throw new Error('AsyncStorage write failed'); + } + return realSetItem(k, v); + }); + + await expect(service.executeInstantSwap(pending.id)).rejects.toThrow('AsyncStorage write failed'); + + // A second, fresh cycle must not be blocked by a leaked in-flight marker. + mockWallet.withdraw.mockClear(); + const quote2 = await service.getQuote(SPARK_BTC, BITCOIN, '0.001'); + const pending2 = await service.executeTransfer(quote2, 0, USER_BTC_ADDR); + await expect(service.executeInstantSwap(pending2.id)).resolves.toBeDefined(); + }); + }); + + describe('commitTransfer + getOngoingTransfers', () => { + function makePersistedExecution(overrides: Partial = {}): SparkExitExecution { + const now = Math.floor(Date.now() / 1000); + return { + type: EXECUTION_SPARK_EXIT, + id: 'spark-exit-fixture', + status: 'pending', + sendAmount: '0.001', + receiveAmount: '0.0009775', + sendAsset: SPARK_BTC, + receiveAsset: BITCOIN, + createdAt: now, + updatedAt: now, + settleAddress: USER_BTC_ADDR, + accountNumber: 0, + serviceName: 'SparkExit', + coopExitRequestId: 'coop-exit-req-1', + exitSpeed: 'MEDIUM', + ...overrides, + }; + } + + it('persists an execution and upserts on subsequent commits with the same id', async () => { + const exec1 = makePersistedExecution({ status: 'pending' }); + await service.commitTransfer(exec1); + + const exec2 = makePersistedExecution({ status: 'confirming', coopExitTxid: 'L1TX' }); + await service.commitTransfer(exec2); + + const raw = storage.map.get(STORAGE_KEY_SPARK_EXIT_TRANSFERS); + const parsed = JSON.parse(raw!); + expect(parsed).toHaveLength(1); + expect(parsed[0].execution.status).toBe('confirming'); + expect(parsed[0].execution.coopExitTxid).toBe('L1TX'); + }); + + it('opportunistically refreshes non-terminal transfers and persists the new status', async () => { + const exec = makePersistedExecution({ status: 'pending' }); + await service.commitTransfer(exec); + + mockWallet.getCoopExitRequest = vi.fn().mockResolvedValue({ + id: 'coop-exit-req-1', + status: 'TX_BROADCASTED', + coopExitTxid: 'TX_FRESH', + }); + + const transfers = await service.getOngoingTransfers(0); + expect(transfers).toHaveLength(1); + expect(transfers[0].status).toBe('confirming'); + expect((transfers[0] as SparkExitExecution).coopExitTxid).toBe('TX_FRESH'); + + // Persisted, so the next poll won't re-hit the SDK for the same change. + const raw = storage.map.get(STORAGE_KEY_SPARK_EXIT_TRANSFERS); + expect(raw).toContain('TX_FRESH'); + }); + + it('does not refresh terminal transfers (no SDK calls for SUCCEEDED / FAILED)', async () => { + await service.commitTransfer(makePersistedExecution({ status: 'completed', coopExitTxid: 'final' })); + mockWallet.getCoopExitRequest = vi.fn(); + + await service.getOngoingTransfers(0); + expect(mockWallet.getCoopExitRequest).not.toHaveBeenCalled(); + }); + + it('filters returned transfers AND scopes refresh to the requested account (does not poll the SDK for foreign accounts)', async () => { + await service.commitTransfer(makePersistedExecution({ id: 'a', accountNumber: 0, coopExitRequestId: 'req-acct-0' })); + await service.commitTransfer(makePersistedExecution({ id: 'b', accountNumber: 1, coopExitRequestId: 'req-acct-1' })); + + mockWallet.getCoopExitRequest = vi.fn().mockResolvedValue(undefined); + + const acct0 = await service.getOngoingTransfers(0); + expect(acct0.map((t) => t.id)).toEqual(['a']); + // The refresh loop must skip the account-1 row when polling for account 0. + // Without this assertion the filter-only behavior would pass even if refresh had no scoping. + expect(mockWallet.getCoopExitRequest).toHaveBeenCalledTimes(1); + expect(mockWallet.getCoopExitRequest).toHaveBeenCalledWith('req-acct-0'); + + mockWallet.getCoopExitRequest.mockClear(); + + const acct1 = await service.getOngoingTransfers(1); + expect(acct1.map((t) => t.id)).toEqual(['b']); + expect(mockWallet.getCoopExitRequest).toHaveBeenCalledTimes(1); + expect(mockWallet.getCoopExitRequest).toHaveBeenCalledWith('req-acct-1'); + }); + + it('prunes terminal transfers older than 7 days', async () => { + const old = Math.floor(Date.now() / 1000) - 8 * 24 * 60 * 60; + await service.commitTransfer(makePersistedExecution({ id: 'old', status: 'completed', createdAt: old })); + await service.commitTransfer(makePersistedExecution({ id: 'fresh', status: 'pending' })); + + const transfers = await service.getOngoingTransfers(0); + expect(transfers.map((t) => t.id)).toEqual(['fresh']); + }); + + it('survives a transient SDK error during refresh — keeps the existing status and retries next poll', async () => { + await service.commitTransfer(makePersistedExecution({ status: 'pending' })); + mockWallet.getCoopExitRequest = vi.fn().mockRejectedValue(new Error('SSP timed out')); + + const transfers = await service.getOngoingTransfers(0); + expect(transfers[0].status).toBe('pending'); + }); + + it('refreshTransferStatus throws for an unknown execution id', async () => { + await service.commitTransfer(makePersistedExecution({ id: 'real-one' })); + await expect(service.refreshTransferStatus('unknown', 0)).rejects.toThrow('SparkExit transfer unknown not found'); + }); + + it('commitTransfer is a no-op for executions owned by a different service (type guard)', async () => { + // Manager dispatches commitTransfer to ALL services; ours must ignore non-SparkExit rows. + const foreignExecution = { + type: 'deposit-address' as const, // EXECUTION_DEPOSIT — owned by NativeDeposit / SideShift / etc. + id: 'foreign-1', + status: 'pending' as const, + sendAmount: '0.01', + receiveAmount: '0.01', + sendAsset: 'native:bitcoin' as const, + receiveAsset: 'native:spark' as const, + createdAt: 0, + updatedAt: 0, + accountNumber: 0, + serviceName: 'NativeDeposit', + depositAddress: 'bc1q...', + }; + + storage.setItem.mockClear(); + await service.commitTransfer(foreignExecution as any); + + // Must not have written anything to OUR storage key. + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storage.map.get(STORAGE_KEY_SPARK_EXIT_TRANSFERS)).toBeUndefined(); + }); + }); + + describe('getTimelineSteps + getTrackingUrl', () => { + function exec(overrides: Partial): SparkExitExecution { + return { + type: EXECUTION_SPARK_EXIT, + id: 'x', + status: 'pending', + sendAmount: '0.001', + receiveAmount: '0.0009775', + sendAsset: SPARK_BTC, + receiveAsset: BITCOIN, + createdAt: 0, + updatedAt: 0, + accountNumber: 0, + serviceName: 'SparkExit', + coopExitRequestId: 'r', + exitSpeed: 'MEDIUM', + ...overrides, + }; + } + + it('returns 3 timeline steps with correct active/completed states', () => { + const steps = service.getTimelineSteps(exec({ status: 'confirming', coopExitTxid: 'ABCDEF1234567890' })); + expect(steps).toHaveLength(3); + expect(steps[0].status).toBe('completed'); // exit initiated + expect(steps[1].status).toBe('active'); // broadcasted, waiting on confirmations + expect(steps[2].status).toBe('upcoming'); // not yet confirmed + // Txid is included in the broadcast step description so the user sees it inline. + expect(steps[1].description).toContain('ABCDEF1234567890'.slice(0, 12)); + }); + + it('omits tracking URL until the L1 txid is known', () => { + expect(service.getTrackingUrl(exec({ status: 'pending' }))).toBeUndefined(); + expect(service.getTrackingUrl(exec({ status: 'confirming', coopExitTxid: 'TX' }))).toContain('TX'); + expect(service.getTrackingUrl(exec({ status: 'confirming', coopExitTxid: 'TX' }))).toContain('/tx/'); + }); + + it('returns terminal state for completed status (step 3 done) and surfaces the L1 txid description on step 2', () => { + const steps = service.getTimelineSteps(exec({ status: 'completed', coopExitTxid: 'FINALTX567890ABC' })); + expect(steps[0].status).toBe('completed'); + expect(steps[1].status).toBe('completed'); + expect(steps[2].status).toBe('completed'); + expect(steps[1].description).toContain('FINALTX56789'); + }); + + it('marks only step 1 active while still in initiated/pending state with no L1 txid', () => { + const steps = service.getTimelineSteps(exec({ status: 'pending' })); + expect(steps[0].status).toBe('completed'); + expect(steps[1].status).toBe('upcoming'); + expect(steps[2].status).toBe('upcoming'); + // Description for step 2 must clearly indicate we're still waiting. + expect(steps[1].description.toLowerCase()).toContain('waiting'); + }); + + // ─── Type-guard coverage ────────────────────────────────────────────────────────────────── + // Manager dispatches getTimelineSteps / getTrackingUrl across all services. Each service + // must ignore executions it doesn't own. Removing those guards must be a test failure. + it('getTimelineSteps returns [] for non-SparkExit executions', () => { + const foreign: any = { + type: 'deposit-address', + id: 'x', + status: 'pending', + sendAmount: '0', + receiveAmount: '0', + sendAsset: SPARK_BTC, + receiveAsset: BITCOIN, + createdAt: 0, + updatedAt: 0, + accountNumber: 0, + serviceName: 'NativeDeposit', + }; + expect(service.getTimelineSteps(foreign)).toEqual([]); + }); + + it('getTrackingUrl returns undefined for non-SparkExit executions even when they carry a txid', () => { + const foreign: any = { + type: 'deposit-address', + id: 'x', + status: 'pending', + sendAmount: '0', + receiveAmount: '0', + sendAsset: SPARK_BTC, + receiveAsset: BITCOIN, + createdAt: 0, + updatedAt: 0, + accountNumber: 0, + serviceName: 'NativeDeposit', + depositTxid: 'NOT_OURS', + }; + expect(service.getTrackingUrl(foreign)).toBeUndefined(); + }); + }); +}); diff --git a/shared/types/IStorage.ts b/shared/types/IStorage.ts index 2d589838..f7aa3f1b 100644 --- a/shared/types/IStorage.ts +++ b/shared/types/IStorage.ts @@ -12,6 +12,7 @@ export const STORAGE_KEY_NATIVE_DEPOSIT_TRANSFERS = 'STORAGE_KEY_NATIVE_DEPOSIT_ export const STORAGE_KEY_GARDEN_TRANSFERS = 'STORAGE_KEY_GARDEN_TRANSFERS'; export const STORAGE_KEY_SYMBIOSIS_TRANSFERS = 'STORAGE_KEY_SYMBIOSIS_TRANSFERS'; export const STORAGE_KEY_FLASHNET_TRANSFERS = 'STORAGE_KEY_FLASHNET_TRANSFERS'; +export const STORAGE_KEY_SPARK_EXIT_TRANSFERS = 'STORAGE_KEY_SPARK_EXIT_TRANSFERS'; export const STORAGE_KEY_SPARK_REFUNDED_DEPOSITS = 'STORAGE_KEY_SPARK_REFUNDED_DEPOSITS'; export const STORAGE_KEY_SPARK_LN_INVOICE_IDS = 'STORAGE_KEY_SPARK_LN_INVOICE_IDS'; diff --git a/shared/types/transfer.ts b/shared/types/transfer.ts index cd52fb2b..4cf57a20 100644 --- a/shared/types/transfer.ts +++ b/shared/types/transfer.ts @@ -95,7 +95,8 @@ export interface TimelineStep { export const EXECUTION_DEPOSIT = 'deposit-address' as const; export const EXECUTION_CLAIM = 'native-claim' as const; export const EXECUTION_INSTANT = 'instant' as const; -export type TransferExecutionType = typeof EXECUTION_DEPOSIT | typeof EXECUTION_CLAIM | typeof EXECUTION_INSTANT; +export const EXECUTION_SPARK_EXIT = 'spark-exit' as const; +export type TransferExecutionType = typeof EXECUTION_DEPOSIT | typeof EXECUTION_CLAIM | typeof EXECUTION_INSTANT | typeof EXECUTION_SPARK_EXIT; interface BaseTransferExecution { /** Discriminant: determines which execution variant this is */ @@ -163,7 +164,22 @@ export interface InstantSwapExecution extends BaseTransferExecution { type: typeof EXECUTION_INSTANT; } -export type TransferExecution = DepositAddressExecution | NativeClaimExecution | InstantSwapExecution; +/** + * Spark → Bitcoin cooperative exit. Funds commit at `executeInstantSwap` time (the + * Spark SDK `withdraw()` call), then the SSP signs and broadcasts an L1 transaction + * asynchronously. Status is refreshed by polling the SSP via `getCoopExitRequest`. + */ +export interface SparkExitExecution extends BaseTransferExecution { + type: typeof EXECUTION_SPARK_EXIT; + /** SSP-side request ID returned by `withdraw()`. Used to poll status via `getCoopExitRequest`. */ + coopExitRequestId: string; + /** Bitcoin L1 txid of the cooperative exit transaction. Populated once the SSP broadcasts it. */ + coopExitTxid?: string; + /** Exit speed the user requested ('FAST' | 'MEDIUM' | 'SLOW'). Determines the fee tier. */ + exitSpeed: 'FAST' | 'MEDIUM' | 'SLOW'; +} + +export type TransferExecution = DepositAddressExecution | NativeClaimExecution | InstantSwapExecution | SparkExitExecution; /** Collect all on-chain txids for a transfer (for deduplication in transaction history) */ export function getRelatedTxids(exec: TransferExecution): string[] { @@ -171,6 +187,7 @@ export function getRelatedTxids(exec: TransferExecution): string[] { if (exec.depositTxid) txids.push(exec.depositTxid); if (exec.type === EXECUTION_CLAIM && exec.claimTxid) txids.push(exec.claimTxid); if (exec.type === EXECUTION_CLAIM && exec.receiveTransferId) txids.push(exec.receiveTransferId); + if (exec.type === EXECUTION_SPARK_EXIT && exec.coopExitTxid) txids.push(exec.coopExitTxid); return txids; }