@lombard.finance/sdk-solana 1.2.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +48 -15
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +37 -31
  4. package/dist/index2.cjs +57 -54
  5. package/dist/index2.js +7608 -7206
  6. package/package.json +2 -2
  7. package/src/const/errors.ts +0 -4
  8. package/src/const/getConfig.ts +43 -20
  9. package/src/const/rpcUrls.ts +2 -2
  10. package/src/idl/asset_router.json +548 -179
  11. package/src/idl/consortium.json +24 -43
  12. package/src/idl/mailbox.json +118 -107
  13. package/src/index.ts +1 -3
  14. package/src/services/SolanaServiceImpl.test.ts +123 -0
  15. package/src/services/SolanaServiceImpl.ts +53 -17
  16. package/src/stories/components/OutputSelector/OutputSelector.tsx +1 -0
  17. package/src/types/errors.ts +2 -0
  18. package/src/utils/createDebugLogger.ts +6 -13
  19. package/src/utils/errors.ts +2 -0
  20. package/src/utils/tokenAccount.ts +3 -1
  21. package/src/utils/transactions.ts +1 -1
  22. package/src/web3Sdk/claimToken/claimBtcb.ts +37 -28
  23. package/src/web3Sdk/claimToken/claimLbtcGmp.ts +66 -8
  24. package/src/web3Sdk/claimToken/claimToken.stories.tsx +2 -2
  25. package/src/web3Sdk/claimToken/claimToken.ts +20 -16
  26. package/src/web3Sdk/claimToken/constants.ts +5 -0
  27. package/src/web3Sdk/claimToken/index.ts +1 -0
  28. package/src/web3Sdk/claimToken/shared.ts +88 -80
  29. package/src/web3Sdk/deposit/deposit.stories.tsx +240 -0
  30. package/src/web3Sdk/deposit/deposit.test.ts +327 -0
  31. package/src/web3Sdk/deposit/deposit.ts +339 -0
  32. package/src/web3Sdk/deposit/index.ts +1 -0
  33. package/src/web3Sdk/getTokenFeeConfig/getTokenFeeConfig.stories.tsx +166 -0
  34. package/src/web3Sdk/getTokenFeeConfig/getTokenFeeConfig.test.ts +224 -0
  35. package/src/web3Sdk/getTokenFeeConfig/getTokenFeeConfig.ts +154 -0
  36. package/src/web3Sdk/getTokenFeeConfig/index.ts +11 -0
  37. package/src/web3Sdk/index.ts +3 -4
  38. package/src/web3Sdk/redeem/index.ts +1 -0
  39. package/src/web3Sdk/redeem/redeem.stories.tsx +226 -0
  40. package/src/web3Sdk/redeem/redeem.test.ts +327 -0
  41. package/src/web3Sdk/redeem/redeem.ts +352 -0
  42. package/src/web3Sdk/redeemToken/redeemBtcb.ts +174 -0
  43. package/src/web3Sdk/redeemToken/redeemForBtc.stories.tsx +35 -21
  44. package/src/web3Sdk/redeemToken/redeemForBtc.test.ts +306 -0
  45. package/src/web3Sdk/redeemToken/redeemForBtc.ts +54 -215
  46. package/src/web3Sdk/redeemToken/redeemLbtc.ts +174 -0
  47. package/src/web3Sdk/redeemToken/shared.test.ts +45 -0
  48. package/src/web3Sdk/redeemToken/shared.ts +97 -0
  49. package/src/web3Sdk/claimLBTC/claimLBTC.stories.tsx +0 -189
  50. package/src/web3Sdk/claimLBTC/claimLBTC.ts +0 -225
  51. package/src/web3Sdk/claimLBTC/index.ts +0 -1
  52. package/src/web3Sdk/claimLBTC/utils/generateDepositId.ts +0 -75
  53. package/src/web3Sdk/claimLBTC/utils/index.ts +0 -2
  54. package/src/web3Sdk/claimLBTC/utils/parseTransactionLogs.ts +0 -44
  55. package/src/web3Sdk/claimLBTC/utils/payloadUtils.ts +0 -58
  56. package/src/web3Sdk/claimLBTC/utils/postMintSignatures.ts +0 -50
  57. package/src/web3Sdk/unstakeLBTC/index.ts +0 -1
  58. package/src/web3Sdk/unstakeLBTC/unstakeLBTC.stories.tsx +0 -141
  59. package/src/web3Sdk/unstakeLBTC/unstakeLBTC.ts +0 -140
  60. /package/src/web3Sdk/{claimLBTC → claimToken}/utils/__tests__/signatureUtils.test.ts +0 -0
  61. /package/src/web3Sdk/{claimLBTC → claimToken}/utils/signatureUtils.ts +0 -0
