diff --git a/.agents/swap.md b/.agents/swap.md
index 99c650c8..6e23b15d 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 cf78a4fa..83af302d 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 6e68f125..3f72aff1 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 afa10bb1..12fa1475 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 42236f86..081ac76e 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 999a2a67..7d28e6a6 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 0075f63a..b25e791f 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 31d9037f..ceb84ac6 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 2c54fb94..16559b12 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 fc5afb31..6378adfd 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 ff986004..641f9c8a 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 848ccb91..70c305d8 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 215fb44a..8d50161c 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 bd953e18..89636208 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 6b6c4e36..c362f1f1 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 db539378..7c007c71 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 c152bbb9..2b641f35 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 0c01ab33..e2e9b19c 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 7d930d97..bd212507 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 e375b46c..65335549 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 7264e460..14ff4004 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 eb06ea0f..3b309445 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 18757f60..4c07b542 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 06fcbbb2..a7da73fe 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 028f5e88..24a7d147 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 26c1b114..6973f44f 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 5539f324..17eaced5 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 f6f2b5d7..8f637961 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 c4b604a0..8e0bd636 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 8d00f0a1..f8017a05 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;
}