Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .agents/swap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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[]
Expand All @@ -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?`
Expand All @@ -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`
Expand All @@ -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
Expand All @@ -60,20 +65,23 @@ 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
- **Chain IDs**: Bitcoin=3652501241, Rootstock=30, Citrea=4114
- **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
- **SparkWallet access**: `SparkWallet.getSDKWalletForAccount(accountNumber)` static getter
- 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`)
Expand All @@ -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
Expand All @@ -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 >"
Expand All @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Redirect } from 'expo-router';

export default function TabsIndex() {
return <Redirect href="/(tabs)/home" />;
return <Redirect href="/home" />;
}
4 changes: 2 additions & 2 deletions mobile/app/BiometricLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default function BiometricLoginScreen({ autoTrigger = false }: BiometricL
isBiometricEnabled,
});

router.replace('/(tabs)/home');
router.replace('/home');
}
}, [isBiometricEnabled, router]);

Expand Down Expand Up @@ -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';
Expand Down
5 changes: 2 additions & 3 deletions mobile/app/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */}
<Animated.View style={[styles.modalContainer, { height: MODAL_MAX_HEIGHT }, modalAnimatedStyle]}>
<BlurTargetView ref={homeBlurTargetRef} style={styles.blurScrollTarget} collapsable={false}>
<View ref={homeBlurTargetRef} style={styles.blurScrollTarget} collapsable={false}>
<RadialGradientScreen network={network} scroll={true} onScroll={handleScroll} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} {...refreshOptions} />}>
<View style={[styles.root, styles.contentWithHeader]}>
{/* Network Selector */}
Expand Down Expand Up @@ -348,7 +347,7 @@ export default function Home() {
<TransactionsList transactions={latestTransactions} error={transactionsError} onTransactionPress={handleTransactionDetails} onViewHistory={handleTransactionHistory} />
</View>
</RadialGradientScreen>
</BlurTargetView>
</View>

{/* Invisible Settings Button for Maestro Testing */}
<Pressable style={styles.maestroSettingsButton} onPress={goToSettings} testID="SettingsButton" accessibilityLabel="Settings" />
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/SendAccountBased.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const SendAccountBased = () => {
<View style={styles.successContainer}>
<Ionicons name="checkmark-circle" size={80} color="#4CAF50" />
<ThemedText style={styles.successTitle}>Transaction Sent!</ThemedText>
<Pressable style={styles.backButton} onPress={() => router.replace('/(tabs)/home')}>
<Pressable style={styles.backButton} onPress={() => router.replace('/home')}>
<ThemedText style={styles.backButtonText}>Back to Wallet</ThemedText>
</Pressable>
</View>
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/SendBtc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ const SendBtc: React.FC = () => {

const handleBack = () => {
if (xArkSwapTo) setNetwork(xArkSwapTo);
router.replace('/(tabs)/home');
router.replace('/home');
};

if (isSuccess) {
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/SendLightning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ const SendLightning: React.FC = () => {
<Ionicons name="checkmark-circle" size={80} color="#4CAF50" />
<ThemedText style={styles.successMessage}>Payment Sent!</ThemedText>
<ThemedText style={styles.successSubMessage}>{amountToSend ? formatBalance(amountToSend, 8, 8) : ''} sats</ThemedText>
<Pressable style={styles.backButton} onPress={() => router.replace('/(tabs)/home')}>
<Pressable style={styles.backButton} onPress={() => router.replace('/home')}>
<ThemedText style={styles.backButtonText}>Back to Wallet</ThemedText>
</Pressable>
</View>
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/SendLiquid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ const SendLiquid = () => {
<View style={styles.successContainer}>
<Ionicons name="checkmark-circle" size={80} color="#4CAF50" />
<ThemedText style={styles.successText}>Transaction Sent!</ThemedText>
<Pressable style={styles.button} onPress={() => router.replace('/(tabs)/home')}>
<Pressable style={styles.button} onPress={() => router.replace('/home')}>
<ThemedText style={styles.buttonText}>Back to Wallet</ThemedText>
</Pressable>
</View>
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/SendNft.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export default function SendNft() {
<ThemedText style={styles.successSub} numberOfLines={1} ellipsizeMode="middle">
Tx: {txid}
</ThemedText>
<Pressable style={styles.secondaryButton} onPress={() => router.replace('/(tabs)/home')} activeOpacity={0.85} testID="send-nft-back-button">
<Pressable style={styles.secondaryButton} onPress={() => router.replace('/home')} activeOpacity={0.85} testID="send-nft-back-button">
<ThemedText style={styles.secondaryButtonText}>Back to Wallet</ThemedText>
</Pressable>
</View>
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/SendTokenStacks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/SwapXArkClaim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const SwapXArkClaim = () => {
};

const handleBack = () => {
router.replace('/(tabs)/home');
router.replace('/home');
};

const disabled = isClaiming || isRefunding;
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/UnlockPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ export default function IndexScreen() {
return <Redirect href="/onboarding/tos" />;
} else {
// If the app is ready, redirect to tabs - the protected routes will handle auth
return <Redirect href="/(tabs)/home" />;
return <Redirect href="/home" />;
}
}
2 changes: 1 addition & 1 deletion mobile/app/onboarding/create-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/onboarding/tos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/send/send-confirm-lightning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ const SendConfirmLightning: React.FC = () => {
};

const handleHome = () => {
router.replace('/(tabs)/home');
router.replace('/home');
};

// Calculate fee from invoice amount
Expand Down
2 changes: 1 addition & 1 deletion mobile/app/send/send-confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ const SendConfirm: React.FC<SendAssetProps> = ({ ticker, token }) => {
};

const handleHome = () => {
router.replace('/(tabs)/home');
router.replace('/home');
};

const formatAddressWithOpacity = (addr: string) => {
Expand Down
8 changes: 4 additions & 4 deletions mobile/app/transfer/confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions shared/class/evm-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export class EvmWallet implements InterfaceSendQuotable {
request,
fee,
feeTicker: AllNetworkInfos[this.network].ticker,
feeDecimals: AllNetworkInfos[this.network].decimals,
_prepared: prepared,
};
}
Expand Down
33 changes: 32 additions & 1 deletion shared/class/wallets/ark-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -627,6 +629,35 @@ export class ArkWallet extends AbstractHDElectrumWallet implements InterfaceLigh
});
}

async getSendQuote(request: SendQuoteRequest): Promise<SendQuote> {
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<string> {
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<string> {
if (!this._wallet) throw new Error('Ark wallet not initialized');

Expand Down
1 change: 1 addition & 0 deletions shared/class/wallets/breez-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export class BreezWallet implements InterfaceLightningWallet, InterfaceSendQuota
request,
fee: String(prepareResponse.feesSat),
feeTicker: 'L-BTC',
feeDecimals: 8,
_prepared: prepareResponse,
};
}
Expand Down
Loading
Loading