From 597fcca7b72ce3971889c0ec7fe4bfc6f7702fa3 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Wed, 15 Apr 2026 20:02:56 +0100 Subject: [PATCH] feat: add getSendQuote/executeSendQuote to Ark, Spark, Stacks --- .agents/swap.md | 19 ++- mobile/app/(tabs)/index.tsx | 2 +- mobile/app/BiometricLogin.tsx | 4 +- mobile/app/Home.tsx | 5 +- mobile/app/SendAccountBased.tsx | 2 +- mobile/app/SendBtc.tsx | 2 +- mobile/app/SendLightning.tsx | 2 +- mobile/app/SendLiquid.tsx | 2 +- mobile/app/SendNft.tsx | 2 +- mobile/app/SendTokenStacks.tsx | 2 +- mobile/app/SwapXArkClaim.tsx | 2 +- mobile/app/UnlockPassword.tsx | 2 +- mobile/app/index.tsx | 2 +- mobile/app/onboarding/create-password.tsx | 2 +- mobile/app/onboarding/tos.tsx | 2 +- mobile/app/send/send-confirm-lightning.tsx | 2 +- mobile/app/send/send-confirm.tsx | 2 +- mobile/app/transfer/confirm.tsx | 8 +- shared/class/evm-wallet.ts | 1 + shared/class/wallets/ark-wallet.ts | 33 +++- shared/class/wallets/breez-wallet.ts | 1 + shared/class/wallets/spark-wallet.ts | 34 +++- shared/class/wallets/stacks-wallet.ts | 184 +++++++++++---------- shared/class/wallets/watch-only-wallet.ts | 1 + shared/hooks/useTokenDiscovery.ts | 1 - shared/hooks/useYieldDiscovery.ts | 2 +- shared/tests/unit-vi/ark-wallet.test.ts | 81 ++++++++- shared/tests/unit-vi/spark-wallet.test.ts | 83 +++++++++- shared/tests/unit-vi/stacks-wallet.test.ts | 144 +++++++++++++++- shared/types/send-quote.ts | 9 +- 30 files changed, 517 insertions(+), 121 deletions(-) diff --git a/.agents/swap.md b/.agents/swap.md index 99c650c83..6e23b15d7 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,6 +33,7 @@ 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?` @@ -42,6 +45,7 @@ getTrackingUrl?(execution): string | undefined ## 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 +55,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 +65,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,6 +73,7 @@ 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**: Instant atomic swap via `@flashnet/sdk`. No deposit address. Executes atomically in `executeTransfer()`. - **API**: `FlashnetClient.simulateSwap()` for quotes, `executeSwap()` for execution @@ -74,6 +81,7 @@ getTrackingUrl?(execution): string | undefined - No tracking URL (instant) ### 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`) @@ -86,6 +94,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 @@ -95,12 +104,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. 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 >" @@ -109,21 +120,27 @@ 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` - `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` +- **Implementations**: `EvmWallet`, `BreezWallet`, `WatchOnlyWallet` (Bitcoin), `ArkWallet`, `SparkWallet`, `StacksWallet` +- Ark/Spark report `fee='0'` — their SDKs do not expose a pre-broadcast fee estimator; Bitcoin/EVM/Breez/Stacks report real fees. Stacks uses `getFee(transaction.auth)` (microSTX, feeTicker `STX`, `feeDecimals: 6`); others report in their native ticker. The `feeDecimals` field on the quote tells consumers how to scale `fee` for display. Stacks rebuilds the signed tx at execute time so the baked-in nonce cannot go stale between quote display and broadcast. ## 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` diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index cf78a4fab..83af302d4 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import { Redirect } from 'expo-router'; export default function TabsIndex() { - return ; + return ; } diff --git a/mobile/app/BiometricLogin.tsx b/mobile/app/BiometricLogin.tsx index 6e68f1258..3f72aff17 100644 --- a/mobile/app/BiometricLogin.tsx +++ b/mobile/app/BiometricLogin.tsx @@ -36,7 +36,7 @@ export default function BiometricLoginScreen({ autoTrigger = false }: BiometricL isBiometricEnabled, }); - router.replace('/(tabs)/home'); + router.replace('/home'); } }, [isBiometricEnabled, router]); @@ -107,7 +107,7 @@ export default function BiometricLoginScreen({ autoTrigger = false }: BiometricL if (router.canDismiss()) { router.dismiss(); } else { - router.replace('/(tabs)/home'); + router.replace('/home'); } } else { const error = 'error' in result ? result.error : 'unknown'; diff --git a/mobile/app/Home.tsx b/mobile/app/Home.tsx index afa10bb11..12fa14759 100644 --- a/mobile/app/Home.tsx +++ b/mobile/app/Home.tsx @@ -3,7 +3,6 @@ import { Image } from 'expo-image'; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Dimensions, RefreshControl, RefreshControlProps, StyleSheet, View } from 'react-native'; -import { BlurTargetView } from 'expo-blur'; import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; @@ -299,7 +298,7 @@ export default function Home() { {/* Modal: scroll + BlurTarget FIRST (Expo: BlurView that uses blurTarget must mount after the target). Header overlays on top. */} - + }> {/* Network Selector */} @@ -348,7 +347,7 @@ export default function Home() { - + {/* Invisible Settings Button for Maestro Testing */} diff --git a/mobile/app/SendAccountBased.tsx b/mobile/app/SendAccountBased.tsx index 42236f86f..081ac76e4 100644 --- a/mobile/app/SendAccountBased.tsx +++ b/mobile/app/SendAccountBased.tsx @@ -108,7 +108,7 @@ const SendAccountBased = () => { Transaction Sent! - router.replace('/(tabs)/home')}> + router.replace('/home')}> Back to Wallet diff --git a/mobile/app/SendBtc.tsx b/mobile/app/SendBtc.tsx index 999a2a67f..7d28e6a6a 100644 --- a/mobile/app/SendBtc.tsx +++ b/mobile/app/SendBtc.tsx @@ -206,7 +206,7 @@ const SendBtc: React.FC = () => { const handleBack = () => { if (xArkSwapTo) setNetwork(xArkSwapTo); - router.replace('/(tabs)/home'); + router.replace('/home'); }; if (isSuccess) { diff --git a/mobile/app/SendLightning.tsx b/mobile/app/SendLightning.tsx index 0075f63a3..b25e791f0 100644 --- a/mobile/app/SendLightning.tsx +++ b/mobile/app/SendLightning.tsx @@ -255,7 +255,7 @@ const SendLightning: React.FC = () => { Payment Sent! {amountToSend ? formatBalance(amountToSend, 8, 8) : ''} sats - router.replace('/(tabs)/home')}> + router.replace('/home')}> Back to Wallet diff --git a/mobile/app/SendLiquid.tsx b/mobile/app/SendLiquid.tsx index 31d9037f8..ceb84ac6d 100644 --- a/mobile/app/SendLiquid.tsx +++ b/mobile/app/SendLiquid.tsx @@ -196,7 +196,7 @@ const SendLiquid = () => { Transaction Sent! - router.replace('/(tabs)/home')}> + router.replace('/home')}> Back to Wallet diff --git a/mobile/app/SendNft.tsx b/mobile/app/SendNft.tsx index 2c54fb94d..16559b122 100644 --- a/mobile/app/SendNft.tsx +++ b/mobile/app/SendNft.tsx @@ -171,7 +171,7 @@ export default function SendNft() { Tx: {txid} - router.replace('/(tabs)/home')} activeOpacity={0.85} testID="send-nft-back-button"> + router.replace('/home')} activeOpacity={0.85} testID="send-nft-back-button"> Back to Wallet diff --git a/mobile/app/SendTokenStacks.tsx b/mobile/app/SendTokenStacks.tsx index fc5afb31c..6378adfdc 100644 --- a/mobile/app/SendTokenStacks.tsx +++ b/mobile/app/SendTokenStacks.tsx @@ -164,7 +164,7 @@ export default function SendTokenStacksScreen() { }, [balance, token]); const resetToInit = () => { - router.replace('/(tabs)/home'); + router.replace('/home'); }; // Validate required parameters after all hooks diff --git a/mobile/app/SwapXArkClaim.tsx b/mobile/app/SwapXArkClaim.tsx index ff9860044..641f9c8ae 100644 --- a/mobile/app/SwapXArkClaim.tsx +++ b/mobile/app/SwapXArkClaim.tsx @@ -105,7 +105,7 @@ const SwapXArkClaim = () => { }; const handleBack = () => { - router.replace('/(tabs)/home'); + router.replace('/home'); }; const disabled = isClaiming || isRefunding; diff --git a/mobile/app/UnlockPassword.tsx b/mobile/app/UnlockPassword.tsx index 848ccb91b..70c305d83 100644 --- a/mobile/app/UnlockPassword.tsx +++ b/mobile/app/UnlockPassword.tsx @@ -74,7 +74,7 @@ export default function UnlockPassword() { // Navigate to home setStep(EStep.READY); - router.replace('/(tabs)/home'); + router.replace('/home'); } catch (error: any) { Alert.alert('Unlock Failed', 'Incorrect password. Please try again.'); } finally { diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 215fb44ac..8d50161ce 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -18,6 +18,6 @@ export default function IndexScreen() { return ; } else { // If the app is ready, redirect to tabs - the protected routes will handle auth - return ; + return ; } } diff --git a/mobile/app/onboarding/create-password.tsx b/mobile/app/onboarding/create-password.tsx index bd953e18e..896362088 100644 --- a/mobile/app/onboarding/create-password.tsx +++ b/mobile/app/onboarding/create-password.tsx @@ -197,7 +197,7 @@ export default function CreatePasswordScreen() { throw new Error(result.message || 'Failed to encrypt wallet'); } - router.replace('/(tabs)/home'); + router.replace('/home'); } catch (error) { console.error('Error encrypting wallet:', error); Alert.alert('Error', 'Failed to create password. Please try again.'); diff --git a/mobile/app/onboarding/tos.tsx b/mobile/app/onboarding/tos.tsx index 6b6c4e360..c362f1f11 100644 --- a/mobile/app/onboarding/tos.tsx +++ b/mobile/app/onboarding/tos.tsx @@ -39,7 +39,7 @@ export default function TermsOfServiceScreen() { // Navigate to the main home screen with onboarding parameter setStep(EStep.READY); - router.replace('/(tabs)/home?fromOnboarding=true'); + router.replace({ pathname: '/home', params: { fromOnboarding: 'true' } }); } catch (error) { console.error('Error accepting terms:', error); Alert.alert('Error', (await getErrorMessage(error)) || 'Failed to accept terms. Please try again.'); diff --git a/mobile/app/send/send-confirm-lightning.tsx b/mobile/app/send/send-confirm-lightning.tsx index db5393782..7c007c71e 100644 --- a/mobile/app/send/send-confirm-lightning.tsx +++ b/mobile/app/send/send-confirm-lightning.tsx @@ -146,7 +146,7 @@ const SendConfirmLightning: React.FC = () => { }; const handleHome = () => { - router.replace('/(tabs)/home'); + router.replace('/home'); }; // Calculate fee from invoice amount diff --git a/mobile/app/send/send-confirm.tsx b/mobile/app/send/send-confirm.tsx index c152bbb9d..2b641f354 100644 --- a/mobile/app/send/send-confirm.tsx +++ b/mobile/app/send/send-confirm.tsx @@ -204,7 +204,7 @@ const SendConfirm: React.FC = ({ ticker, token }) => { }; const handleHome = () => { - router.replace('/(tabs)/home'); + router.replace('/home'); }; const formatAddressWithOpacity = (addr: string) => { diff --git a/mobile/app/transfer/confirm.tsx b/mobile/app/transfer/confirm.tsx index 0c01ab33f..e2e9b19c3 100644 --- a/mobile/app/transfer/confirm.tsx +++ b/mobile/app/transfer/confirm.tsx @@ -181,7 +181,7 @@ export default function TransferConfirm() { await transferService.commitTransfer(execution); setPreparedExecution(undefined); setCommitted(true); - router.replace('/modals/transfer-success'); + router.replace({ pathname: '/modals/transfer-success' }); return; } @@ -192,7 +192,7 @@ export default function TransferConfirm() { await transferService.commitTransfer(completed); setPreparedExecution(undefined); setCommitted(true); - router.replace('/modals/transfer-success'); + router.replace({ pathname: '/modals/transfer-success' }); return; } @@ -211,7 +211,7 @@ export default function TransferConfirm() { setPreparedExecution(undefined); setCommitted(true); - router.replace('/modals/transfer-success'); + router.replace({ pathname: '/modals/transfer-success' }); } catch (e: any) { // If send was already broadcast, update transfer with txid const execution = executionRef.current; @@ -305,7 +305,7 @@ export default function TransferConfirm() { const sendAssetInfo = getAssetInfo(sendAsset); const receiveAssetInfo = getAssetInfo(receiveAsset); const feeDisplay = sendQuote - ? `${new BigNumber(sendQuote.fee).dividedBy(new BigNumber(10).pow(AllNetworkInfos[sendAssetInfo.network].decimals)).toFixed()} ${sendQuote.feeTicker}` + ? `${new BigNumber(sendQuote.fee).dividedBy(new BigNumber(10).pow(sendQuote.feeDecimals)).toFixed()} ${sendQuote.feeTicker}` : quote.feeTicker ? `${quote.fee} ${quote.feeTicker}` : quote.fee; diff --git a/shared/class/evm-wallet.ts b/shared/class/evm-wallet.ts index 7d930d97e..bd2125070 100644 --- a/shared/class/evm-wallet.ts +++ b/shared/class/evm-wallet.ts @@ -286,6 +286,7 @@ export class EvmWallet implements InterfaceSendQuotable { request, fee, feeTicker: AllNetworkInfos[this.network].ticker, + feeDecimals: AllNetworkInfos[this.network].decimals, _prepared: prepared, }; } diff --git a/shared/class/wallets/ark-wallet.ts b/shared/class/wallets/ark-wallet.ts index e375b46cd..65335549f 100644 --- a/shared/class/wallets/ark-wallet.ts +++ b/shared/class/wallets/ark-wallet.ts @@ -15,10 +15,12 @@ import { sleep } from '../../modules/sleep'; import { CommonTokenTransfer, CommonTransaction } from '../../types/common-transaction'; import { NETWORK_ARK, NETWORK_ARK_MUTINYNET } from '../../types/networks'; import { CachedTokenInfo } from '../../types/token-info'; +import { SendQuote, SendQuoteRequest } from '../../types/send-quote'; import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; import { createLightningInvoiceResponse, InterfaceLightningWallet, LightningPaymentLimitsResponse } from './interface-lightning-wallet'; import { InterfaceAccountBasedWallet } from './interface-account-based-wallet'; import { InterfaceCanHaveTokens } from './interface-can-have-tokens'; +import { InterfaceSendQuotable } from './interface-send-quotable'; const bip32 = BIP32Factory(ecc); @@ -407,7 +409,7 @@ class LayerzContractRepository { } } -export class ArkWallet extends AbstractHDElectrumWallet implements InterfaceLightningWallet, InterfaceAccountBasedWallet, InterfaceCanHaveTokens { +export class ArkWallet extends AbstractHDElectrumWallet implements InterfaceLightningWallet, InterfaceAccountBasedWallet, InterfaceCanHaveTokens, InterfaceSendQuotable { private _wallet: Wallet | undefined = undefined; private _arkadeLightning: ArkadeSwaps | undefined = undefined; private _arkServerUrl: string = 'https://mutinynet.arkade.sh'; @@ -627,6 +629,35 @@ export class ArkWallet extends AbstractHDElectrumWallet implements InterfaceLigh }); } + async getSendQuote(request: SendQuoteRequest): Promise { + assert(this._wallet, 'Ark wallet not initialized'); + assert(request.toAddress, 'toAddress is required'); + const amountBig = BigInt(request.amount); + assert(amountBig > 0n, 'amount must be positive'); + assert(amountBig <= BigInt(Number.MAX_SAFE_INTEGER), 'Amount too large'); + + if (request.tokenId) { + const token = this._arkTokenBalances.find((t) => t.id === request.tokenId); + assert(token && token.balance != null, 'token balance is unavailable'); + assert(BigInt(token.balance) >= amountBig, `Insufficient ${token.symbol ?? 'token'} balance`); + } + + return { + request, + fee: '0', + feeTicker: 'BTC', + feeDecimals: 8, + }; + } + + async executeSendQuote(quote: SendQuote, _mnemonic?: string, _accountNumber?: number): Promise { + const { toAddress, amount, tokenId } = quote.request; + if (tokenId) { + return await this.transferToken(tokenId, BigInt(amount), toAddress); + } + return await this.pay(toAddress, Number(amount)); + } + async getOffchainReceiveAddress(): Promise { if (!this._wallet) throw new Error('Ark wallet not initialized'); diff --git a/shared/class/wallets/breez-wallet.ts b/shared/class/wallets/breez-wallet.ts index 7264e460a..14ff40044 100644 --- a/shared/class/wallets/breez-wallet.ts +++ b/shared/class/wallets/breez-wallet.ts @@ -282,6 +282,7 @@ export class BreezWallet implements InterfaceLightningWallet, InterfaceSendQuota request, fee: String(prepareResponse.feesSat), feeTicker: 'L-BTC', + feeDecimals: 8, _prepared: prepareResponse, }; } diff --git a/shared/class/wallets/spark-wallet.ts b/shared/class/wallets/spark-wallet.ts index eb06ea0f9..3b3094456 100644 --- a/shared/class/wallets/spark-wallet.ts +++ b/shared/class/wallets/spark-wallet.ts @@ -10,10 +10,12 @@ import { CommonTokenTransfer, CommonTransaction } from '../../types/common-trans import { NETWORK_BITCOIN, NETWORK_SPARK } from '../../types/networks'; import { CachedTokenInfo, NftInfo } from '../../types/token-info'; import { IStorage, STORAGE_KEY_SPARK_REFUNDED_DEPOSITS } from '../../types/IStorage'; +import { SendQuote, SendQuoteRequest } from '../../types/send-quote'; import { ArkWallet } from './ark-wallet'; import { InterfaceAccountBasedWallet } from './interface-account-based-wallet'; import { InterfaceCanHaveTokens } from './interface-can-have-tokens'; import { createLightningInvoiceResponse, InterfaceLightningWallet, LightningPaymentLimitsResponse } from './interface-lightning-wallet'; +import { InterfaceSendQuotable } from './interface-send-quotable'; import { uint8ArrayToHex, uint8ArrayToString } from '../../modules/uint8array-extras'; import { InterfaceCanHaveNfts } from './interface-can-have-nfts'; @@ -41,7 +43,7 @@ export type StaticDepositQuoteOutput = Awaited>['wallet']; -export class SparkWallet extends ArkWallet implements InterfaceLightningWallet, InterfaceAccountBasedWallet, InterfaceCanHaveTokens, InterfaceCanHaveNfts { +export class SparkWallet extends ArkWallet implements InterfaceLightningWallet, InterfaceAccountBasedWallet, InterfaceCanHaveTokens, InterfaceCanHaveNfts, InterfaceSendQuotable { private _sdkWallet: SparkSDKWallet | undefined = undefined; /** SDK wallets indexed by account number */ private static _sdkWalletsByAccount: Map = new Map(); @@ -473,6 +475,36 @@ export class SparkWallet extends ArkWallet implements InterfaceLightningWallet, return await this._sdkWallet.transferTokens({ receiverSparkAddress, tokenAmount, tokenIdentifier: tokenIdentifier as Bech32mTokenIdentifier }); } + async getSendQuote(request: SendQuoteRequest): Promise { + assert(this._sdkWallet, 'Spark wallet not initialized'); + assert(request.toAddress, 'toAddress is required'); + const amountBig = BigInt(request.amount); + assert(amountBig > 0n, 'amount must be positive'); + + if (request.tokenId) { + const entry = this.tokenBalances.get(request.tokenId as Bech32mTokenIdentifier); + assert(entry, 'token balance is unavailable'); + assert(entry.ownedBalance >= amountBig, `Insufficient ${entry.tokenMetadata.tokenTicker ?? 'token'} balance`); + } else { + assert(amountBig <= BigInt(Number.MAX_SAFE_INTEGER), 'Amount too large'); + } + + return { + request, + fee: '0', + feeTicker: 'BTC', + feeDecimals: 8, + }; + } + + async executeSendQuote(quote: SendQuote, _mnemonic?: string, _accountNumber?: number): Promise { + const { toAddress, amount, tokenId } = quote.request; + if (tokenId) { + return await this.transferToken(tokenId, BigInt(amount), toAddress); + } + return await this.pay(toAddress, Number(amount)); + } + async transferNFT(nft: NftInfo, address: string): Promise { if (!this._sdkWallet) throw new Error('Spark wallet not initialized'); diff --git a/shared/class/wallets/stacks-wallet.ts b/shared/class/wallets/stacks-wallet.ts index 18757f60f..4c07b5422 100644 --- a/shared/class/wallets/stacks-wallet.ts +++ b/shared/class/wallets/stacks-wallet.ts @@ -1,15 +1,28 @@ import assert from 'assert'; import { generateNewAccount, generateWallet, getStxAddress, Wallet as SdkWallet } from '@stacks/wallet-sdk'; import { createClient } from '@stacks/blockchain-api-client'; -import { broadcastTransaction, makeContractCall, makeSTXTokenTransfer, noneCV, SignedTokenTransferOptions, standardPrincipalCV, uintCV, validateStacksAddress } from '@stacks/transactions'; +import { + broadcastTransaction, + getFee, + makeContractCall, + makeSTXTokenTransfer, + noneCV, + SignedTokenTransferOptions, + standardPrincipalCV, + StacksTransactionWire, + uintCV, + validateStacksAddress, +} from '@stacks/transactions'; import { CachedTokenInfo, NftInfo } from '../../types/token-info'; import { CommonTransaction } from '../../types/common-transaction'; import { NETWORK_STACKS } from '../../types/networks'; import { IStorage } from '../../types/IStorage'; +import { SendQuote, SendQuoteRequest } from '../../types/send-quote'; import { InterfaceAccountBasedWallet } from './interface-account-based-wallet'; import { InterfaceCanHaveTokens } from './interface-can-have-tokens'; import { InterfaceCanHaveNfts } from './interface-can-have-nfts'; +import { InterfaceSendQuotable } from './interface-send-quotable'; import { ArkWallet } from './ark-wallet'; const sbtcId = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token'; @@ -18,7 +31,7 @@ const baseUrl = 'https://api.mainnet.hiro.so'; const STORAGE_KEY = 'STACKS_TOKEN_METADATA'; const STORAGE_KEY_NFT = 'STACKS_NFT_METADATA_V2'; -export class StacksWallet extends ArkWallet implements InterfaceAccountBasedWallet, InterfaceCanHaveTokens, InterfaceCanHaveNfts { +export class StacksWallet extends ArkWallet implements InterfaceAccountBasedWallet, InterfaceCanHaveTokens, InterfaceCanHaveNfts, InterfaceSendQuotable { protected _accountNumber: number = 0; private _sdkWallet: SdkWallet | undefined = undefined; public secret: string = ''; @@ -226,34 +239,82 @@ export class StacksWallet extends ArkWallet implements InterfaceAccountBasedWall * sending sBTC, not STX */ async pay(address: string, amount: number): Promise { + assert(Number.isFinite(amount) && amount > 0, 'Amount must be a positive number'); + const { transaction } = await this._buildSendTransaction({ toAddress: address, amount: String(amount) }); + return this._broadcastAndUnwrap(transaction, 'sBTC transfer'); + } + + /** + * sending native coin (STX) + */ + async payStx(address: string, amount: number, memo?: string): Promise { + const { transaction } = await this._buildSendTransaction({ toAddress: address, amount: String(amount), tokenId: 'STX', memo }); + return this._broadcastAndUnwrap(transaction, 'STX transfer'); + } + + // makeContractCall / makeSTXTokenTransfer with our options always produce AuthType.Standard, + // so getFee(auth) below resolves via the Standard branch. + private async _buildSendTransaction(request: SendQuoteRequest): Promise<{ transaction: StacksTransactionWire; fee: bigint }> { assert(this._sdkWallet, 'Stacks wallet is not initialized'); assert(this._sdkWallet.accounts[this._accountNumber], 'Stacks account not found'); - assert(address, 'Recipient address is required'); - assert(Number.isFinite(amount) && amount > 0, 'Amount must be a positive number'); + assert(request.toAddress, 'Recipient address is required'); - // Ensure cached sBTC balance is sufficient - const sbtcTokenId = sbtcId; - const sbtc = this._tokenBalances.find((t) => t.id === sbtcTokenId); - assert(sbtc && sbtc.balance != null, 'sBTC token balance is unavailable'); - const available = BigInt(sbtc.balance); - assert(available >= BigInt(amount), `Insufficient sBTC balance. Have ${available}, need ${BigInt(amount)}`); + const amount = BigInt(request.amount); + assert(amount > 0n, 'Amount must be a positive number'); const senderKey = this._sdkWallet.accounts[this._accountNumber].stxPrivateKey; const senderAddress = await this.getOffchainReceiveAddress(); - const contractAddress = sbtcId.split('.')[0]; - const contractName = 'sbtc-token'; + let transaction: StacksTransactionWire; + + if (!request.tokenId) { + // sBTC (treated as native for this wallet) + const sbtc = this._tokenBalances.find((t) => t.id === sbtcId); + assert(sbtc && sbtc.balance != null, 'sBTC token balance is unavailable'); + assert(BigInt(sbtc.balance) >= amount, `Insufficient sBTC balance. Have ${sbtc.balance}, need ${amount}`); + + transaction = await makeContractCall({ + contractAddress: sbtcId.split('.')[0], + contractName: 'sbtc-token', + functionName: 'transfer', + functionArgs: [uintCV(amount), standardPrincipalCV(senderAddress), standardPrincipalCV(request.toAddress), noneCV()], + senderKey, + network: 'mainnet', + postConditionMode: 'allow', + }); + } else if (request.tokenId === 'STX') { + const txOptions: SignedTokenTransferOptions = { + recipient: request.toAddress, + amount, + senderKey, + network: 'mainnet', + memo: request.memo, + }; + transaction = await makeSTXTokenTransfer(txOptions); + } else { + const tokenBalance = this._tokenBalances.find((t) => t.id === request.tokenId); + assert(tokenBalance && tokenBalance.balance != null, 'token balance is unavailable'); + assert(BigInt(tokenBalance.balance) >= amount, `Insufficient token balance. Have ${tokenBalance.balance}, need ${amount}`); + + const contractAddress = request.tokenId.split('.')[0]; + const contractName = request.tokenId.split('::')[0].split('.')[1]; + assert(contractName, `Incorrect Stacks contract name for token ${request.tokenId}`); + + transaction = await makeContractCall({ + contractAddress, + contractName, + functionName: 'transfer', + functionArgs: [uintCV(amount), standardPrincipalCV(senderAddress), standardPrincipalCV(request.toAddress), noneCV()], + senderKey, + network: 'mainnet', + postConditionMode: 'allow', + }); + } - const transaction = await makeContractCall({ - contractAddress, - contractName, - functionName: 'transfer', - functionArgs: [uintCV(BigInt(amount)), standardPrincipalCV(senderAddress), standardPrincipalCV(address), noneCV()], - senderKey, - network: 'mainnet', - postConditionMode: 'allow', - }); + return { transaction, fee: getFee(transaction.auth) }; + } + private async _broadcastAndUnwrap(transaction: StacksTransactionWire, errorLabel: string): Promise { const broadcastResponse: any = await broadcastTransaction({ transaction }); if (broadcastResponse && typeof broadcastResponse.txid === 'string') { @@ -264,31 +325,24 @@ export class StacksWallet extends ArkWallet implements InterfaceAccountBasedWall return broadcastResponse; } - throw new Error(`Failed to broadcast sBTC transfer: ${JSON.stringify(broadcastResponse)}`); + throw new Error(`Failed to broadcast ${errorLabel}: ${JSON.stringify(broadcastResponse)}`); } - /** - * sending native coin (STX) - */ - async payStx(address: string, amount: number, memo?: string): Promise { - assert(this._sdkWallet, 'Stacks wallet is not initialized'); - assert(this._sdkWallet.accounts[this._accountNumber], 'Stacks account not found'); - - const txOptions: SignedTokenTransferOptions = { - recipient: address, - amount: BigInt(amount), - senderKey: this._sdkWallet.accounts[this._accountNumber].stxPrivateKey, - network: 'mainnet', - memo, - // nonce: 0n, // set a nonce manually if you don't want builder to fetch from a Stacks node - // fee: 200n, // set a tx fee if you don't want the builder to estimate + // The signed tx produced here is discarded — executeSendQuote rebuilds fresh so the baked-in + // nonce cannot go stale between quote display and broadcast. Fee remains a real estimate. + async getSendQuote(request: SendQuoteRequest): Promise { + const { fee } = await this._buildSendTransaction(request); + return { + request, + fee: String(fee), + feeTicker: 'STX', + feeDecimals: 6, }; + } - const transaction = await makeSTXTokenTransfer(txOptions); - - // broadcasting transaction to the specified network - const broadcastResponse = await broadcastTransaction({ transaction }); - return broadcastResponse.txid; + async executeSendQuote(quote: SendQuote, _mnemonic?: string, _accountNumber?: number): Promise { + const { transaction } = await this._buildSendTransaction(quote.request); + return this._broadcastAndUnwrap(transaction, 'transfer'); } async getCommonTransactions(): Promise { @@ -371,50 +425,8 @@ export class StacksWallet extends ArkWallet implements InterfaceAccountBasedWall } async transferToken(tokenId: string, amount: bigint, address: string, memo?: string): Promise { - assert(this._sdkWallet, 'Stacks wallet is not initialized'); - assert(this._sdkWallet.accounts[this._accountNumber], 'Stacks account not found'); - assert(address, 'Recipient address is required'); - assert(amount > 0, `Amount must be a positive number (got ${amount})`); - - if (tokenId === 'STX') { - // its actually a native token - return this.payStx(address, Number(amount), memo); - } - - // Ensure cached balance is sufficient - const tokenBalance = this._tokenBalances.find((t) => t.id === tokenId); - assert(tokenBalance && tokenBalance.balance != null, 'token balance is unavailable'); - const available = BigInt(tokenBalance.balance); - assert(available >= BigInt(amount), `Insufficient token balance. Have ${available}, need ${BigInt(amount)}`); - - const senderKey = this._sdkWallet.accounts[this._accountNumber].stxPrivateKey; - const senderAddress = await this.getOffchainReceiveAddress(); - - const contractAddress = tokenId.split('.')[0]; - const contractName = tokenId.split('::')[0].split('.')[1]; - assert(contractName, `Incorrect Stacks contract name for token ${tokenId}`); - - const transaction = await makeContractCall({ - contractAddress, - contractName, - functionName: 'transfer', - functionArgs: [uintCV(BigInt(amount)), standardPrincipalCV(senderAddress), standardPrincipalCV(address), noneCV()], - senderKey, - network: 'mainnet', - postConditionMode: 'allow', - }); - - const broadcastResponse: any = await broadcastTransaction({ transaction }); - - if (broadcastResponse && typeof broadcastResponse.txid === 'string') { - return broadcastResponse.txid; - } - - if (typeof broadcastResponse === 'string') { - return broadcastResponse; - } - - throw new Error(`Failed to broadcast sBTC transfer: ${JSON.stringify(broadcastResponse)}`); + const { transaction } = await this._buildSendTransaction({ toAddress: address, amount: String(amount), tokenId, memo }); + return this._broadcastAndUnwrap(transaction, 'token transfer'); } async transferNFT(nft: NftInfo, address: string): Promise { diff --git a/shared/class/wallets/watch-only-wallet.ts b/shared/class/wallets/watch-only-wallet.ts index 06fcbbb2a..a7da73fe7 100644 --- a/shared/class/wallets/watch-only-wallet.ts +++ b/shared/class/wallets/watch-only-wallet.ts @@ -351,6 +351,7 @@ export class WatchOnlyWallet extends LegacyWallet implements InterfaceSendQuotab request, fee: String(fee), feeTicker: 'BTC', + feeDecimals: 8, _prepared: { psbt }, }; } diff --git a/shared/hooks/useTokenDiscovery.ts b/shared/hooks/useTokenDiscovery.ts index 028f5e889..24a7d147f 100644 --- a/shared/hooks/useTokenDiscovery.ts +++ b/shared/hooks/useTokenDiscovery.ts @@ -1,7 +1,6 @@ import assert from 'assert'; import useSWR from 'swr'; -import { ArkWallet } from '../class/wallets/ark-wallet'; import { StacksWallet } from '../class/wallets/stacks-wallet'; import { walletCanHaveTokens } from '../class/wallets/interface-can-have-tokens'; import { getTokenList } from '../models/token-list'; diff --git a/shared/hooks/useYieldDiscovery.ts b/shared/hooks/useYieldDiscovery.ts index 26c1b1141..6973f44fb 100644 --- a/shared/hooks/useYieldDiscovery.ts +++ b/shared/hooks/useYieldDiscovery.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useTokenDiscovery } from './useTokenDiscovery'; import { IBackgroundCaller } from '../types/IBackgroundCaller'; import { IStorage } from '../types/IStorage'; -import { NETWORK_BOTANIX, NETWORK_CITREA, NETWORK_SPARK, Networks } from '../types/networks'; +import { NETWORK_BOTANIX, NETWORK_SPARK, Networks } from '../types/networks'; import { CachedTokenInfo } from '@shared/types/token-info'; type YieldTokenDefinition = { diff --git a/shared/tests/unit-vi/ark-wallet.test.ts b/shared/tests/unit-vi/ark-wallet.test.ts index 5539f3241..17eaced5b 100644 --- a/shared/tests/unit-vi/ark-wallet.test.ts +++ b/shared/tests/unit-vi/ark-wallet.test.ts @@ -1,5 +1,5 @@ import assert from 'assert'; -import { test, vi } from 'vitest'; +import { beforeEach, describe, expect, it, test, vi } from 'vitest'; import { ArkTransaction, TxType } from '@arkade-os/sdk'; import { ArkWallet } from '../../class/wallets/ark-wallet'; @@ -79,3 +79,82 @@ test('ark mainnet can getCommonTransactions', async (context) => { }, ]); }); + +describe('ArkWallet getSendQuote / executeSendQuote', () => { + const TO = 'ark1recipientaddress'; + const TOKEN_ID = 'tokenAssetId'; + const TXID = 'arktxid0001'; + + const createWallet = (): ArkWallet => { + const w = new ArkWallet(); + (w as any)._wallet = { + send: vi.fn().mockResolvedValue(TXID), + }; + return w; + }; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('native send: returns fee=0, feeTicker=BTC, feeDecimals=8, and execute calls _wallet.send with amount', async () => { + const w = createWallet(); + + const quote = await w.getSendQuote({ toAddress: TO, amount: '1000' }); + + expect(quote.fee).toBe('0'); + expect(quote.feeTicker).toBe('BTC'); + expect(quote.feeDecimals).toBe(8); + expect(quote.request.toAddress).toBe(TO); + expect(quote.request.amount).toBe('1000'); + + const txid = await w.executeSendQuote(quote); + expect(txid).toBe(TXID); + expect((w as any)._wallet.send).toHaveBeenCalledWith({ address: TO, amount: 1000 }); + }); + + it('token send: checks cached balance and execute calls _wallet.send with assets[]', async () => { + const w = createWallet(); + (w as any)._arkTokenBalances = [{ id: TOKEN_ID, symbol: 'TKN', balance: '5000' }]; + + const quote = await w.getSendQuote({ toAddress: TO, amount: '1000', tokenId: TOKEN_ID }); + expect(quote.fee).toBe('0'); + + const txid = await w.executeSendQuote(quote); + expect(txid).toBe(TXID); + expect((w as any)._wallet.send).toHaveBeenCalledWith({ + address: TO, + assets: [{ assetId: TOKEN_ID, amount: 1000 }], + }); + }); + + it('token send: throws if token balance unavailable', async () => { + const w = createWallet(); + (w as any)._arkTokenBalances = []; + + await expect(w.getSendQuote({ toAddress: TO, amount: '1000', tokenId: TOKEN_ID })).rejects.toThrow(/token balance is unavailable/); + }); + + it('token send: throws if insufficient balance', async () => { + const w = createWallet(); + (w as any)._arkTokenBalances = [{ id: TOKEN_ID, symbol: 'TKN', balance: '500' }]; + + await expect(w.getSendQuote({ toAddress: TO, amount: '1000', tokenId: TOKEN_ID })).rejects.toThrow(/Insufficient TKN balance/); + }); + + it('throws if wallet not initialized', async () => { + const w = new ArkWallet(); + await expect(w.getSendQuote({ toAddress: TO, amount: '1000' })).rejects.toThrow(/not initialized/); + }); + + it('throws on non-positive amount', async () => { + const w = createWallet(); + await expect(w.getSendQuote({ toAddress: TO, amount: '0' })).rejects.toThrow(/amount must be positive/); + }); + + it('throws when native amount exceeds MAX_SAFE_INTEGER', async () => { + const w = createWallet(); + const overflow = String(BigInt(Number.MAX_SAFE_INTEGER) + 1n); + await expect(w.getSendQuote({ toAddress: TO, amount: overflow })).rejects.toThrow(/Amount too large/); + }); +}); diff --git a/shared/tests/unit-vi/spark-wallet.test.ts b/shared/tests/unit-vi/spark-wallet.test.ts index f6f2b5d74..8f6379615 100644 --- a/shared/tests/unit-vi/spark-wallet.test.ts +++ b/shared/tests/unit-vi/spark-wallet.test.ts @@ -1,5 +1,5 @@ import { encodeBech32mTokenIdentifier, encodeSparkAddress } from '@buildonspark/spark-sdk'; -import { describe, it, vi, assert } from 'vitest'; +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; import { SparkWallet } from '../../class/wallets/spark-wallet'; const ownIdentityPublicKey = '036b1448c1b77fea99943c36c4ebed2de121ad98349f249949a1c43817fe26c2e2'; @@ -240,3 +240,84 @@ describe('Spark Wallet', () => { assert.strictEqual(wallet.isAddressValid('spark1'), false); }); }); + +describe('SparkWallet getSendQuote / executeSendQuote', () => { + const TO = 'sp1recipientaddress'; + const TOKEN_ID = 'btkn1tokenid'; + const NATIVE_TXID = 'sparktxid0001'; + const TOKEN_TXID = 'sparktxid0002'; + + const createWallet = (): SparkWallet => { + const w = new SparkWallet(); + (w as any)._sdkWallet = { + transfer: vi.fn().mockResolvedValue({ id: NATIVE_TXID }), + transferTokens: vi.fn().mockResolvedValue(TOKEN_TXID), + }; + return w; + }; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('native send: returns fee=0, feeTicker=BTC, feeDecimals=8, and execute calls _sdkWallet.transfer', async () => { + const w = createWallet(); + + const quote = await w.getSendQuote({ toAddress: TO, amount: '500' }); + + expect(quote.fee).toBe('0'); + expect(quote.feeTicker).toBe('BTC'); + expect(quote.feeDecimals).toBe(8); + expect(quote.request.amount).toBe('500'); + + const txid = await w.executeSendQuote(quote); + expect(txid).toBe(NATIVE_TXID); + expect((w as any)._sdkWallet.transfer).toHaveBeenCalledWith({ receiverSparkAddress: TO, amountSats: 500 }); + }); + + it('token send: checks cached tokenBalances and execute calls _sdkWallet.transferTokens', async () => { + const w = createWallet(); + (w as any).tokenBalances = new Map([[TOKEN_ID, { ownedBalance: 10000n, tokenMetadata: { tokenTicker: 'USDB', tokenName: 'USD Bond', decimals: 6 } }]]); + + const quote = await w.getSendQuote({ toAddress: TO, amount: '1000', tokenId: TOKEN_ID }); + expect(quote.fee).toBe('0'); + + const txid = await w.executeSendQuote(quote); + expect(txid).toBe(TOKEN_TXID); + expect((w as any)._sdkWallet.transferTokens).toHaveBeenCalledWith({ + receiverSparkAddress: TO, + tokenAmount: 1000n, + tokenIdentifier: TOKEN_ID, + }); + }); + + it('token send: throws if token balance unavailable', async () => { + const w = createWallet(); + (w as any).tokenBalances = new Map(); + + await expect(w.getSendQuote({ toAddress: TO, amount: '1000', tokenId: TOKEN_ID })).rejects.toThrow(/token balance is unavailable/); + }); + + it('token send: throws if insufficient balance', async () => { + const w = createWallet(); + (w as any).tokenBalances = new Map([[TOKEN_ID, { ownedBalance: 100n, tokenMetadata: { tokenTicker: 'USDB', tokenName: 'USD Bond', decimals: 6 } }]]); + + await expect(w.getSendQuote({ toAddress: TO, amount: '1000', tokenId: TOKEN_ID })).rejects.toThrow(/Insufficient USDB balance/); + }); + + it('throws if wallet not initialized', async () => { + const w = new SparkWallet(); + await expect(w.getSendQuote({ toAddress: TO, amount: '1000' })).rejects.toThrow(/not initialized/); + }); + + it('throws on non-positive amount', async () => { + const w = createWallet(); + await expect(w.getSendQuote({ toAddress: TO, amount: '0' })).rejects.toThrow(/amount must be positive/); + }); + + it('throws when native amount exceeds MAX_SAFE_INTEGER', async () => { + const w = createWallet(); + const overflow = String(BigInt(Number.MAX_SAFE_INTEGER) + 1n); + await expect(w.getSendQuote({ toAddress: TO, amount: overflow })).rejects.toThrow(/Amount too large/); + }); +}); diff --git a/shared/tests/unit-vi/stacks-wallet.test.ts b/shared/tests/unit-vi/stacks-wallet.test.ts index c4b604a0b..8e0bd6368 100644 --- a/shared/tests/unit-vi/stacks-wallet.test.ts +++ b/shared/tests/unit-vi/stacks-wallet.test.ts @@ -1,7 +1,22 @@ -import { test } from 'vitest'; +import assert from 'assert'; +import { AuthType } from '@stacks/transactions'; +import { beforeEach, describe, expect, it, test, vi } from 'vitest'; import { StacksWallet } from '../../class/wallets/stacks-wallet'; -import assert from 'assert'; + +const makeContractCallMock = vi.fn(); +const makeSTXTokenTransferMock = vi.fn(); +const broadcastTransactionMock = vi.fn(); + +vi.mock('@stacks/transactions', async () => { + const actual = await vi.importActual('@stacks/transactions'); + return { + ...actual, + makeContractCall: (...args: any[]) => makeContractCallMock(...args), + makeSTXTokenTransfer: (...args: any[]) => makeSTXTokenTransferMock(...args), + broadcastTransaction: (...args: any[]) => broadcastTransactionMock(...args), + }; +}); const storageMock = { async setItem(key: string, value: string) {}, @@ -33,3 +48,128 @@ test('stacks wallet can generate addresses for different accounts', async (conte w.setAccountNumber(0); assert.strictEqual(await w.getOffchainReceiveAddress(), 'SP2R874DNSDKVF0Z281M8H9A2CCNZ3HDH4W2DZNT6'); }); + +describe('StacksWallet getSendQuote / executeSendQuote', () => { + const TO_STX = 'SP1D6V3SQR6HRSBY19HVED0YQEX3QHGYT8YH60AGF'; + const SENDER_ADDRESS = 'SP2R874DNSDKVF0Z281M8H9A2CCNZ3HDH4W2DZNT6'; + const SBTC_ID = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token'; + const SIP010_CONTRACT = 'SP2R874DNSDKVF0Z281M8H9A2CCNZ3HDH4W2DZNT6'; + const SIP010_TOKEN_ID = `${SIP010_CONTRACT}.my-token::my-token`; + const TXID = 'stxtxid0001'; + + const makeTxStub = (fee: bigint) => ({ auth: { authType: AuthType.Standard, spendingCondition: { fee } } }); + + const createWallet = (): StacksWallet => { + const w = new StacksWallet(); + (w as any)._sdkWallet = { + accounts: [{ stxPrivateKey: 'privkey' }], + }; + vi.spyOn(w, 'getOffchainReceiveAddress').mockResolvedValue(SENDER_ADDRESS); + (w as any)._tokenBalances = [ + { id: SBTC_ID, symbol: 'sBTC', balance: '10000' }, + { id: SIP010_TOKEN_ID, symbol: 'MYT', balance: '2000' }, + ]; + return w; + }; + + beforeEach(() => { + // clear, not restore: keep the vi.mock('@stacks/transactions') wiring alive across tests + vi.clearAllMocks(); + }); + + it('sBTC (no tokenId): builds contract call, quote reports STX fee, execute rebuilds and broadcasts', async () => { + const tx = makeTxStub(180n); + makeContractCallMock.mockResolvedValue(tx); + broadcastTransactionMock.mockResolvedValue({ txid: TXID }); + + const w = createWallet(); + const quote = await w.getSendQuote({ toAddress: TO_STX, amount: '1000' }); + + expect(quote.fee).toBe('180'); + expect(quote.feeTicker).toBe('STX'); + expect(quote.feeDecimals).toBe(6); + expect(makeContractCallMock).toHaveBeenCalledOnce(); + expect(makeContractCallMock.mock.calls[0][0]).toMatchObject({ contractName: 'sbtc-token', functionName: 'transfer' }); + expect(makeSTXTokenTransferMock).not.toHaveBeenCalled(); + + const txid = await w.executeSendQuote(quote); + expect(txid).toBe(TXID); + // rebuild-at-execute: contract call runs a second time with the same request + expect(makeContractCallMock).toHaveBeenCalledTimes(2); + expect(broadcastTransactionMock).toHaveBeenCalledWith({ transaction: tx }); + }); + + it('STX (tokenId=STX): builds STX token transfer; rebuilds at execute', async () => { + const tx = makeTxStub(200n); + makeSTXTokenTransferMock.mockResolvedValue(tx); + broadcastTransactionMock.mockResolvedValue({ txid: TXID }); + + const w = createWallet(); + const quote = await w.getSendQuote({ toAddress: TO_STX, amount: '500', tokenId: 'STX', memo: 'hello' }); + + expect(quote.fee).toBe('200'); + expect(makeSTXTokenTransferMock).toHaveBeenCalledOnce(); + expect(makeSTXTokenTransferMock.mock.calls[0][0]).toMatchObject({ recipient: TO_STX, amount: 500n, memo: 'hello' }); + expect(makeContractCallMock).not.toHaveBeenCalled(); + + await w.executeSendQuote(quote); + expect(makeSTXTokenTransferMock).toHaveBeenCalledTimes(2); + }); + + it('SIP-010 token: builds contract call against token contract; rebuilds at execute', async () => { + const tx = makeTxStub(350n); + makeContractCallMock.mockResolvedValue(tx); + broadcastTransactionMock.mockResolvedValue({ txid: TXID }); + + const w = createWallet(); + const quote = await w.getSendQuote({ toAddress: TO_STX, amount: '500', tokenId: SIP010_TOKEN_ID }); + + expect(quote.fee).toBe('350'); + expect(makeContractCallMock.mock.calls[0][0]).toMatchObject({ + contractAddress: SIP010_CONTRACT, + contractName: 'my-token', + functionName: 'transfer', + }); + + await w.executeSendQuote(quote); + expect(makeContractCallMock).toHaveBeenCalledTimes(2); + // second build call uses the same request payload + expect(makeContractCallMock.mock.calls[1][0]).toMatchObject({ + contractAddress: SIP010_CONTRACT, + contractName: 'my-token', + functionName: 'transfer', + }); + }); + + it('throws on insufficient sBTC balance', async () => { + const w = createWallet(); + (w as any)._tokenBalances = [{ id: SBTC_ID, symbol: 'sBTC', balance: '100' }]; + await expect(w.getSendQuote({ toAddress: TO_STX, amount: '1000' })).rejects.toThrow(/Insufficient sBTC balance/); + }); + + it('unwraps raw string broadcast response', async () => { + const tx = makeTxStub(180n); + makeContractCallMock.mockResolvedValue(tx); + broadcastTransactionMock.mockResolvedValue('rawtxid123'); + + const w = createWallet(); + const quote = await w.getSendQuote({ toAddress: TO_STX, amount: '1000' }); + const txid = await w.executeSendQuote(quote); + expect(txid).toBe('rawtxid123'); + }); + + it('throws when broadcast response is unrecognized', async () => { + const tx = makeTxStub(180n); + makeContractCallMock.mockResolvedValue(tx); + broadcastTransactionMock.mockResolvedValue({ error: 'nope' }); + + const w = createWallet(); + const quote = await w.getSendQuote({ toAddress: TO_STX, amount: '1000' }); + await expect(w.executeSendQuote(quote)).rejects.toThrow(/Failed to broadcast transfer/); + }); + + it('throws if wallet not initialized', async () => { + const w = new StacksWallet(); + await expect(w.getSendQuote({ toAddress: TO_STX, amount: '1000' })).rejects.toThrow(/not initialized/); + }); +}); diff --git a/shared/types/send-quote.ts b/shared/types/send-quote.ts index 8d00f0a19..f8017a052 100644 --- a/shared/types/send-quote.ts +++ b/shared/types/send-quote.ts @@ -15,10 +15,13 @@ export interface SendQuoteRequest { export interface SendQuote { /** Echo of the original request */ request: SendQuoteRequest; - /** Estimated fee in smallest unit of native currency */ + /** Estimated fee in smallest unit of the fee currency */ fee: string; /** Ticker of the fee currency (e.g. "RBTC", "L-BTC") */ feeTicker: string; - /** Wallet-specific prepared data needed for execution. Opaque to consumers. */ - _prepared: unknown; + /** Decimals of the fee currency (e.g. 18 for wei, 8 for sats, 6 for microSTX) */ + feeDecimals: number; + /** Wallet-specific prepared data needed for execution. Opaque to consumers. + * Omit when the quote carries no pre-broadcast artifact (e.g. Ark/Spark). */ + _prepared?: unknown; }