@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.
- package/README.md +48 -15
- package/dist/index.cjs +1 -1
- package/dist/index.js +37 -31
- package/dist/index2.cjs +57 -54
- package/dist/index2.js +7608 -7206
- package/package.json +2 -2
- package/src/const/errors.ts +0 -4
- package/src/const/getConfig.ts +43 -20
- package/src/const/rpcUrls.ts +2 -2
- package/src/idl/asset_router.json +548 -179
- package/src/idl/consortium.json +24 -43
- package/src/idl/mailbox.json +118 -107
- package/src/index.ts +1 -3
- package/src/services/SolanaServiceImpl.test.ts +123 -0
- package/src/services/SolanaServiceImpl.ts +53 -17
- package/src/stories/components/OutputSelector/OutputSelector.tsx +1 -0
- package/src/types/errors.ts +2 -0
- package/src/utils/createDebugLogger.ts +6 -13
- package/src/utils/errors.ts +2 -0
- package/src/utils/tokenAccount.ts +3 -1
- package/src/utils/transactions.ts +1 -1
- package/src/web3Sdk/claimToken/claimBtcb.ts +37 -28
- package/src/web3Sdk/claimToken/claimLbtcGmp.ts +66 -8
- package/src/web3Sdk/claimToken/claimToken.stories.tsx +2 -2
- package/src/web3Sdk/claimToken/claimToken.ts +20 -16
- package/src/web3Sdk/claimToken/constants.ts +5 -0
- package/src/web3Sdk/claimToken/index.ts +1 -0
- package/src/web3Sdk/claimToken/shared.ts +88 -80
- package/src/web3Sdk/deposit/deposit.stories.tsx +240 -0
- package/src/web3Sdk/deposit/deposit.test.ts +327 -0
- package/src/web3Sdk/deposit/deposit.ts +339 -0
- package/src/web3Sdk/deposit/index.ts +1 -0
- package/src/web3Sdk/getTokenFeeConfig/getTokenFeeConfig.stories.tsx +166 -0
- package/src/web3Sdk/getTokenFeeConfig/getTokenFeeConfig.test.ts +224 -0
- package/src/web3Sdk/getTokenFeeConfig/getTokenFeeConfig.ts +154 -0
- package/src/web3Sdk/getTokenFeeConfig/index.ts +11 -0
- package/src/web3Sdk/index.ts +3 -4
- package/src/web3Sdk/redeem/index.ts +1 -0
- package/src/web3Sdk/redeem/redeem.stories.tsx +226 -0
- package/src/web3Sdk/redeem/redeem.test.ts +327 -0
- package/src/web3Sdk/redeem/redeem.ts +352 -0
- package/src/web3Sdk/redeemToken/redeemBtcb.ts +174 -0
- package/src/web3Sdk/redeemToken/redeemForBtc.stories.tsx +35 -21
- package/src/web3Sdk/redeemToken/redeemForBtc.test.ts +306 -0
- package/src/web3Sdk/redeemToken/redeemForBtc.ts +54 -215
- package/src/web3Sdk/redeemToken/redeemLbtc.ts +174 -0
- package/src/web3Sdk/redeemToken/shared.test.ts +45 -0
- package/src/web3Sdk/redeemToken/shared.ts +97 -0
- package/src/web3Sdk/claimLBTC/claimLBTC.stories.tsx +0 -189
- package/src/web3Sdk/claimLBTC/claimLBTC.ts +0 -225
- package/src/web3Sdk/claimLBTC/index.ts +0 -1
- package/src/web3Sdk/claimLBTC/utils/generateDepositId.ts +0 -75
- package/src/web3Sdk/claimLBTC/utils/index.ts +0 -2
- package/src/web3Sdk/claimLBTC/utils/parseTransactionLogs.ts +0 -44
- package/src/web3Sdk/claimLBTC/utils/payloadUtils.ts +0 -58
- package/src/web3Sdk/claimLBTC/utils/postMintSignatures.ts +0 -50
- package/src/web3Sdk/unstakeLBTC/index.ts +0 -1
- package/src/web3Sdk/unstakeLBTC/unstakeLBTC.stories.tsx +0 -141
- package/src/web3Sdk/unstakeLBTC/unstakeLBTC.ts +0 -140
- /package/src/web3Sdk/{claimLBTC → claimToken}/utils/__tests__/signatureUtils.test.ts +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
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=
|
|
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
|
|
170
|
-
3.
|
|
171
|
-
4.
|
|
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
|
-
|
|
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
|
|