diff --git a/docs/ai/PRODUCT.md b/docs/ai/PRODUCT.md index b2c6ce1f424..25ac30e24b1 100644 --- a/docs/ai/PRODUCT.md +++ b/docs/ai/PRODUCT.md @@ -281,4 +281,5 @@ Only Plug's **Internet Computer identity** is derived from the seed phrase. Plug - **Dogecoin and Litecoin are not shown.** Plug supports both and OISY supports neither. The page says so explicitly rather than omitting them silently, so a user with holdings there does not conclude the import is broken. - **Internet Computer tokens can be moved.** Each ICP or ICRC row offers a **Send to my wallet** action that transfers the balance to the user's own OISY account, signed locally with the identity the seed phrase controls. The network fee is deducted from the same token, so the amount sent is the balance minus the fee, and a balance that cannot cover its own fee shows the reason instead of an action that could only fail. Every send is confirmed first, since it cannot be undone, and only one runs at a time. - **EVM balances can be moved too.** Ethereum and the other EVM networks are signed by the original wallet's own canister — the only party that can sign for those addresses — and OISY broadcasts the result. Gas is paid in the network's native coin out of the imported account, so a native transfer moves the balance minus gas, and a token transfer needs native coin sitting in that same account; a row that lacks it says which coin is missing rather than simply greying out. -- **Bitcoin and Solana are still send-from-source.** Their transfers are not built yet, so the page says to send them from the original wallet to the user's OISY addresses. +- **Solana balances can be moved too.** Native SOL and SPL tokens are signed by the original wallet's own canister and broadcast by OISY, reusing OISY's own Solana send (which creates the destination token account when needed). The fee is paid in SOL from the imported account, so a native send moves the balance minus the fee and an SPL send needs SOL there — a row that lacks it says so. +- **Bitcoin is still send-from-source.** Its transfer is not built yet, so the page says to send it from the original wallet to the user's OISY address. diff --git a/docs/ai/spec-driven-development/specs/2026-08-04-feat-plug-wallet-import.md b/docs/ai/spec-driven-development/specs/2026-08-04-feat-plug-wallet-import.md index 0035a167925..2028d218a81 100644 --- a/docs/ai/spec-driven-development/specs/2026-08-04-feat-plug-wallet-import.md +++ b/docs/ai/spec-driven-development/specs/2026-08-04-feat-plug-wallet-import.md @@ -128,9 +128,15 @@ The EVM destination is the user's **OISY EVM address**, not their principal — **A row's identity is network plus symbol.** Symbol alone is not unique and neither is the address: the same token enabled on several EVM networks yields rows sharing both. The first cut keyed the in-flight send on symbol, which would have spun two rows at once. -### BTC and SOL +### SOL -Not yet built. SOL should fit OISY's existing `TransactionPartialSigner` abstraction via `sign_sol`. BTC needs UTXOs, fee percentiles and a broadcast path — the last is an open question, since `bitcoin_send_transaction` is canister-only. +`sign_sol` is raw Ed25519 over the message bytes, which is exactly what a `TransactionPartialSigner` produces — so the Plug signer drops into OISY's own `sendSol` in place of the default. `sendSol` gains one optional `signerOverride` parameter (backward-compatible for its three existing callers); everything else — building the transfer, creating the destination token account when the OISY side does not hold that token yet, broadcast, confirm — is reused. + +The fee is paid in SOL from the imported account, so it mirrors EVM: a native send moves `balance - fee`, an SPL send needs SOL present, and the gating is shared with EVM through the `isPlugFeeChain` / `isPlugGasToken` predicates rather than duplicated. SPL sufficiency beyond the base fee (the ATA rent) is left to the network to reject, rather than estimated up front. + +### BTC + +Not yet built. It needs UTXOs, fee percentiles and a broadcast path — the last is an open question, since `bitcoin_send_transaction` is canister-only. ## PRODUCT.md updates (land with the behaviour change) diff --git a/src/frontend/src/lib/components/plug-import/PlugImport.svelte b/src/frontend/src/lib/components/plug-import/PlugImport.svelte index cc781b69e37..228e0b39272 100644 --- a/src/frontend/src/lib/components/plug-import/PlugImport.svelte +++ b/src/frontend/src/lib/components/plug-import/PlugImport.svelte @@ -7,21 +7,22 @@ import PlugImportForm from '$lib/components/plug-import/PlugImportForm.svelte'; import { ZERO } from '$lib/constants/app.constants'; import { PLUG_IMPORT_ERROR, PLUG_IMPORT_NOTICES } from '$lib/constants/test-ids.constants'; - import { ethAddress } from '$lib/derived/address.derived'; + import { ethAddress, solAddressMainnet } from '$lib/derived/address.derived'; import { authIdentity } from '$lib/derived/auth.derived'; import { enabledFungibleTokens } from '$lib/derived/tokens.derived'; import { sweepPlugEvmBalance } from '$lib/services/plug-evm.services'; + import { sweepPlugSolBalance } from '$lib/services/plug-sol.services'; import { loadPlugBalances, sweepPlugBalance } from '$lib/services/plug.services'; import { i18n } from '$lib/stores/i18n.store'; import { toastsError, toastsShow } from '$lib/stores/toasts.store'; import type { NetworkId } from '$lib/types/network'; import type { PlugAccount, PlugBalance } from '$lib/types/plug'; import { replacePlaceholders } from '$lib/utils/i18n.utils'; - import { isNetworkIdEthereum, isNetworkIdEvm } from '$lib/utils/network.utils'; + import { isNetworkIdEthereum, isNetworkIdEvm, isNetworkIdSolana } from '$lib/utils/network.utils'; import { derivePlugAccounts, derivePlugIdentity, - isPlugEvmContractToken, + isPlugGasToken, isPlugSweepableToken, plugEvmNetwork, plugRowKey @@ -90,6 +91,24 @@ return; } + if (isNetworkIdSolana(network.id)) { + // The Solana destination is the user's own OISY Solana address. + if (isNullish($solAddressMainnet)) { + throw new Error('Your OISY Solana address is not loaded yet'); + } + + await sweepPlugSolBalance({ + identity, + token, + balance: amount, + nativeBalance: nativeBalanceFor({ account, networkId: network.id }), + destination: $solAddressMainnet, + source: address + }); + + return; + } + if (!isPlugSweepableToken(token)) { throw new Error(`No send path for ${token.symbol} on ${network.name}`); } @@ -105,7 +124,7 @@ networkId: NetworkId; }): bigint => (balances.get(account.index) ?? []).find( - ({ token }) => token.network.id === networkId && !isPlugEvmContractToken(token) + ({ token }) => token.network.id === networkId && !isPlugGasToken(token) )?.balance ?? ZERO; const send = async ({ diff --git a/src/frontend/src/lib/components/plug-import/PlugImportAccount.svelte b/src/frontend/src/lib/components/plug-import/PlugImportAccount.svelte index b0ae2758b1b..97939f24f4a 100644 --- a/src/frontend/src/lib/components/plug-import/PlugImportAccount.svelte +++ b/src/frontend/src/lib/components/plug-import/PlugImportAccount.svelte @@ -14,10 +14,10 @@ import type { PlugBalance, PlugAccount } from '$lib/types/plug'; import { formatToken, shortenWithMiddleEllipsis } from '$lib/utils/format.utils'; import { replacePlaceholders } from '$lib/utils/i18n.utils'; - import { isNetworkIdEthereum, isNetworkIdEvm } from '$lib/utils/network.utils'; import { - isPlugEvmContractToken, - isPlugEvmSendable, + isPlugFeeChain, + isPlugFeeChainSendable, + isPlugGasToken, isPlugSweepableToken, plugRowKey, plugSweepableAmount @@ -32,17 +32,18 @@ let { account, balances, sending, onsend }: Props = $props(); - const isEvm = ({ token: { network } }: PlugBalance): boolean => - isNetworkIdEthereum(network.id) || isNetworkIdEvm(network.id); + const isFeeChain = ({ token: { network } }: PlugBalance): boolean => isPlugFeeChain(network.id); - // How much a row can move, or undefined when it cannot move at all. EVM amounts - // are settled at send time against live fee data — gas is not knowable here — so - // the row reports the full balance and the service trims the reserve. + // How much a row can move, or undefined when it cannot move at all. On fee chains + // (EVM / Solana) the exact fee is settled at send time, so the row reports the + // full balance and the service trims the reserve. const sendableAmount = (row: PlugBalance): bigint | undefined => { const { token, balance } = row; - if (isEvm(row)) { - return isPlugEvmSendable({ token, balance, balances: balances ?? [] }) ? balance : undefined; + if (isFeeChain(row)) { + return isPlugFeeChainSendable({ token, balance, balances: balances ?? [] }) + ? balance + : undefined; } return plugSweepableAmount({ token, balance }); @@ -55,10 +56,14 @@ const blockedReason = (row: PlugBalance): string | undefined => { const { token, balance } = row; - if (isEvm(row)) { - return replacePlaceholders($i18n.plug_import.text.send_needs_gas, { - $symbol: nativeSymbol(token.network.id) ?? token.symbol - }); + if (isFeeChain(row)) { + // A native coin pays its own fee, so if it is blocked here the balance is + // simply unavailable — only a token missing its gas coin has advice to give. + return isPlugGasToken(token) + ? replacePlaceholders($i18n.plug_import.text.send_needs_gas, { + $symbol: nativeSymbol(token.network.id) ?? token.symbol + }) + : undefined; } if (!isPlugSweepableToken(token)) { @@ -73,9 +78,8 @@ }; const nativeSymbol = (networkId: NetworkId): string | undefined => - (balances ?? []).find( - ({ token }) => token.network.id === networkId && !isPlugEvmContractToken(token) - )?.token.symbol; + (balances ?? []).find(({ token }) => token.network.id === networkId && !isPlugGasToken(token)) + ?.token.symbol; let loaded = $derived(nonNullish(balances)); @@ -158,7 +162,7 @@ {/snippet}

