@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,352 @@
1
+ import { BN, Program } from '@coral-xyz/anchor';
2
+ import { Env } from '@lombard.finance/sdk-common';
3
+ import {
4
+ ASSOCIATED_TOKEN_PROGRAM_ID,
5
+ getAssociatedTokenAddress,
6
+ } from '@solana/spl-token';
7
+ import { PublicKey, SystemProgram } from '@solana/web3.js';
8
+
9
+ import { DEFAULT_ENV, getConfig, networkToEnv } from '../../const/getConfig';
10
+ import { getConnection } from '../../const/rpcUrls';
11
+ import { getAssetRouterIdl } from '../../idl/getAssetRouterIdl';
12
+ import { ISolanaWalletProvider, SolanaNetwork } from '../../types';
13
+ import {
14
+ ErrorCode,
15
+ sendAndConfirmTransaction,
16
+ SolanaSdkError,
17
+ } from '../../utils';
18
+ import { createDebugLogger } from '../../utils/createDebugLogger';
19
+ import { getTokenProgramForMint } from '../../utils/tokenAccount';
20
+ import { validateAmount } from '../redeemToken/shared';
21
+
22
+ export interface RedeemParams {
23
+ amount: string;
24
+ /**
25
+ * Owner wallet that receives the routed destination token (e.g. BTC.b).
26
+ * Solana pubkey (base58). The GMP payload uses the ATA for Asset Router
27
+ * `native_mint` from on-chain config and this owner — matching backend/claimer expectations.
28
+ */
29
+ recipient: string;
30
+ /**
31
+ * Source token mint to burn. Defaults to `config.lbtcTokenMint`.
32
+ */
33
+ tokenMint?: string;
34
+ /**
35
+ * Destination Lombard routing chain ID (hex, 32 bytes).
36
+ * Defaults to `config.solanaRoutingChainId` (same-chain redeem).
37
+ */
38
+ toLchainId?: string;
39
+ /**
40
+ * Destination token address / mint. Defaults to `config.btcbTokenMint`.
41
+ */
42
+ toTokenAddress?: string;
43
+ network: SolanaNetwork;
44
+ /**
45
+ * Optional environment override. When provided, used instead of
46
+ * the default `networkToEnv[network]` mapping to resolve config.
47
+ */
48
+ env?: Env;
49
+ rpcUrl?: string;
50
+ debug?: boolean;
51
+ /**
52
+ * Skip preflight transaction simulation before broadcast.
53
+ */
54
+ skipPreflight?: boolean;
55
+ }
56
+
57
+ /**
58
+ * Redeem tokens via Asset Router's generic `redeem` instruction.
59
+ *
60
+ * Burns the source token and sends a GMP message through the Mailbox
61
+ * to route the destination token to the recipient's ATA for `native_mint`.
62
+ *
63
+ * Default flow (all optional params omitted): LBTC → BTC.b on Solana.
64
+ */
65
+ export async function redeem(
66
+ provider: ISolanaWalletProvider,
67
+ params: RedeemParams,
68
+ ): Promise<string> {
69
+ const {
70
+ amount, recipient, network,
71
+ env: envOverride, rpcUrl,
72
+ debug = false, skipPreflight = true,
73
+ } = params;
74
+ const { debugLog, printLogs } = createDebugLogger({ debug });
75
+
76
+ try {
77
+ if (!provider.publicKey) {
78
+ throw new Error('Wallet not connected');
79
+ }
80
+
81
+ const env = envOverride ?? networkToEnv[network] ?? DEFAULT_ENV;
82
+ const config = getConfig(env);
83
+
84
+ if (!config.assetRouter) {
85
+ throw new Error(`Asset Router not configured for network: ${network}`);
86
+ }
87
+ if (!config.mailbox) {
88
+ throw new Error(`Mailbox not configured for network: ${network}`);
89
+ }
90
+ if (!config.solanaRoutingChainId) {
91
+ throw new Error(`Solana routing chain ID not configured for network: ${network}`);
92
+ }
93
+
94
+ // Resolve source token mint (defaults to LBTC)
95
+ const mintAddress = params.tokenMint || config.lbtcTokenMint;
96
+ if (!mintAddress) {
97
+ throw new Error(`Source token mint not configured for network: ${network}`);
98
+ }
99
+
100
+ // Resolve destination chain and token (defaults to Solana + BTC.b)
101
+ const toLchainIdHex = params.toLchainId || config.solanaRoutingChainId;
102
+ const toTokenAddressStr = params.toTokenAddress || config.btcbTokenMint;
103
+ if (!toTokenAddressStr) {
104
+ throw new Error(`Destination token not configured for network: ${network}`);
105
+ }
106
+
107
+ validateAmount(amount);
108
+
109
+ const connection = getConnection(network, rpcUrl);
110
+ const payer = new PublicKey(provider.publicKey);
111
+ const mint = new PublicKey(mintAddress);
112
+ const recipientPubkey = new PublicKey(recipient);
113
+ const assetRouterProgramId = new PublicKey(config.assetRouter);
114
+ const mailboxProgramId = new PublicKey(config.mailbox);
115
+ const solanaRoutingChainId = Buffer.from(config.solanaRoutingChainId, 'hex');
116
+ const toLchainId = Buffer.from(toLchainIdHex, 'hex');
117
+ const toTokenAddress = new PublicKey(toTokenAddressStr).toBytes();
118
+
119
+ debugLog('Payer:', payer.toBase58());
120
+ debugLog('Mint (source):', mint.toBase58());
121
+ debugLog('Destination token (route arg):', toTokenAddressStr);
122
+ debugLog('Recipient (owner):', recipientPubkey.toBase58());
123
+ debugLog('Amount:', amount);
124
+
125
+ // ── Detect token program (Token vs Token-2022) ──
126
+ const tokenProgramId = await getTokenProgramForMint(connection, mint);
127
+ debugLog('Token program:', tokenProgramId.toBase58());
128
+
129
+ // ── Asset Router PDAs ──
130
+ const [assetRouterConfigPDA] = PublicKey.findProgramAddressSync(
131
+ [Buffer.from('asset_router_config')],
132
+ assetRouterProgramId,
133
+ );
134
+ const [tokenConfigPDA] = PublicKey.findProgramAddressSync(
135
+ [Buffer.from('token_config'), mint.toBuffer()],
136
+ assetRouterProgramId,
137
+ );
138
+ const [tokenRoutePDA] = PublicKey.findProgramAddressSync(
139
+ [
140
+ Buffer.from('token_route'),
141
+ solanaRoutingChainId,
142
+ mint.toBuffer(),
143
+ toLchainId,
144
+ Buffer.from(toTokenAddress),
145
+ ],
146
+ assetRouterProgramId,
147
+ );
148
+ const [messagingAuthorityPDA] = PublicKey.findProgramAddressSync(
149
+ [Buffer.from('messaging_authority')],
150
+ assetRouterProgramId,
151
+ );
152
+
153
+ debugLog('Asset Router config PDA:', assetRouterConfigPDA.toBase58());
154
+ debugLog('Token config PDA:', tokenConfigPDA.toBase58());
155
+ debugLog('Token route PDA:', tokenRoutePDA.toBase58());
156
+ debugLog('Messaging authority PDA:', messagingAuthorityPDA.toBase58());
157
+
158
+ // ── Mailbox PDAs ──
159
+ const [mailboxConfigPDA] = PublicKey.findProgramAddressSync(
160
+ [Buffer.from('mailbox_config')],
161
+ mailboxProgramId,
162
+ );
163
+ if (!config.ledgerChainId) {
164
+ throw new Error(`Ledger chain ID not configured for network: ${network}`);
165
+ }
166
+ const ledgerChainId = Buffer.from(config.ledgerChainId, 'hex');
167
+ const [outboundMessagePathPDA] = PublicKey.findProgramAddressSync(
168
+ [Buffer.from('outbound_message_path'), ledgerChainId],
169
+ mailboxProgramId,
170
+ );
171
+ const [senderConfigPDA] = PublicKey.findProgramAddressSync(
172
+ [Buffer.from('sender_config'), assetRouterProgramId.toBuffer()],
173
+ mailboxProgramId,
174
+ );
175
+
176
+ debugLog('Mailbox config PDA:', mailboxConfigPDA.toBase58());
177
+ debugLog('Outbound message path PDA:', outboundMessagePathPDA.toBase58());
178
+ debugLog('Sender config PDA:', senderConfigPDA.toBase58());
179
+
180
+ // ── Read on-chain state ──
181
+ const [arConfigInfo, mailboxConfigInfo] = await Promise.all([
182
+ connection.getAccountInfo(assetRouterConfigPDA),
183
+ connection.getAccountInfo(mailboxConfigPDA),
184
+ ]);
185
+
186
+ if (!arConfigInfo) {
187
+ throw new Error('Asset Router config account not found');
188
+ }
189
+ if (!mailboxConfigInfo) {
190
+ throw new Error('Mailbox config account not found');
191
+ }
192
+
193
+ if (arConfigInfo.data.length < 137) {
194
+ throw new Error(
195
+ `Asset Router config account data too short: expected >= 137 bytes (treasury, paused, native_mint), got ${arConfigInfo.data.length}`,
196
+ );
197
+ }
198
+ const arTreasury = new PublicKey(arConfigInfo.data.subarray(72, 104));
199
+ const paused = arConfigInfo.data[104] !== 0;
200
+ const nativeMintFromConfig = new PublicKey(
201
+ arConfigInfo.data.subarray(105, 137),
202
+ );
203
+ if (paused) {
204
+ throw new Error('Asset Router is paused');
205
+ }
206
+ debugLog('Asset Router treasury:', arTreasury.toBase58());
207
+ debugLog('Native mint (recipient ATA mint):', nativeMintFromConfig.toBase58());
208
+
209
+ if (mailboxConfigInfo.data.length < 104) {
210
+ throw new Error(
211
+ `Mailbox config account data too short: expected >= 104 bytes, got ${mailboxConfigInfo.data.length}`,
212
+ );
213
+ }
214
+ const mailboxTreasury = new PublicKey(
215
+ mailboxConfigInfo.data.subarray(72, 104),
216
+ );
217
+ debugLog('Mailbox treasury:', mailboxTreasury.toBase58());
218
+
219
+ // ── Token accounts ──
220
+ const payerTokenAccount = await getAssociatedTokenAddress(
221
+ mint,
222
+ payer,
223
+ false,
224
+ tokenProgramId,
225
+ ASSOCIATED_TOKEN_PROGRAM_ID,
226
+ );
227
+ const treasuryTokenAccount = await getAssociatedTokenAddress(
228
+ mint,
229
+ arTreasury,
230
+ true,
231
+ tokenProgramId,
232
+ ASSOCIATED_TOKEN_PROGRAM_ID,
233
+ );
234
+
235
+ const destinationTokenProgramId = await getTokenProgramForMint(
236
+ connection,
237
+ nativeMintFromConfig,
238
+ );
239
+ const recipientTokenAccount = await getAssociatedTokenAddress(
240
+ nativeMintFromConfig,
241
+ recipientPubkey,
242
+ false,
243
+ destinationTokenProgramId,
244
+ ASSOCIATED_TOKEN_PROGRAM_ID,
245
+ );
246
+
247
+ debugLog('Payer token account:', payerTokenAccount.toBase58());
248
+ debugLog('Treasury token account:', treasuryTokenAccount.toBase58());
249
+ debugLog('Recipient token account (payload):', recipientTokenAccount.toBase58());
250
+
251
+ // ── Balance check ──
252
+ const tokenBalance = await connection.getTokenAccountBalance(payerTokenAccount);
253
+ const userBalance = BigInt(tokenBalance.value.amount);
254
+ const parsedAmount = BigInt(amount);
255
+ if (userBalance < parsedAmount) {
256
+ throw new Error(
257
+ `Insufficient balance: have ${tokenBalance.value.uiAmountString}, need ${Number(parsedAmount) / 1e8}`,
258
+ );
259
+ }
260
+
261
+ const assetRouterProgram = new Program(
262
+ getAssetRouterIdl(env),
263
+ { connection },
264
+ );
265
+
266
+ // ── Instruction args ──
267
+ const toLchainIdArray = Array.from(toLchainId);
268
+ const toTokenAddressArray = Array.from(toTokenAddress);
269
+ const recipientArray = Array.from(recipientTokenAccount.toBytes());
270
+
271
+ // ── Build & send with nonce retry ──
272
+ const MAX_NONCE_RETRIES = 3;
273
+ for (let attempt = 0; attempt < MAX_NONCE_RETRIES; attempt++) {
274
+ const freshMailboxConfig = await connection.getAccountInfo(mailboxConfigPDA);
275
+ if (!freshMailboxConfig) {
276
+ throw new Error('Mailbox config account not found');
277
+ }
278
+ if (freshMailboxConfig.data.length < 145) {
279
+ throw new Error(
280
+ `Mailbox config account data too short: expected >= 145 bytes, got ${freshMailboxConfig.data.length}`,
281
+ );
282
+ }
283
+ const globalNonce = freshMailboxConfig.data.readBigUInt64LE(137);
284
+ const nonceBuf = Buffer.alloc(8);
285
+ nonceBuf.writeBigUInt64BE(globalNonce);
286
+
287
+ const [outboundMessagePDA] = PublicKey.findProgramAddressSync(
288
+ [Buffer.from('outbound_message'), nonceBuf],
289
+ mailboxProgramId,
290
+ );
291
+
292
+ debugLog(`Attempt ${attempt + 1}: global nonce=${globalNonce}, outbound_message=${outboundMessagePDA.toBase58()}`);
293
+
294
+ const tx = await assetRouterProgram.methods
295
+ .redeem(toLchainIdArray, toTokenAddressArray, recipientArray, new BN(amount))
296
+ .accounts({
297
+ payer,
298
+ config: assetRouterConfigPDA,
299
+ tokenConfig: tokenConfigPDA,
300
+ tokenRoute: tokenRoutePDA,
301
+ payerTokenAccount,
302
+ tokenProgram: tokenProgramId,
303
+ mint,
304
+ treasuryTokenAccount,
305
+ messagingAuthority: messagingAuthorityPDA,
306
+ mailbox: mailboxProgramId,
307
+ mailboxConfig: mailboxConfigPDA,
308
+ outboundMessagePath: outboundMessagePathPDA,
309
+ outboundMessage: outboundMessagePDA,
310
+ senderConfig: senderConfigPDA,
311
+ treasury: mailboxTreasury,
312
+ systemProgram: SystemProgram.programId,
313
+ })
314
+ .transaction();
315
+
316
+ debugLog('Instruction account count:', tx.instructions[0]?.keys.length);
317
+
318
+ try {
319
+ const { signature } = await sendAndConfirmTransaction({
320
+ instruction: tx,
321
+ connection,
322
+ provider,
323
+ debugLabel: 'Asset Router redeem',
324
+ skipPreflight,
325
+ });
326
+
327
+ debugLog('redeem completed, signature:', signature);
328
+ return signature;
329
+ } catch (err: unknown) {
330
+ const isNonceError =
331
+ err instanceof Error &&
332
+ err.message.includes('0x7d6'); // ConstraintSeeds
333
+ if (isNonceError && attempt < MAX_NONCE_RETRIES - 1) {
334
+ debugLog(`Nonce stale (ConstraintSeeds), retrying...`);
335
+ continue;
336
+ }
337
+ throw err;
338
+ }
339
+ }
340
+
341
+ throw new Error('Failed after max nonce retries');
342
+ } catch (error: unknown) {
343
+ if (error instanceof Error && debug) {
344
+ error.message = `${error.message}\n\nDebug logs:\n${printLogs()}`;
345
+ }
346
+ throw SolanaSdkError.wrap(
347
+ error,
348
+ ErrorCode.REDEEM_REJECTED,
349
+ 'redeem operation failed',
350
+ );
351
+ }
352
+ }
@@ -0,0 +1,174 @@
1
+ import { BN } from '@coral-xyz/anchor';
2
+ import {
3
+ ASSOCIATED_TOKEN_PROGRAM_ID,
4
+ getAssociatedTokenAddress,
5
+ } from '@solana/spl-token';
6
+ import { PublicKey, SystemProgram } from '@solana/web3.js';
7
+
8
+ import { sendAndConfirmTransaction } from '../../utils';
9
+ import { BTC_NATIVE_TOKEN_ADDRESS, RedeemContext } from './shared';
10
+
11
+ /**
12
+ * BTC.b → BTC redemption via Asset Router's `redeem_for_btc`.
13
+ *
14
+ * Burns BTC.b tokens and sends a GMP message through the Mailbox to trigger
15
+ * a BTC payout to the specified Bitcoin address.
16
+ */
17
+ export async function redeemBtcbForBtc(ctx: RedeemContext): Promise<string> {
18
+ const {
19
+ provider, params, config, connection,
20
+ payer, mint, tokenProgramId, scriptPubKey,
21
+ assetRouterProgramId, mailboxProgramId,
22
+ solanaRoutingChainId, bitcoinRoutingChainId,
23
+ assetRouterProgram, assetRouterConfigPDA, mailboxConfigPDA,
24
+ arTreasury, mailboxTreasury,
25
+ debugLog,
26
+ } = ctx;
27
+
28
+ const { amount, skipPreflight = false } = params;
29
+
30
+ // ── BTC.b-specific PDAs ──
31
+ const [tokenConfigPDA] = PublicKey.findProgramAddressSync(
32
+ [Buffer.from('token_config'), mint.toBuffer()],
33
+ assetRouterProgramId,
34
+ );
35
+ const [tokenRoutePDA] = PublicKey.findProgramAddressSync(
36
+ [
37
+ Buffer.from('token_route'),
38
+ solanaRoutingChainId,
39
+ mint.toBuffer(),
40
+ bitcoinRoutingChainId,
41
+ BTC_NATIVE_TOKEN_ADDRESS,
42
+ ],
43
+ assetRouterProgramId,
44
+ );
45
+ const [messagingAuthorityPDA] = PublicKey.findProgramAddressSync(
46
+ [Buffer.from('messaging_authority')],
47
+ assetRouterProgramId,
48
+ );
49
+
50
+ debugLog('Token config PDA:', tokenConfigPDA.toBase58());
51
+ debugLog('Token route PDA:', tokenRoutePDA.toBase58());
52
+ debugLog('Messaging authority PDA:', messagingAuthorityPDA.toBase58());
53
+
54
+ // ── Mailbox PDAs ──
55
+ if (!config.ledgerChainId) {
56
+ throw new Error(`Ledger chain ID not configured for network: ${params.network}`);
57
+ }
58
+ const ledgerChainId = Buffer.from(config.ledgerChainId, 'hex');
59
+ const [outboundMessagePathPDA] = PublicKey.findProgramAddressSync(
60
+ [Buffer.from('outbound_message_path'), ledgerChainId],
61
+ mailboxProgramId,
62
+ );
63
+ const [senderConfigPDA] = PublicKey.findProgramAddressSync(
64
+ [Buffer.from('sender_config'), assetRouterProgramId.toBuffer()],
65
+ mailboxProgramId,
66
+ );
67
+
68
+ debugLog('Outbound message path PDA:', outboundMessagePathPDA.toBase58());
69
+ debugLog('Sender config PDA:', senderConfigPDA.toBase58());
70
+
71
+ // ── Token accounts ──
72
+ const payerTokenAccount = await getAssociatedTokenAddress(
73
+ mint,
74
+ payer,
75
+ false,
76
+ tokenProgramId,
77
+ ASSOCIATED_TOKEN_PROGRAM_ID,
78
+ );
79
+ const treasuryTokenAccount = await getAssociatedTokenAddress(
80
+ mint,
81
+ arTreasury,
82
+ true,
83
+ tokenProgramId,
84
+ ASSOCIATED_TOKEN_PROGRAM_ID,
85
+ );
86
+
87
+ debugLog('Payer token account:', payerTokenAccount.toBase58());
88
+ debugLog('Treasury token account:', treasuryTokenAccount.toBase58());
89
+
90
+ // ── Balance check ──
91
+ const tokenBalance = await connection.getTokenAccountBalance(payerTokenAccount);
92
+ const userBalance = BigInt(tokenBalance.value.amount);
93
+ const parsedAmount = BigInt(amount);
94
+ if (userBalance < parsedAmount) {
95
+ throw new Error(
96
+ `Insufficient BTC.b balance: have ${tokenBalance.value.uiAmountString}, need ${Number(parsedAmount) / 1e8}`,
97
+ );
98
+ }
99
+
100
+ // ── Build & send with nonce retry ──
101
+ // The outbound_message PDA depends on global_nonce which can change between
102
+ // reads. Retry up to 3 times if the nonce becomes stale.
103
+ const MAX_NONCE_RETRIES = 3;
104
+ for (let attempt = 0; attempt < MAX_NONCE_RETRIES; attempt++) {
105
+ const freshMailboxConfig = await connection.getAccountInfo(mailboxConfigPDA);
106
+ if (!freshMailboxConfig) {
107
+ throw new Error('Mailbox config account not found');
108
+ }
109
+ // global_nonce is a u64 at offset 137; need 137 + 8 = 145 bytes
110
+ if (freshMailboxConfig.data.length < 145) {
111
+ throw new Error(
112
+ `Mailbox config account data too short: expected >= 145 bytes, got ${freshMailboxConfig.data.length}`,
113
+ );
114
+ }
115
+ const globalNonce = freshMailboxConfig.data.readBigUInt64LE(137);
116
+ const nonceBuf = Buffer.alloc(8);
117
+ nonceBuf.writeBigUInt64BE(globalNonce);
118
+
119
+ const [outboundMessagePDA] = PublicKey.findProgramAddressSync(
120
+ [Buffer.from('outbound_message'), nonceBuf],
121
+ mailboxProgramId,
122
+ );
123
+
124
+ debugLog(`Attempt ${attempt + 1}: global nonce=${globalNonce}, outbound_message=${outboundMessagePDA.toBase58()}`);
125
+
126
+ const tx = await assetRouterProgram.methods
127
+ .redeemForBtc(scriptPubKey, new BN(amount))
128
+ .accounts({
129
+ payer,
130
+ config: assetRouterConfigPDA,
131
+ tokenConfig: tokenConfigPDA,
132
+ tokenRoute: tokenRoutePDA,
133
+ payerTokenAccount,
134
+ tokenProgram: tokenProgramId,
135
+ mint,
136
+ treasuryTokenAccount,
137
+ messagingAuthority: messagingAuthorityPDA,
138
+ mailbox: mailboxProgramId,
139
+ mailboxConfig: mailboxConfigPDA,
140
+ outboundMessagePath: outboundMessagePathPDA,
141
+ outboundMessage: outboundMessagePDA,
142
+ senderConfig: senderConfigPDA,
143
+ treasury: mailboxTreasury,
144
+ systemProgram: SystemProgram.programId,
145
+ })
146
+ .transaction();
147
+
148
+ debugLog('Instruction account count:', tx.instructions[0]?.keys.length);
149
+
150
+ try {
151
+ const { signature } = await sendAndConfirmTransaction({
152
+ instruction: tx,
153
+ connection,
154
+ provider,
155
+ debugLabel: 'Asset Router redeem_for_btc',
156
+ skipPreflight,
157
+ });
158
+
159
+ debugLog('redeem_for_btc completed, signature:', signature);
160
+ return signature;
161
+ } catch (err: unknown) {
162
+ const isNonceError =
163
+ err instanceof Error &&
164
+ err.message.includes('0x7d6'); // ConstraintSeeds
165
+ if (isNonceError && attempt < MAX_NONCE_RETRIES - 1) {
166
+ debugLog(`Nonce stale (ConstraintSeeds), retrying...`);
167
+ continue;
168
+ }
169
+ throw err;
170
+ }
171
+ }
172
+
173
+ throw new Error('Failed after max nonce retries');
174
+ }
@@ -2,7 +2,7 @@ import { Env } from '@lombard.finance/sdk-common';
2
2
  import type { Meta, StoryObj } from '@storybook/react';