@@ -0,0 +1,226 @@
1
+ import { Env } from '@lombard.finance/sdk-common';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { useState } from 'react';
4
+
5
+ import { envToNetwork, getConfig } from '../../const/getConfig';
6
+ import {
7
+ Button,
8
+ CodeBlock,
9
+ ConnectButton,
10
+ ErrorDisplay,
11
+ ResultDisplay,
12
+ SectionCard,
13
+ } from '../../stories/components';
14
+ import { functionType } from '../../stories/decorators/function-type';
15
+ import { useConnect } from '../../stories/hooks/useConnect';
16
+ import useQuery from '../../stories/hooks/useQuery';
17
+ import { redeem } from './redeem';
18
+
19
+ interface RedeemStoryArgs {
20
+ environment: Env;
21
+ amount: string;
22
+ recipient: string;
23
+ tokenMint: string;
24
+ toLchainId: string;
25
+ toTokenAddress: string;
26
+ }
27
+
28
+ export const StoryView = ({
29
+ environment,
30
+ amount,
31
+ recipient,
32
+ tokenMint,
33
+ toLchainId,
34
+ toTokenAddress,
35
+ }: RedeemStoryArgs) => {
36
+ const network = envToNetwork[environment];
37
+ const config = getConfig(environment);
38
+ const [transactionLogs, setTransactionLogs] = useState<string[] | null>(null);
39
+
40
+ const {
41
+ data: connectionData,
42
+ error: connectError,
43
+ isLoading: isConnecting,
44
+ connect,
45
+ disconnect,
46
+ } = useConnect();
47
+ const isConnected = !!connectionData;
48
+ const address = connectionData?.address;
49
+ const provider = connectionData?.provider;
50
+
51
+ const effectiveMint = tokenMint || config.lbtcTokenMint;
52
+ const effectiveToToken = toTokenAddress || config.btcbTokenMint;
53
+
54
+ const request = async () => {
55
+ if (!provider || !address) throw new Error('Wallet not connected.');
56
+ if (!recipient) throw new Error('Recipient address is required (set in args).');
57
+ const parsedAmount = parseFloat(amount);
58
+ if (!amount || isNaN(parsedAmount) || parsedAmount <= 0)
59
+ throw new Error('Amount must be a positive number in BTC (set in args).');
60
+
61
+ const amountSats = Math.round(parsedAmount * 1e8).toString();
62
+
63
+ setTransactionLogs(null);
64
+ try {
65
+ const result = await redeem(provider, {
66
+ amount: amountSats,
67
+ recipient,
68
+ tokenMint: tokenMint || undefined,
69
+ toLchainId: toLchainId || undefined,
70
+ toTokenAddress: toTokenAddress || undefined,
71
+ network,
72
+ env: environment,
73
+ debug: true,
74
+ });
75
+ return result;
76
+ } catch (err: unknown) {
77
+ if (err instanceof Error && err.message.includes('Debug logs:')) {
78
+ const parts = err.message.split('Debug logs:\n');
79
+ setTransactionLogs(parts[1]?.split('\n') || []);
80
+ }
81
+ throw err;
82
+ }
83
+ };
84
+
85
+ const {
86
+ data: txHash,
87
+ error,
88
+ isLoading,
89
+ refetch: handleRedeem,
90
+ } = useQuery(
91
+ request,
92
+ [provider, address, amount, recipient, tokenMint, toLchainId, toTokenAddress, environment],
93
+ false,
94
+ );
95
+
96
+ return (
97
+ <>
98
+ <ConnectButton
99
+ connect={connect}
100
+ disconnect={disconnect}
101
+ isConnected={isConnected}
102
+ isLoading={isConnecting}
103
+ error={connectError}
104
+ walletName={connectionData?.walletName}
105
+ address={connectionData?.address}
106
+ network={network}
107
+ />
108
+
109
+ {isConnected && (
110
+ <>
111
+ <SectionCard title="Configuration">
112
+ <p>
113
+ <strong>Environment:</strong> {environment}
114
+ </p>
115
+ <p>
116
+ <strong>Network:</strong> {network}
117
+ </p>
118
+ <p>
119
+ <strong>Amount:</strong> {amount} BTC
120
+ </p>
121
+ <p>
122
+ <strong>Recipient:</strong> {recipient || <em>Not set</em>}
123
+ </p>
124
+ <p>
125
+ <strong>Source token mint:</strong>{' '}
126
+ {effectiveMint || <em>Not configured</em>}
127
+ </p>
128
+ <p>
129
+ <strong>Destination token:</strong>{' '}
130
+ {effectiveToToken || <em>Not configured</em>}
131
+ </p>
132
+ </SectionCard>
133
+
134
+ <div className="d-grid gap-2 my-4">
135
+ <Button
136
+ primary
137
+ size="large"
138
+ onClick={handleRedeem}
139
+ isLoading={isLoading}
140
+ actionName={redeem.name}
141
+ />
142
+ </div>
143
+
144
+ {txHash && (
145
+ <ResultDisplay
146
+ result={txHash}
147
+ title="Redeem Transaction Hash"
148
+ successMessage="Success! Redeem transaction submitted."
149
+ />
150
+ )}
151
+ {(error || connectError) && (
152
+ <ErrorDisplay
153
+ error={error || connectError}
154
+ title="Redeem Error"
155
+ />
156
+ )}
157
+
158
+ {transactionLogs && transactionLogs.length > 0 && (
159
+ <SectionCard title="Transaction Logs (Debug)">
160
+ <CodeBlock text={transactionLogs.join('\n')} />
161
+ </SectionCard>
162
+ )}
163
+ </>
164
+ )}
165
+ </>
166
+ );
167
+ };
168
+
169
+ const meta: Meta<typeof StoryView> = {
170
+ title: 'write/redeem (Asset Router)',
171
+ component: StoryView,
172
+ tags: ['autodocs'],
173
+ decorators: [functionType('write')],
174
+ parameters: {
175
+ docs: {
176
+ description: {
177
+ component: `Demonstrates generic token redemption via the Asset Router's \`redeem\` instruction.
178
+
179
+ **Flow:**
180
+ 1. Connect a Solana wallet holding the source token (defaults to LBTC)
181
+ 2. Enter the recipient address and amount (in BTC)
182
+ 3. Optionally override source mint, destination chain ID, and destination token
183
+ 4. Call \`redeem\` — burns the source token and sends a GMP message through the Mailbox
184
+ 5. The destination token is routed to the recipient's ATA for Asset Router \`native_mint\` (payload carries that token account address)`,
185
+ },
186
+ },
187
+ },
188
+ args: {
189
+ environment: Env.stage,
190
+ amount: '0.0002',
191
+ recipient: '',
192
+ tokenMint: '',
193
+ toLchainId: '',
194
+ toTokenAddress: '',
195
+ },
196
+ argTypes: {
197
+ environment: {
198
+ control: { type: 'select' },
199
+ options: Object.values(Env),
200
+ },
201
+ amount: {
202
+ control: { type: 'text' },
203
+ description: 'Amount to redeem in BTC (e.g. 0.0002)',
204
+ },
205
+ recipient: {
206
+ control: { type: 'text' },
207
+ description:
208
+ 'Recipient wallet (owner); SDK uses the associated token account for on-chain native_mint in the redeem payload (Solana base58)',
209
+ },
210
+ tokenMint: {
211
+ control: { type: 'text' },
212
+ description: 'Source token mint override (defaults to LBTC from config)',
213
+ },
214
+ toLchainId: {
215
+ control: { type: 'text' },
216
+ description: 'Destination Lombard routing chain ID (hex). Defaults to Solana routing chain ID',
217
+ },
218
+ toTokenAddress: {
219
+ control: { type: 'text' },
220
+ description: 'Destination token address/mint override (defaults to BTC.b from config)',
221
+ },
222
+ },
223
+ };
224
+
225
+ export default meta;
226
+ type Story = StoryObj<typeof meta>;
@@ -0,0 +1,327 @@
1
+ import { PublicKey } from '@solana/web3.js';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { IConfig } from '../../const/getConfig';
5
+ import { SolanaNetwork } from '../../types';
6
+ import { ErrorCode, SolanaSdkError } from '../../utils';
7
+
8
+ // ── Constants ──
9
+
10
+ const MOCK_LBTC_MINT = 'LBTCojyVJ63rsEED2DLEGWMzSxWJyQynXE91LMLgV1J';
11
+ const MOCK_BTCB_MINT = 'BTCB3ripBAut19jM8kDPVbJHb2ZdR2GcZvGZkCmFPtV8';
12
+ const MOCK_ASSET_ROUTER = 'LomVyJDZ91jeVbNnTupJXKJTQFakJVMc87CmwDHYt95';
13
+ const MOCK_MAILBOX = 'LomJw912MoUd7iiAesTQAgz1paLcTqi6ndG3w3pnKH9';
14
+ const MOCK_SOLANA_CHAIN_ID = '0259db5080fc2c6d3bcf7ca90712d3c2e5e6c28f27f0dfbb9953bdb0894c03ab';
15
+ const MOCK_LEDGER_CHAIN_ID = '031f51c4e4cc1dae1c752d2f8fe2ae045da668a13f2e47a465964d630f5ed22e';
16
+ const MOCK_PAYER = '8yarEiDaJVikHZbk3PQSoWiDn2T3oM1FHZN1Jv4VZFdr';
17
+ const MOCK_RECIPIENT = 'DVMiNi7uxHEPABTBt1nLMoxnPniPKbLAFj4MPJq1RDjg';
18
+ const MOCK_RECIPIENT_ATA = 'GRq2yasTvWWPPqSwxCZvqfCTfDhP3MswDH4nW2v6F5To';
19
+ const MOCK_PAYER_LBTC_ATA = 'GfYV1f1bR9vy41mSyQ8quxYbds121kijSBj5A3nG8oDQ';
20
+ const MOCK_TREASURY_LBTC_ATA = '3SG3oyrG3KSvJ9bbxPDu7ZXEe5o1TW1QkgudkKvK6FK4';
21
+
22
+ const fullConfig: IConfig = {
23
+ lbtcTokenMint: MOCK_LBTC_MINT,
24
+ btcbTokenMint: MOCK_BTCB_MINT,
25
+ assetRouter: MOCK_ASSET_ROUTER,
26
+ mailbox: MOCK_MAILBOX,
27
+ solanaRoutingChainId: MOCK_SOLANA_CHAIN_ID,
28
+ bitcoinRoutingChainId: 'ff000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6',
29
+ ledgerChainId: MOCK_LEDGER_CHAIN_ID,
30
+ lbtcProgramId: 'HEY7PCJe3GB27UWdopuYb1xDbB5SNtTcYPxRjntvfBSA',
31
+ treasuryAddress: 'ByHNGi4zPJw5StyWZoLQJ9n2wT12oupJF2pTSNKMnnAZ',
32
+ bascule: null,
33
+ basculeData: null,
34
+ admin: '6MKjyWZnkSMitJYAixvJzqhJiVsjTA3hYHX8aP9qNioj',
35
+ lzOftAdapter: 'AEFwQgaSNhQcZhAcGZGM9iTyGML3fsJC2aBvYmzV81FE',
36
+ lzOftStore: '3SG3oyrG3KSvJ9bbxPDu7ZXEe5o1TW1QkgudkKvK6FK4',
37
+ lzMultisig: 'GfYV1f1bR9vy41mSyQ8quxYbds121kijSBj5A3nG8oDQ',
38
+ lzEscrow: 'GRq2yasTvWWPPqSwxCZvqfCTfDhP3MswDH4nW2v6F5To',
39
+ consortium: null,
40
+ ratioOracle: null,
41
+ bridge: null,
42
+ lombardTokenPool: null,
43
+ };
44
+
45
+ // ── Mocks ──
46
+
47
+ vi.mock('../../const/getConfig', () => ({
48
+ DEFAULT_ENV: 'dev' as const,
49
+ networkToEnv: { devnet: 'dev', testnet: 'testnet', 'mainnet-beta': 'prod' },
50
+ getConfig: vi.fn(() => fullConfig),
51
+ }));
52
+
53
+ // Build AR config data through native_mint (offset 105–136) for redeem recipient ATA derivation
54
+ function buildArConfigData(paused = false) {
55
+ const data = Buffer.alloc(145, 0);
56
+ const treasury = new PublicKey(MOCK_PAYER);
57
+ treasury.toBuffer().copy(data, 72);
58
+ data[104] = paused ? 1 : 0;
59
+ new PublicKey(MOCK_BTCB_MINT).toBuffer().copy(data, 105);
60
+ data.writeBigUInt64LE(42n, 137);
61
+ return data;
62
+ }
63
+
64
+ function buildMailboxConfigData() {
65
+ const data = Buffer.alloc(145, 0);
66
+ const treasury = new PublicKey(MOCK_PAYER);
67
+ treasury.toBuffer().copy(data, 72);
68
+ data.writeBigUInt64LE(42n, 137);
69
+ return data;
70
+ }
71
+
72
+ const arConfigPDA = PublicKey.findProgramAddressSync(
73
+ [Buffer.from('asset_router_config')],
74
+ new PublicKey(MOCK_ASSET_ROUTER),
75
+ )[0];
76
+
77
+ function defaultGetAccountInfo(pubkey: PublicKey) {
78
+ if (pubkey.toBase58() === arConfigPDA.toBase58()) {
79
+ return Promise.resolve({ data: buildArConfigData() });
80
+ }
81
+ return Promise.resolve({ data: buildMailboxConfigData() });
82
+ }
83
+
84
+ const mockConnection = {
85
+ getAccountInfo: vi.fn().mockImplementation(defaultGetAccountInfo),
86
+ getTokenAccountBalance: vi.fn().mockResolvedValue({
87
+ value: { amount: '999999999', uiAmountString: '9.99' },
88
+ }),
89
+ };
90
+
91
+ vi.mock('../../const/rpcUrls', () => ({
92
+ getConnection: vi.fn(() => mockConnection),
93
+ }));
94
+
95
+ vi.mock('../../utils/tokenAccount', () => ({
96
+ getTokenProgramForMint: vi.fn().mockResolvedValue(new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')),
97
+ }));
98
+
99
+ vi.mock('../../idl/getAssetRouterIdl', () => ({
100
+ getAssetRouterIdl: vi.fn(() => ({
101
+ address: MOCK_ASSET_ROUTER,
102
+ metadata: { name: 'asset_router', version: '0.1.0', spec: '0.1.0' },
103
+ instructions: [],
104
+ })),
105
+ }));
106
+
107
+ vi.mock('@solana/spl-token', () => ({
108
+ getAssociatedTokenAddress: vi
109
+ .fn()
110
+ .mockImplementation(
111
+ async (
112
+ mint: PublicKey,
113
+ owner: PublicKey,
114
+ allowOwnerOffCurve?: boolean,
115
+ ) => {
116
+ if (
117
+ mint.toBase58() === MOCK_BTCB_MINT &&
118
+ owner.toBase58() === MOCK_RECIPIENT
119
+ ) {
120
+ return new PublicKey(MOCK_RECIPIENT_ATA);
121
+ }
122
+ if (
123
+ mint.toBase58() === MOCK_LBTC_MINT &&
124
+ owner.toBase58() === MOCK_PAYER &&
125
+ allowOwnerOffCurve
126
+ ) {
127
+ return new PublicKey(MOCK_TREASURY_LBTC_ATA);
128
+ }
129
+ if (
130
+ mint.toBase58() === MOCK_LBTC_MINT &&
131
+ owner.toBase58() === MOCK_PAYER &&
132
+ !allowOwnerOffCurve
133
+ ) {
134
+ return new PublicKey(MOCK_PAYER_LBTC_ATA);
135
+ }
136
+ return new PublicKey(MOCK_PAYER);
137
+ },
138
+ ),
139
+ ASSOCIATED_TOKEN_PROGRAM_ID: new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'),
140
+ }));
141
+
142
+ const mockTx = { instructions: [{ keys: [] }] };
143
+ const mockMethods = {
144
+ redeem: vi.fn().mockReturnValue({
145
+ accounts: vi.fn().mockReturnValue({
146
+ transaction: vi.fn().mockResolvedValue(mockTx),
147
+ }),
148
+ }),
149
+ };
150
+
151
+ vi.mock('@coral-xyz/anchor', () => ({
152
+ Program: vi.fn().mockImplementation(() => ({ methods: mockMethods })),
153
+ BN: vi.fn().mockImplementation((v: string) => ({ toString: () => v })),
154
+ }));
155
+
156
+ vi.mock('../../utils', async () => {
157
+ const actual = await vi.importActual('../../utils');
158
+ return {
159
+ ...actual,
160
+ sendAndConfirmTransaction: vi.fn().mockResolvedValue({ signature: 'mock-redeem-sig' }),
161
+ };
162
+ });
163
+
164
+ const baseParams = {
165
+ amount: '100000',
166
+ recipient: MOCK_RECIPIENT,
167
+ network: SolanaNetwork.devnet,
168
+ };
169
+
170
+ describe('redeem', () => {
171
+ let redeemFn: typeof import('./redeem').redeem;
172
+
173
+ beforeEach(async () => {
174
+ vi.clearAllMocks();
175
+ mockConnection.getAccountInfo.mockImplementation(defaultGetAccountInfo);
176
+ mockConnection.getTokenAccountBalance.mockResolvedValue({
177
+ value: { amount: '999999999', uiAmountString: '9.99' },
178
+ });
179
+ const mod = await import('./redeem');
180
+ redeemFn = mod.redeem;
181
+ });
182
+
183
+ afterEach(() => {
184
+ vi.clearAllMocks();
185
+ });
186
+
187
+ // ── Validation ──
188
+
189
+ it('should throw when wallet is not connected', async () => {
190
+ await expect(
191
+ redeemFn({ publicKey: null } as any, baseParams),
192
+ ).rejects.toThrow('Wallet not connected');
193
+ });
194
+
195
+ it('should throw when Asset Router is not configured', async () => {
196
+ const { getConfig } = await import('../../const/getConfig');
197
+ vi.mocked(getConfig).mockReturnValueOnce({ ...fullConfig, assetRouter: null });
198
+
199
+ await expect(
200
+ redeemFn({ publicKey: MOCK_PAYER } as any, baseParams),
201
+ ).rejects.toThrow('Asset Router not configured');
202
+ });
203
+
204
+ it('should throw when Mailbox is not configured', async () => {
205
+ const { getConfig } = await import('../../const/getConfig');
206
+ vi.mocked(getConfig).mockReturnValueOnce({ ...fullConfig, mailbox: null });
207
+
208
+ await expect(
209
+ redeemFn({ publicKey: MOCK_PAYER } as any, baseParams),
210
+ ).rejects.toThrow('Mailbox not configured');
211
+ });
212
+
213
+ it('should throw when Solana routing chain ID is not configured', async () => {
214
+ const { getConfig } = await import('../../const/getConfig');
215
+ vi.mocked(getConfig).mockReturnValueOnce({ ...fullConfig, solanaRoutingChainId: null });
216
+
217
+ await expect(
218
+ redeemFn({ publicKey: MOCK_PAYER } as any, baseParams),
219
+ ).rejects.toThrow('Solana routing chain ID not configured');
220
+ });
221
+
222
+ it('should throw when source token mint is not resolved', async () => {
223
+ const { getConfig } = await import('../../const/getConfig');
224
+ vi.mocked(getConfig).mockReturnValueOnce({ ...fullConfig, lbtcTokenMint: '' });
225
+
226
+ await expect(
227
+ redeemFn({ publicKey: MOCK_PAYER } as any, baseParams),
228
+ ).rejects.toThrow('Source token mint not configured');
229
+ });
230
+
231
+ it('should throw when destination token is not configured', async () => {
232
+ const { getConfig } = await import('../../const/getConfig');
233
+ vi.mocked(getConfig).mockReturnValueOnce({ ...fullConfig, btcbTokenMint: '' });
234
+
235
+ await expect(
236
+ redeemFn({ publicKey: MOCK_PAYER } as any, baseParams),
237
+ ).rejects.toThrow('Destination token not configured');
238
+ });
239
+
240
+ it('should throw when amount is zero', async () => {
241
+ await expect(
242
+ redeemFn({ publicKey: MOCK_PAYER } as any, { ...baseParams, amount: '0' }),
243
+ ).rejects.toThrow('greater than zero');
244
+ });
245
+
246
+ it('should throw when Asset Router is paused', async () => {
247
+ mockConnection.getAccountInfo.mockImplementation((pubkey: PublicKey) => {
248
+ if (pubkey.toBase58() === arConfigPDA.toBase58()) {
249
+ return Promise.resolve({ data: buildArConfigData(true) });
250
+ }
251
+ return Promise.resolve({ data: buildMailboxConfigData() });
252
+ });
253
+
254
+ await expect(
255
+ redeemFn({ publicKey: MOCK_PAYER } as any, baseParams),
256
+ ).rejects.toThrow('Asset Router is paused');
257
+ });
258
+
259
+ it('should throw on insufficient balance', async () => {
260
+ mockConnection.getTokenAccountBalance.mockResolvedValue({
261
+ value: { amount: '10', uiAmountString: '0.0000001' },
262
+ });
263
+
264
+ await expect(
265
+ redeemFn({ publicKey: MOCK_PAYER } as any, baseParams),
266
+ ).rejects.toThrow('Insufficient balance');
267
+ });
268
+
269
+ it('should throw when Ledger chain ID is not configured', async () => {
270
+ const { getConfig } = await import('../../const/getConfig');
271
+ vi.mocked(getConfig).mockReturnValueOnce({ ...fullConfig, ledgerChainId: null });
272
+
273
+ await expect(
274
+ redeemFn({ publicKey: MOCK_PAYER } as any, baseParams),
275
+ ).rejects.toThrow('Ledger chain ID not configured');
276
+ });
277
+
278
+ // ── Success path ──
279
+
280
+ it('should return transaction signature on success', async () => {
281
+ mockConnection.getAccountInfo.mockImplementation(defaultGetAccountInfo);
282
+
283
+ const sig = await redeemFn({ publicKey: MOCK_PAYER } as any, baseParams);
284
+
285
+ expect(sig).toBe('mock-redeem-sig');
286
+ });
287
+
288
+ it('should pass native_mint ATA bytes as redeem recipient argument', async () => {
289
+ mockConnection.getAccountInfo.mockImplementation(defaultGetAccountInfo);
290
+
291
+ await redeemFn({ publicKey: MOCK_PAYER } as any, baseParams);
292
+
293
+ expect(mockMethods.redeem).toHaveBeenCalled();
294
+ const recipientArg = mockMethods.redeem.mock.calls[0][2] as number[];
295
+ expect(recipientArg).toEqual(
296
+ Array.from(new PublicKey(MOCK_RECIPIENT_ATA).toBytes()),
297
+ );
298
+ });
299
+
300
+ // ── Error wrapping ──
301
+
302
+ it('should wrap errors with SolanaSdkError', async () => {
303
+ const { getConfig } = await import('../../const/getConfig');
304
+ vi.mocked(getConfig).mockReturnValueOnce({ ...fullConfig, assetRouter: null });
305
+
306
+ try {
307
+ await redeemFn({ publicKey: MOCK_PAYER } as any, baseParams);
308
+ expect.unreachable('should have thrown');
309
+ } catch (err) {
310
+ expect(err).toBeInstanceOf(SolanaSdkError);
311
+ expect((err as SolanaSdkError).code).toBe(ErrorCode.REDEEM_REJECTED);
312
+ }
313
+ });
314
+
315
+ it('should use env override when provided', async () => {
316
+ mockConnection.getAccountInfo.mockImplementation(defaultGetAccountInfo);
317
+
318
+ const { getConfig } = await import('../../const/getConfig');
319
+
320
+ await redeemFn({ publicKey: MOCK_PAYER } as any, {
321
+ ...baseParams,
322
+ env: 'stage',
323
+ });
324
+
325
+ expect(getConfig).toHaveBeenCalledWith('stage');
326
+ });
327
+ });