- {#if isPlugEvmContractToken(token)} + {#if isPlugGasToken(token)} {replacePlaceholders($i18n.plug_import.text.send_confirm_description_gas, { $amount: formatToken({ value: amount, unitName: token.decimals }), $symbol: token.symbol, diff --git a/src/frontend/src/lib/services/plug-sol.services.ts b/src/frontend/src/lib/services/plug-sol.services.ts new file mode 100644 index 00000000000..7b264dcd5aa --- /dev/null +++ b/src/frontend/src/lib/services/plug-sol.services.ts @@ -0,0 +1,126 @@ +import { signPlugSolMessage } from '$lib/api/plug-helper.api'; +import { ZERO } from '$lib/constants/app.constants'; +import type { Token } from '$lib/types/token'; +import { SOLANA_TRANSACTION_FEE_IN_LAMPORTS } from '$sol/constants/sol.constants'; +import { sendSol } from '$sol/services/sol-send.services'; +import type { SolAddress } from '$sol/types/address'; +import { isTokenSpl } from '$sol/utils/spl.utils'; +import type { Identity } from '@icp-sdk/core/agent'; +import { + assertIsTransactionPartialSigner, + assertIsTransactionSigner, + address as solAddress, + type Signature, + type SignatureDictionary, + type Transaction, + type TransactionPartialSigner, + type TransactionWithinSizeLimit, + type TransactionWithLifetime +} from '@solana/kit'; + +/** + * A Solana signer backed by the imported wallet's own canister. + * + * Solana signing is raw Ed25519 over the message bytes — no hashing, no recovery + * id — so the helper canister's `sign_sol` takes the transaction message and + * returns the signature unchanged, exactly matching what a `TransactionPartialSigner` + * must produce. Only that canister can sign for this address, so the imported + * identity, not the signed-in one, must make the call. + */ +const createPlugSolSigner = ({ + identity, + address +}: { + identity: Identity; + address: SolAddress; +}): TransactionPartialSigner => { + const signer: TransactionPartialSigner = { + address: solAddress(address), + signTransactions: async ( + transactions: (Transaction & TransactionWithinSizeLimit & TransactionWithLifetime)[] + ): Promise => + await Promise.all( + transactions.map( + async (transaction) => + ({ + [address]: await signPlugSolMessage({ + identity, + message: Uint8Array.from(transaction.messageBytes) + }) + }) as SignatureDictionary + ) + ) + }; + + assertIsTransactionSigner(signer); + assertIsTransactionPartialSigner(signer); + + return signer; +}; + +/** + * Sends an imported wallet's Solana balance to the signed-in user's own address. + * + * The whole build/sign/broadcast/confirm flow is OISY's own `sendSol`; only the + * signer is swapped for one backed by the imported wallet's canister. + * + * The network fee is paid in SOL from the *imported* account. A native send can + * therefore only move `balance - fee`, and an SPL send needs SOL there for the + * fee (and for creating the destination token account when the OISY side does not + * hold that token yet). The SPL sufficiency beyond the base fee is left to the + * network, which rejects an underfunded transaction rather than half-applying it. + */ +export const sweepPlugSolBalance = async ({ + identity, + token, + balance, + nativeBalance, + destination, + source +}: { + identity: Identity; + token: Token; + balance: bigint; + nativeBalance: bigint; + destination: SolAddress; + source: SolAddress; +}): Promise => { + if (isTokenSpl(token)) { + if (nativeBalance <= SOLANA_TRANSACTION_FEE_IN_LAMPORTS) { + throw new Error('Not enough SOL to cover the fee for this token transfer'); + } + + return await runSweep({ identity, token, amount: balance, destination, source }); + } + + const amount = balance - SOLANA_TRANSACTION_FEE_IN_LAMPORTS; + + if (amount <= ZERO) { + throw new Error('Balance does not cover the network fee for this transfer'); + } + + return await runSweep({ identity, token, amount, destination, source }); +}; + +const runSweep = async ({ + identity, + token, + amount, + destination, + source +}: { + identity: Identity; + token: Token; + amount: bigint; + destination: SolAddress; + source: SolAddress; +}): Promise => + await sendSol({ + identity, + token, + amount, + prioritizationFee: ZERO, + destination, + source, + signerOverride: createPlugSolSigner({ identity, address: source }) + }); diff --git a/src/frontend/src/lib/utils/plug.utils.ts b/src/frontend/src/lib/utils/plug.utils.ts index 5f18aba9d34..ac3f3d273d1 100644 --- a/src/frontend/src/lib/utils/plug.utils.ts +++ b/src/frontend/src/lib/utils/plug.utils.ts @@ -20,7 +20,9 @@ import { SIGNER_MASTER_PUB_KEYS } from '$lib/constants/signer.constants'; import type { NetworkId } from '$lib/types/network'; import type { PlugAccount, PlugBalance } from '$lib/types/plug'; import type { Token } from '$lib/types/token'; +import { isNetworkIdEthereum, isNetworkIdEvm, isNetworkIdSolana } from '$lib/utils/network.utils'; import type { SolAddress } from '$sol/types/address'; +import { isTokenSpl } from '$sol/utils/spl.utils'; import { secp256k1 } from '@dfinity/ic-pub-key/ecdsa'; import { bip340secp256k1, ed25519 } from '@dfinity/ic-pub-key/schnorr'; import { isNullish, nonNullish } from '@dfinity/utils'; @@ -198,6 +200,23 @@ export const plugSweepableAmount = ({ export const isPlugEvmContractToken = (token: Token): token is Erc20Token | Erc4626Token => isTokenErc20(token) || isTokenErc4626(token); +/** + * A token whose network fee is paid in a *separate* native coin — ERC-20/ERC-4626 + * (gas in ETH) and SPL (fee in SOL). Moving one is impossible without that coin in + * the same account, which is what distinguishes it from a native coin that pays + * its own fee. Used to gate the send action and to find the native row. + */ +export const isPlugGasToken = (token: Token): boolean => + isPlugEvmContractToken(token) || isTokenSpl(token); + +/** + * A chain where the fee is paid from a native balance in the account, so a token + * needs that coin present and a native send must reserve it. Both EVM and Solana + * work this way; IC ledgers charge the fee in the token itself. + */ +export const isPlugFeeChain = (networkId: NetworkId): boolean => + isNetworkIdEthereum(networkId) || isNetworkIdEvm(networkId) || isNetworkIdSolana(networkId); + /** * Identifies a balance row. * @@ -215,19 +234,18 @@ const plugNativeBalance = ({ networkId: NetworkId; balances: PlugBalance[]; }): bigint | undefined => - balances.find(({ token }) => token.network.id === networkId && !isPlugEvmContractToken(token)) - ?.balance; + balances.find(({ token }) => token.network.id === networkId && !isPlugGasToken(token))?.balance; /** - * Whether an EVM row can be moved. + * Whether a fee-chain (EVM / Solana) row can be moved. * - * Gas is paid in the network's native coin out of the imported account, so a - * token transfer is impossible without native coin sitting there — a common state - * for someone who only ever received tokens. The exact gas figure needs live fee - * data, so this is the cheap gate that keeps an impossible action off the screen; - * the send path re-checks against the real fee. + * The fee is paid in the network's native coin out of the imported account, so a + * token transfer is impossible without that coin sitting there — a common state + * for someone who only ever received tokens. The exact fee needs live data, so + * this is the cheap gate that keeps an impossible action off the screen; the send + * path re-checks against the real fee. */ -export const isPlugEvmSendable = ({ +export const isPlugFeeChainSendable = ({ token, balance, balances @@ -240,7 +258,7 @@ export const isPlugEvmSendable = ({ return false; } - if (!isPlugEvmContractToken(token)) { + if (!isPlugGasToken(token)) { return true; } diff --git a/src/frontend/src/sol/services/sol-send.services.ts b/src/frontend/src/sol/services/sol-send.services.ts index 66895b4cc8f..0d47cdb1242 100644 --- a/src/frontend/src/sol/services/sol-send.services.ts +++ b/src/frontend/src/sol/services/sol-send.services.ts @@ -321,7 +321,8 @@ export const sendSol = async ({ amount, prioritizationFee, destination, - source + source, + signerOverride }: { identity: NullishIdentity; token: Token; @@ -330,6 +331,10 @@ export const sendSol = async ({ destination: SolAddress; source: SolAddress; progress?: (step: ProgressStepsSendSol) => void; + // The signer normally derives from the signed-in identity via the OISY signer + // canister. The Plug import injects a signer backed by the imported wallet's own + // canister, the only party that can sign for its Solana address. + signerOverride?: TransactionPartialSigner; }): Promise => { progress?.(ProgressStepsSendSol.INITIALIZATION); @@ -342,11 +347,13 @@ export const sendSol = async ({ const rpc = solanaHttpRpc(solNetwork); const rpcSubscriptions = solanaWebSocketRpc(solNetwork); - const signer: TransactionPartialSigner = createSigner({ - identity, - address: source, - network: solNetwork - }); + const signer: TransactionPartialSigner = + signerOverride ?? + createSigner({ + identity, + address: source, + network: solNetwork + }); const transactionMessage = isTokenSpl(token) ? await createSplTokenTransactionMessage({ diff --git a/src/frontend/src/tests/lib/components/plug-import/PlugImportAccount.spec.ts b/src/frontend/src/tests/lib/components/plug-import/PlugImportAccount.spec.ts index 869e6d9cfe7..807999ae092 100644 --- a/src/frontend/src/tests/lib/components/plug-import/PlugImportAccount.spec.ts +++ b/src/frontend/src/tests/lib/components/plug-import/PlugImportAccount.spec.ts @@ -1,6 +1,7 @@ import { BASE_NETWORK } from '$env/networks/networks-evm/networks.evm.base.env'; import { BTC_MAINNET_NETWORK } from '$env/networks/networks.btc.env'; import { ETHEREUM_NETWORK } from '$env/networks/networks.eth.env'; +import { SOLANA_MAINNET_NETWORK } from '$env/networks/networks.sol.env'; import PlugImportAccount from '$lib/components/plug-import/PlugImportAccount.svelte'; import { ZERO } from '$lib/constants/app.constants'; import { @@ -115,6 +116,21 @@ describe('PlugImportAccount', () => { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' } as unknown as typeof mockValidToken; + const nativeSol = { + ...mockValidToken, + standard: { code: 'solana' }, + symbol: 'SOL', + network: SOLANA_MAINNET_NETWORK + } as unknown as typeof mockValidToken; + + const spl = { + ...mockValidToken, + standard: { code: 'spl' }, + symbol: 'USD1', + network: SOLANA_MAINNET_NETWORK, + address: 'USD1ttGY1N17NEEHLmELoaybftRBUSErhqYiQzvEmuB' + } as unknown as typeof mockValidToken; + const sendButton = (row: PlugBalance) => `${PLUG_IMPORT_SEND_BUTTON}-${plugRowKey(row)}`; const disabledLabel = (row: PlugBalance) => `${PLUG_IMPORT_SEND_DISABLED}-${plugRowKey(row)}`; @@ -229,5 +245,46 @@ describe('PlugImportAccount', () => { expect(plugRowKey(onBase)).not.toBe(plugRowKey(onEthereum)); }); + + it('offers a send action for a native SOL balance', () => { + const row = balance({ token: nativeSol, balance: 10n ** 8n }); + + const { getByTestId } = render(PlugImportAccount, { + onsend: vi.fn(), + account: mockAccount, + balances: [row] + }); + + expect(getByTestId(sendButton(row))).toBeInTheDocument(); + }); + + it('offers a send action for an SPL token when the account holds SOL', () => { + const solRow = balance({ token: nativeSol, balance: 10n ** 8n }); + const tokenRow = balance({ token: spl, balance: 5_000_000n }); + + const { getByTestId } = render(PlugImportAccount, { + onsend: vi.fn(), + account: mockAccount, + balances: [solRow, tokenRow] + }); + + expect(getByTestId(sendButton(tokenRow))).toBeInTheDocument(); + }); + + it('blocks an SPL token with no SOL for the fee, naming the coin needed', () => { + const solRow = balance({ token: nativeSol, balance: ZERO }); + const tokenRow = balance({ token: spl, balance: 5_000_000n }); + + const { getByTestId, queryByTestId } = render(PlugImportAccount, { + onsend: vi.fn(), + account: mockAccount, + balances: [solRow, tokenRow] + }); + + expect(queryByTestId(sendButton(tokenRow))).toBeNull(); + expect(getByTestId(disabledLabel(tokenRow))).toHaveTextContent( + replacePlaceholders(en.plug_import.text.send_needs_gas, { $symbol: 'SOL' }) + ); + }); }); }); diff --git a/src/frontend/src/tests/lib/services/plug-sol.services.spec.ts b/src/frontend/src/tests/lib/services/plug-sol.services.spec.ts new file mode 100644 index 00000000000..e7def007977 --- /dev/null +++ b/src/frontend/src/tests/lib/services/plug-sol.services.spec.ts @@ -0,0 +1,93 @@ +import { SOLANA_MAINNET_NETWORK } from '$env/networks/networks.sol.env'; +import { ZERO } from '$lib/constants/app.constants'; +import { sweepPlugSolBalance } from '$lib/services/plug-sol.services'; +import type { Token } from '$lib/types/token'; +import { SOLANA_TRANSACTION_FEE_IN_LAMPORTS } from '$sol/constants/sol.constants'; +import { sendSol } from '$sol/services/sol-send.services'; +import { mockIdentity } from '$tests/mocks/identity.mock'; +import { mockValidToken } from '$tests/mocks/tokens.mock'; + +vi.mock('$sol/services/sol-send.services', () => ({ sendSol: vi.fn() })); +vi.mock('$lib/api/plug-helper.api', () => ({ signPlugSolMessage: vi.fn() })); + +const SOURCE = 'EUxq91X9hA2s2qDDHKmS8bHjQ8GX2XMNkakgRiDgksx'; +const DESTINATION = '2DZJD4BS96NY1EYe1zq137CQovKaEaQ1cmVwYq8wTaxG'; + +const nativeSol = { ...mockValidToken, symbol: 'SOL', network: SOLANA_MAINNET_NETWORK } as Token; + +const splToken = { + ...mockValidToken, + standard: { code: 'spl' }, + symbol: 'USD1', + network: SOLANA_MAINNET_NETWORK, + address: 'USD1ttGY1N17NEEHLmELoaybftRBUSErhqYiQzvEmuB', + owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +} as unknown as Token; + +describe('sweepPlugSolBalance', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(sendSol).mockResolvedValue('sig' as never); + }); + + const call = ({ + token, + balance, + nativeBalance = 10n ** 9n + }: { + token: Token; + balance: bigint; + nativeBalance?: bigint; + }) => + sweepPlugSolBalance({ + identity: mockIdentity, + token, + balance, + nativeBalance, + destination: DESTINATION, + source: SOURCE + }); + + describe('native SOL', () => { + it('reserves the fee out of the amount and injects the imported signer', async () => { + await call({ token: nativeSol, balance: 10n ** 7n }); + + expect(sendSol).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + token: nativeSol, + amount: 10n ** 7n - SOLANA_TRANSACTION_FEE_IN_LAMPORTS, + destination: DESTINATION, + source: SOURCE, + prioritizationFee: ZERO, + signerOverride: expect.objectContaining({ address: SOURCE }) + }) + ); + }); + + it('refuses a balance that cannot cover the fee, without sending', async () => { + await expect( + call({ token: nativeSol, balance: SOLANA_TRANSACTION_FEE_IN_LAMPORTS }) + ).rejects.toThrow('does not cover the network fee'); + + expect(sendSol).not.toHaveBeenCalled(); + }); + }); + + describe('SPL', () => { + it('sends the full token balance, since the fee is paid in SOL', async () => { + await call({ token: splToken, balance: 5_000_000n }); + + expect(sendSol).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ token: splToken, amount: 5_000_000n }) + ); + }); + + it('refuses when the account holds no SOL for the fee, without sending', async () => { + await expect( + call({ token: splToken, balance: 5_000_000n, nativeBalance: ZERO }) + ).rejects.toThrow('Not enough SOL'); + + expect(sendSol).not.toHaveBeenCalled(); + }); + }); +});