3
3
  import { useState } from 'react';
4
4
 
5
- import { envToNetwork } from '../../const/getConfig';
5
+ import { envToNetwork, getConfig } from '../../const/getConfig';
6
6
  import {
7
7
  Button,
8
8
  CodeBlock,
@@ -16,20 +16,27 @@ import { useConnect } from '../../stories/hooks/useConnect';
16
16
  import useQuery from '../../stories/hooks/useQuery';
17
17
  import { redeemForBtc } from './redeemForBtc';
18
18
 
19
+ type RedeemToken = 'BTC.b' | 'LBTC';
20
+
19
21
  interface RedeemForBtcStoryArgs {
20
22
  environment: Env;
21
23
  amount: string;
22
24
  btcAddress: string;
23
- tokenMint: string;
25
+ token: RedeemToken;
24
26
  }
25
27
 
28
+ const getTokenMint = (token: RedeemToken, config: ReturnType<typeof getConfig>): string | undefined =>
29
+ token === 'LBTC' ? config.lbtcTokenMint : config.btcbTokenMint ?? undefined;
30
+
26
31
  export const StoryView = ({
27
32
  environment,
28
33
  amount,
29
34
  btcAddress,
30
- tokenMint,
35
+ token,
31
36
  }: RedeemForBtcStoryArgs) => {
32
37
  const network = envToNetwork[environment];
38
+ const config = getConfig(environment);
39
+ const tokenMint = getTokenMint(token, config);
33
40
  const [transactionLogs, setTransactionLogs] = useState<string[] | null>(null);
34
41
 
35
42
  const {
@@ -53,12 +60,18 @@ export const StoryView = ({
53
60
 
54
61
  const amountSats = Math.round(parsedAmount * 1e8).toString();
55
62
 
63
+ if (!tokenMint) {
64
+ throw new Error(
65
+ 'No mint for the selected token in this environment (e.g. BTC.b may be unset on mainnet).',
66
+ );
67
+ }
68
+
56
69
  setTransactionLogs(null);
57
70
  try {
58
71
  const result = await redeemForBtc(provider, {
59
72
  amount: amountSats,
60
73
  btcAddress,
61
- tokenMint: tokenMint || undefined,
74
+ tokenMint,
62
75
  network,
63
76
  env: environment,
64
77
  debug: true,
@@ -80,7 +93,7 @@ export const StoryView = ({
80
93
  refetch: handleRedeem,
81
94
  } = useQuery(
82
95
  request,
83
- [provider, address, amount, btcAddress, tokenMint, environment],
96
+ [provider, address, amount, btcAddress, token, environment],
84
97
  false,
85
98
  );
86
99
 
@@ -112,11 +125,10 @@ export const StoryView = ({
112
125
  <p>
113
126
  <strong>BTC Address:</strong> {btcAddress || <em>Not set</em>}
114
127
  </p>
115
- {tokenMint && (
116
- <p>
117
- <strong>Token Mint Override:</strong> {tokenMint}
118
- </p>
119
- )}
128
+ <p>
129
+ <strong>Token:</strong> {token}
130
+ {tokenMint ? ` (${tokenMint})` : ' — not configured'}
131
+ </p>
120
132
  </SectionCard>
121
133
 
122
134
  <div className="d-grid gap-2 my-4">
@@ -133,7 +145,7 @@ export const StoryView = ({
133
145
  <ResultDisplay
134
146
  result={txHash}
135
147
  title="Redeem Transaction Hash"
136
- successMessage="Success! BTC.b → BTC redemption transaction submitted."
148
+ successMessage={`Success! ${token} → BTC redemption transaction submitted.`}
137
149
  />
138
150
  )}
139
151
  {(error || connectError) && (
@@ -162,13 +174,14 @@ const meta: Meta<typeof StoryView> = {
162
174
  parameters: {
163
175
  docs: {
164
176
  description: {
165
- component: `Demonstrates redeeming BTC.b → BTC via the Asset Router's \`redeem_for_btc\` instruction.
177
+ component: `Demonstrates redeeming BTC.b or LBTC → BTC via the Asset Router's \`redeem_for_btc\` instruction.
166
178
 
167
179
  **Flow:**
168
- 1. Connect a Solana wallet holding BTC.b tokens
169
- 2. Enter the destination Bitcoin address and amount (in satoshis)
170
- 3. Call \`redeemForBtc\` burns BTC.b and sends a GMP message through the Mailbox
171
- 4. The Lombard protocol processes the GMP message and sends BTC to the specified address`,
180
+ 1. Connect a Solana wallet holding BTC.b or LBTC tokens
181
+ 2. Enter the destination Bitcoin address and amount (in BTC)
182
+ 3. Optionally set the token mint to the LBTC address to redeem LBTC instead of BTC.b
183
+ 4. Call \`redeemForBtc\` burns the token and sends a GMP message through the Mailbox
184
+ 5. The Lombard protocol processes the GMP message and sends BTC to the specified address`,
172
185
  },
173
186
  },
174
187
  },
@@ -176,13 +189,18 @@ const meta: Meta<typeof StoryView> = {
176
189
  environment: Env.stage,
177
190
  amount: '0.0002',
178
191
  btcAddress: '',
179
- tokenMint: '',
192
+ token: 'BTC.b',
180
193
  },
181
194
  argTypes: {
182
195
  environment: {
183
196
  control: { type: 'select' },
184
197
  options: Object.values(Env),
185
198
  },
199
+ token: {
200
+ control: { type: 'select' },
201
+ options: ['BTC.b', 'LBTC'] satisfies RedeemToken[],
202
+ description: 'Token to redeem for BTC',
203
+ },
186
204
  amount: {
187
205
  control: { type: 'text' },
188
206
  description: 'Amount to redeem in BTC (e.g. 0.0002)',
@@ -191,10 +209,6 @@ const meta: Meta<typeof StoryView> = {
191
209
  control: { type: 'text' },
192
210
  description: 'Destination Bitcoin address (taproot, segwit, etc.)',
193
211
  },
194
- tokenMint: {
195
- control: { type: 'text' },
196
- description: 'BTC.b mint address override (leave empty for default)',
197
- },
198
212
  },
199
213
  };
200
214