@waiaas/adapter-ripple 2.13.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.
@@ -0,0 +1,802 @@
1
+ /**
2
+ * RippleAdapter -- IChainAdapter implementation for XRP Ledger.
3
+ *
4
+ * Uses xrpl.js v4.x Client for WebSocket RPC communication.
5
+ * Supports native XRP transfers, balance queries, fee estimation, and nonce retrieval.
6
+ * Trust Line tokens (Phase 472), NFTs (Phase 473) will extend this adapter.
7
+ *
8
+ * @see Phase 471-01 (scaffold + connection + balance + fee + nonce)
9
+ * @see Phase 471-02 (transaction pipeline + AdapterPool wiring)
10
+ */
11
+ import xrpl from 'xrpl';
12
+ const { Client, Wallet } = xrpl;
13
+ import { ChainError } from '@waiaas/core';
14
+ import { isXAddress, decodeXAddress, XRP_DECIMALS, DROPS_PER_XRP } from './address-utils.js';
15
+ import { parseTrustLineToken, smallestUnitToIou, iouToSmallestUnit, IOU_DECIMALS } from './currency-utils.js';
16
+ import { parseRippleTransaction } from './tx-parser.js';
17
+ import { createPrivateKey, createPublicKey } from 'node:crypto';
18
+ import rippleKeypairsDefault from 'ripple-keypairs';
19
+ // CJS default export compat (same pattern as xrpl import)
20
+ const { deriveAddress } = rippleKeypairsDefault.default ?? rippleKeypairsDefault;
21
+ /** Average ledger close time in milliseconds (~3.5-4s). */
22
+ const LEDGER_CLOSE_MS = 4000;
23
+ /** Fee safety margin: 120% of base fee per project convention. */
24
+ const FEE_SAFETY_NUMERATOR = 120n;
25
+ const FEE_SAFETY_DENOMINATOR = 100n;
26
+ export class RippleAdapter {
27
+ chain = 'ripple';
28
+ network;
29
+ client = null;
30
+ _connected = false;
31
+ serverInfo = null;
32
+ constructor(network) {
33
+ this.network = network;
34
+ }
35
+ // -- Connection management (4) --
36
+ async connect(rpcUrl) {
37
+ if (this.client) {
38
+ try {
39
+ await this.client.disconnect();
40
+ }
41
+ catch {
42
+ // ignore
43
+ }
44
+ }
45
+ this.client = new Client(rpcUrl, { connectionTimeout: 10000 });
46
+ await this.client.connect();
47
+ this._connected = true;
48
+ // Fetch initial server info for reserve values
49
+ await this.refreshServerInfo();
50
+ }
51
+ async disconnect() {
52
+ if (this.client) {
53
+ try {
54
+ await this.client.disconnect();
55
+ }
56
+ catch {
57
+ // ignore
58
+ }
59
+ this.client = null;
60
+ }
61
+ this._connected = false;
62
+ this.serverInfo = null;
63
+ }
64
+ isConnected() {
65
+ return this._connected && this.client?.isConnected() === true;
66
+ }
67
+ async getHealth() {
68
+ const start = Date.now();
69
+ try {
70
+ const client = this.getClient();
71
+ const response = await client.request({ command: 'server_info' });
72
+ const latencyMs = Date.now() - start;
73
+ const info = response.result.info;
74
+ const ledgerIndex = info.validated_ledger?.seq ?? 0;
75
+ return {
76
+ healthy: true,
77
+ latencyMs,
78
+ blockHeight: BigInt(ledgerIndex),
79
+ };
80
+ }
81
+ catch (_err) {
82
+ return {
83
+ healthy: false,
84
+ latencyMs: Date.now() - start,
85
+ };
86
+ }
87
+ }
88
+ // -- Balance query (1) --
89
+ async getBalance(address) {
90
+ const client = this.getClient();
91
+ // Decode X-address if needed
92
+ let classicAddress = address;
93
+ if (isXAddress(address)) {
94
+ const decoded = decodeXAddress(address);
95
+ classicAddress = decoded.classicAddress;
96
+ }
97
+ try {
98
+ const response = await client.request({
99
+ command: 'account_info',
100
+ account: classicAddress,
101
+ ledger_index: 'validated',
102
+ });
103
+ const balance = BigInt(response.result.account_data.Balance);
104
+ return {
105
+ address: classicAddress,
106
+ balance,
107
+ decimals: XRP_DECIMALS,
108
+ symbol: 'XRP',
109
+ };
110
+ }
111
+ catch (err) {
112
+ // Handle "Account not found" (actNotFound)
113
+ if (this.isActNotFound(err)) {
114
+ return {
115
+ address: classicAddress,
116
+ balance: 0n,
117
+ decimals: XRP_DECIMALS,
118
+ symbol: 'XRP',
119
+ };
120
+ }
121
+ throw this.mapError(err);
122
+ }
123
+ }
124
+ // -- Transaction 4-stage pipeline (4) --
125
+ async buildTransaction(request) {
126
+ const client = this.getClient();
127
+ // Decode destination address
128
+ let destinationAddress = request.to;
129
+ let destinationTag;
130
+ if (isXAddress(request.to)) {
131
+ const decoded = decodeXAddress(request.to);
132
+ destinationAddress = decoded.classicAddress;
133
+ if (decoded.tag !== false) {
134
+ destinationTag = decoded.tag;
135
+ }
136
+ }
137
+ // Parse Destination Tag from memo
138
+ if (request.memo) {
139
+ const memoTag = this.parseDestinationTag(request.memo);
140
+ if (memoTag !== undefined) {
141
+ // Explicit memo tag takes priority over X-address tag
142
+ destinationTag = memoTag;
143
+ }
144
+ }
145
+ // Build XRPL Payment transaction
146
+ const payment = {
147
+ TransactionType: 'Payment',
148
+ Account: request.from,
149
+ Destination: destinationAddress,
150
+ Amount: request.amount.toString(), // drops as string
151
+ ...(destinationTag !== undefined && { DestinationTag: destinationTag }),
152
+ };
153
+ // autofill populates Sequence, Fee, LastLedgerSequence
154
+ const autofilled = await client.autofill(payment);
155
+ // Apply fee safety margin: (Fee * 120) / 100
156
+ const baseFee = BigInt(autofilled.Fee ?? '12');
157
+ const safeFee = (baseFee * FEE_SAFETY_NUMERATOR) / FEE_SAFETY_DENOMINATOR;
158
+ autofilled.Fee = safeFee.toString();
159
+ // Serialize to JSON bytes
160
+ const txJson = JSON.stringify(autofilled);
161
+ const serialized = new TextEncoder().encode(txJson);
162
+ // Calculate approximate expiry from LastLedgerSequence
163
+ const lastLedgerSeq = autofilled.LastLedgerSequence ?? 0;
164
+ const currentLedger = this.serverInfo?.ledgerIndex ?? 0;
165
+ const ledgersRemaining = lastLedgerSeq - currentLedger;
166
+ const expiresAt = new Date(Date.now() + ledgersRemaining * LEDGER_CLOSE_MS);
167
+ return {
168
+ chain: 'ripple',
169
+ serialized,
170
+ estimatedFee: safeFee,
171
+ expiresAt,
172
+ metadata: {
173
+ Sequence: autofilled.Sequence,
174
+ LastLedgerSequence: autofilled.LastLedgerSequence,
175
+ Fee: autofilled.Fee,
176
+ DestinationTag: destinationTag,
177
+ originalTx: autofilled,
178
+ },
179
+ nonce: autofilled.Sequence,
180
+ };
181
+ }
182
+ async simulateTransaction(tx) {
183
+ const client = this.getClient();
184
+ try {
185
+ // Deserialize transaction JSON
186
+ const txJson = new TextDecoder().decode(tx.serialized);
187
+ const txObj = JSON.parse(txJson);
188
+ // Use autofill as dry-run validation
189
+ await client.autofill(txObj);
190
+ return {
191
+ success: true,
192
+ logs: ['autofill validation passed'],
193
+ };
194
+ }
195
+ catch (err) {
196
+ return {
197
+ success: false,
198
+ logs: [],
199
+ error: err instanceof Error ? err.message : String(err),
200
+ };
201
+ }
202
+ }
203
+ async signTransaction(tx, privateKey) {
204
+ // Deserialize transaction JSON
205
+ const txJson = new TextDecoder().decode(tx.serialized);
206
+ const txObj = JSON.parse(txJson);
207
+ // The privateKey from KeyStore is the 32-byte Ed25519 seed (from sodium-native).
208
+ // Reconstruct the full Ed25519 keypair from seed using sodium-native,
209
+ // then build an xrpl Wallet with the raw key hex.
210
+ // NOTE: Wallet.fromEntropy() uses XRPL-specific key derivation (HMAC-SHA512)
211
+ // which produces a DIFFERENT keypair than sodium-native Ed25519.
212
+ const wallet = walletFromSodiumSeed(privateKey);
213
+ // Verify wallet address matches transaction Account
214
+ const txAccount = txObj['Account'];
215
+ if (wallet.address !== txAccount) {
216
+ throw new ChainError('WALLET_NOT_SIGNER', 'ripple', {
217
+ message: `Wallet address ${wallet.address} does not match transaction Account ${txAccount}`,
218
+ });
219
+ }
220
+ // Sign the transaction
221
+ const { tx_blob } = wallet.sign(txObj);
222
+ // Encode tx_blob hex to Uint8Array
223
+ return new Uint8Array(Buffer.from(tx_blob, 'hex'));
224
+ }
225
+ async submitTransaction(signedTx) {
226
+ const client = this.getClient();
227
+ // Convert signedTx bytes back to hex string
228
+ const txBlob = Buffer.from(signedTx).toString('hex').toUpperCase();
229
+ const response = await client.request({
230
+ command: 'submit',
231
+ tx_blob: txBlob,
232
+ });
233
+ const result = response.result;
234
+ const engineResult = result['engine_result'];
235
+ const txJson = result['tx_json'];
236
+ const txHash = txJson?.['hash'] ?? '';
237
+ // Check result
238
+ if (engineResult === 'tesSUCCESS' || engineResult.startsWith('tec')) {
239
+ return {
240
+ txHash,
241
+ status: 'submitted',
242
+ };
243
+ }
244
+ // Rejected transactions
245
+ throw new ChainError('CONTRACT_EXECUTION_FAILED', 'ripple', {
246
+ message: `Transaction rejected: ${engineResult} - ${result['engine_result_message'] ?? ''}`,
247
+ });
248
+ }
249
+ async waitForConfirmation(txHash, timeoutMs = 30000) {
250
+ const client = this.getClient();
251
+ const deadline = Date.now() + timeoutMs;
252
+ while (Date.now() < deadline) {
253
+ try {
254
+ const response = await client.request({
255
+ command: 'tx',
256
+ transaction: txHash,
257
+ });
258
+ const result = response.result;
259
+ const validated = result['validated'];
260
+ if (validated) {
261
+ const meta = result['meta'];
262
+ const txResult = (meta?.['TransactionResult'] ?? 'tesSUCCESS');
263
+ const ledgerIndex = result['ledger_index'];
264
+ const fee = result['Fee'];
265
+ return {
266
+ txHash,
267
+ status: txResult === 'tesSUCCESS' ? 'confirmed' : 'failed',
268
+ blockNumber: ledgerIndex !== undefined ? BigInt(ledgerIndex) : undefined,
269
+ fee: fee !== undefined ? BigInt(fee) : undefined,
270
+ };
271
+ }
272
+ }
273
+ catch (err) {
274
+ // Transaction not found yet, continue polling
275
+ if (!this.isActNotFound(err) && !this.isTxNotFound(err)) {
276
+ throw this.mapError(err);
277
+ }
278
+ }
279
+ // Wait before retrying
280
+ await new Promise((resolve) => setTimeout(resolve, 2000));
281
+ }
282
+ // Timeout -- still pending
283
+ return {
284
+ txHash,
285
+ status: 'submitted',
286
+ };
287
+ }
288
+ // -- Asset query (1) --
289
+ async getAssets(address) {
290
+ const client = this.getClient();
291
+ // Decode X-address if needed
292
+ let classicAddress = address;
293
+ if (isXAddress(address)) {
294
+ const decoded = decodeXAddress(address);
295
+ classicAddress = decoded.classicAddress;
296
+ }
297
+ const assets = [];
298
+ // 1. Native XRP balance
299
+ const balanceInfo = await this.getBalance(classicAddress);
300
+ assets.push({
301
+ mint: 'native',
302
+ symbol: 'XRP',
303
+ name: 'XRP',
304
+ balance: balanceInfo.balance,
305
+ decimals: XRP_DECIMALS,
306
+ isNative: true,
307
+ });
308
+ // 2. Trust Line tokens via account_lines
309
+ try {
310
+ const response = await client.request({
311
+ command: 'account_lines',
312
+ account: classicAddress,
313
+ ledger_index: 'validated',
314
+ });
315
+ const lines = response.result.lines;
316
+ for (const line of lines) {
317
+ assets.push({
318
+ mint: `${line.currency}.${line.account}`,
319
+ symbol: line.currency,
320
+ name: `Trust Line: ${line.currency}`,
321
+ balance: iouToSmallestUnit(line.balance, IOU_DECIMALS),
322
+ decimals: IOU_DECIMALS,
323
+ isNative: false,
324
+ });
325
+ }
326
+ }
327
+ catch (err) {
328
+ // actNotFound means no account -- return only XRP with 0 balance
329
+ if (!this.isActNotFound(err)) {
330
+ throw this.mapError(err);
331
+ }
332
+ }
333
+ return assets;
334
+ }
335
+ // -- Fee estimation (1) --
336
+ async estimateFee(_request) {
337
+ await this.refreshServerInfo();
338
+ const baseFee = this.serverInfo?.baseFee ?? 10n;
339
+ const safeFee = (baseFee * FEE_SAFETY_NUMERATOR) / FEE_SAFETY_DENOMINATOR;
340
+ return {
341
+ fee: safeFee,
342
+ details: {
343
+ baseFee: baseFee.toString(),
344
+ safetyMargin: '120%',
345
+ },
346
+ };
347
+ }
348
+ // -- Token operations (2) -- Phase 472 stubs
349
+ async buildTokenTransfer(request) {
350
+ const client = this.getClient();
351
+ // Parse "{currency}.{issuer}" from token address
352
+ const { currency, issuer } = parseTrustLineToken(request.token.address);
353
+ // Decode destination address
354
+ let destinationAddress = request.to;
355
+ let destinationTag;
356
+ if (isXAddress(request.to)) {
357
+ const decoded = decodeXAddress(request.to);
358
+ destinationAddress = decoded.classicAddress;
359
+ if (decoded.tag !== false) {
360
+ destinationTag = decoded.tag;
361
+ }
362
+ }
363
+ // Parse Destination Tag from memo
364
+ if (request.memo) {
365
+ const memoTag = this.parseDestinationTag(request.memo);
366
+ if (memoTag !== undefined) {
367
+ destinationTag = memoTag;
368
+ }
369
+ }
370
+ // Build IOU Payment transaction with Amount object
371
+ const payment = {
372
+ TransactionType: 'Payment',
373
+ Account: request.from,
374
+ Destination: destinationAddress,
375
+ Amount: {
376
+ currency,
377
+ issuer,
378
+ value: smallestUnitToIou(request.amount, request.token.decimals),
379
+ },
380
+ ...(destinationTag !== undefined && { DestinationTag: destinationTag }),
381
+ };
382
+ // autofill populates Sequence, Fee, LastLedgerSequence
383
+ const autofilled = await client.autofill(payment);
384
+ // Apply fee safety margin: (Fee * 120) / 100
385
+ const baseFee = BigInt(autofilled.Fee ?? '12');
386
+ const safeFee = (baseFee * FEE_SAFETY_NUMERATOR) / FEE_SAFETY_DENOMINATOR;
387
+ autofilled.Fee = safeFee.toString();
388
+ // Serialize to JSON bytes
389
+ const txJson = JSON.stringify(autofilled);
390
+ const serialized = new TextEncoder().encode(txJson);
391
+ // Calculate approximate expiry
392
+ const lastLedgerSeq = autofilled.LastLedgerSequence ?? 0;
393
+ const currentLedger = this.serverInfo?.ledgerIndex ?? 0;
394
+ const ledgersRemaining = lastLedgerSeq - currentLedger;
395
+ const expiresAt = new Date(Date.now() + ledgersRemaining * LEDGER_CLOSE_MS);
396
+ return {
397
+ chain: 'ripple',
398
+ serialized,
399
+ estimatedFee: safeFee,
400
+ expiresAt,
401
+ metadata: {
402
+ Sequence: autofilled.Sequence,
403
+ LastLedgerSequence: autofilled.LastLedgerSequence,
404
+ Fee: autofilled.Fee,
405
+ DestinationTag: destinationTag,
406
+ originalTx: autofilled,
407
+ },
408
+ nonce: autofilled.Sequence,
409
+ };
410
+ }
411
+ async getTokenInfo(tokenAddress) {
412
+ // Parse "{currency}.{issuer}" -- no RPC call needed for XRPL Trust Lines
413
+ const { currency } = parseTrustLineToken(tokenAddress);
414
+ return {
415
+ address: tokenAddress,
416
+ symbol: currency,
417
+ name: `Trust Line: ${currency}`,
418
+ decimals: IOU_DECIMALS,
419
+ };
420
+ }
421
+ // -- Contract operations (2) -- XRPL native tx via calldata JSON
422
+ async buildContractCall(request) {
423
+ if (request.calldata) {
424
+ let parsed;
425
+ try {
426
+ parsed = JSON.parse(request.calldata);
427
+ }
428
+ catch {
429
+ throw new ChainError('INVALID_INSTRUCTION', 'ripple', {
430
+ message: 'XRPL does not support smart contracts. Invalid calldata JSON.',
431
+ });
432
+ }
433
+ const xrplTxType = parsed['xrplTxType'];
434
+ switch (xrplTxType) {
435
+ case 'OfferCreate': {
436
+ const offer = {
437
+ TransactionType: 'OfferCreate',
438
+ Account: request.from,
439
+ TakerPays: parsed['TakerPays'],
440
+ TakerGets: parsed['TakerGets'],
441
+ ...(parsed['Flags'] !== undefined && { Flags: parsed['Flags'] }),
442
+ ...(parsed['Expiration'] !== undefined && { Expiration: parsed['Expiration'] }),
443
+ ...(parsed['OfferSequence'] !== undefined && { OfferSequence: parsed['OfferSequence'] }),
444
+ };
445
+ return this.buildXrplNativeTx(offer);
446
+ }
447
+ case 'OfferCancel': {
448
+ const cancel = {
449
+ TransactionType: 'OfferCancel',
450
+ Account: request.from,
451
+ OfferSequence: parsed['OfferSequence'],
452
+ };
453
+ return this.buildXrplNativeTx(cancel);
454
+ }
455
+ case 'TrustSet': {
456
+ const trustSet = {
457
+ TransactionType: 'TrustSet',
458
+ Account: request.from,
459
+ LimitAmount: parsed['LimitAmount'],
460
+ Flags: parsed['Flags'] ?? 0x00020000, // tfSetNoRipple
461
+ };
462
+ return this.buildXrplNativeTx(trustSet);
463
+ }
464
+ default:
465
+ throw new ChainError('INVALID_INSTRUCTION', 'ripple', {
466
+ message: `Unsupported XRPL transaction type: ${xrplTxType ?? 'none'}. Use calldata with xrplTxType: OfferCreate | OfferCancel | TrustSet.`,
467
+ });
468
+ }
469
+ }
470
+ throw new ChainError('INVALID_INSTRUCTION', 'ripple', {
471
+ message: 'XRPL does not support smart contracts. Use calldata with xrplTxType for native DEX operations.',
472
+ });
473
+ }
474
+ async buildApprove(request) {
475
+ const client = this.getClient();
476
+ // Parse "{currency}.{issuer}" from token address
477
+ const { currency, issuer } = parseTrustLineToken(request.token.address);
478
+ // Build TrustSet transaction with tfSetNoRipple flag
479
+ const trustSet = {
480
+ TransactionType: 'TrustSet',
481
+ Account: request.from,
482
+ LimitAmount: {
483
+ currency,
484
+ issuer,
485
+ value: smallestUnitToIou(request.amount, request.token.decimals),
486
+ },
487
+ Flags: 131072, // tfSetNoRipple (0x00020000)
488
+ };
489
+ // autofill populates Sequence, Fee, LastLedgerSequence
490
+ const autofilled = await client.autofill(trustSet);
491
+ // Apply fee safety margin: (Fee * 120) / 100
492
+ const baseFee = BigInt(autofilled.Fee ?? '12');
493
+ const safeFee = (baseFee * FEE_SAFETY_NUMERATOR) / FEE_SAFETY_DENOMINATOR;
494
+ autofilled.Fee = safeFee.toString();
495
+ // Serialize to JSON bytes
496
+ const txJson = JSON.stringify(autofilled);
497
+ const serialized = new TextEncoder().encode(txJson);
498
+ // Calculate approximate expiry
499
+ const lastLedgerSeq = autofilled.LastLedgerSequence ?? 0;
500
+ const currentLedger = this.serverInfo?.ledgerIndex ?? 0;
501
+ const ledgersRemaining = lastLedgerSeq - currentLedger;
502
+ const expiresAt = new Date(Date.now() + ledgersRemaining * LEDGER_CLOSE_MS);
503
+ return {
504
+ chain: 'ripple',
505
+ serialized,
506
+ estimatedFee: safeFee,
507
+ expiresAt,
508
+ metadata: {
509
+ Sequence: autofilled.Sequence,
510
+ LastLedgerSequence: autofilled.LastLedgerSequence,
511
+ Fee: autofilled.Fee,
512
+ originalTx: autofilled,
513
+ },
514
+ nonce: autofilled.Sequence,
515
+ };
516
+ }
517
+ // -- Batch operations (1) -- Unsupported
518
+ async buildBatch(_request) {
519
+ throw new ChainError('BATCH_NOT_SUPPORTED', 'ripple', {
520
+ message: 'XRPL does not support batch transactions',
521
+ });
522
+ }
523
+ // -- Utility operations (3) --
524
+ async getTransactionFee(tx) {
525
+ const fee = tx.metadata?.Fee;
526
+ if (typeof fee === 'string') {
527
+ return BigInt(fee);
528
+ }
529
+ return tx.estimatedFee;
530
+ }
531
+ async getCurrentNonce(address) {
532
+ const client = this.getClient();
533
+ // Decode X-address if needed
534
+ let classicAddress = address;
535
+ if (isXAddress(address)) {
536
+ const decoded = decodeXAddress(address);
537
+ classicAddress = decoded.classicAddress;
538
+ }
539
+ try {
540
+ const response = await client.request({
541
+ command: 'account_info',
542
+ account: classicAddress,
543
+ ledger_index: 'validated',
544
+ });
545
+ return response.result.account_data.Sequence;
546
+ }
547
+ catch (err) {
548
+ if (this.isActNotFound(err)) {
549
+ return 0;
550
+ }
551
+ throw this.mapError(err);
552
+ }
553
+ }
554
+ // sweepAll is optional, not implemented for Ripple (reserve makes full sweep complex)
555
+ // -- Sign-only operations (2) --
556
+ async parseTransaction(rawTx) {
557
+ return parseRippleTransaction(rawTx);
558
+ }
559
+ async signExternalTransaction(rawTx, privateKey) {
560
+ const txObj = JSON.parse(rawTx);
561
+ const wallet = walletFromSodiumSeed(privateKey);
562
+ const { tx_blob, hash } = wallet.sign(txObj);
563
+ return {
564
+ signedTransaction: tx_blob,
565
+ txHash: hash,
566
+ };
567
+ }
568
+ // -- NFT operations (3) -- XLS-20
569
+ async buildNftTransferTx(request) {
570
+ const client = this.getClient();
571
+ // XLS-20 NFT transfer uses NFTokenCreateOffer (sell offer with Amount=0)
572
+ // The recipient must accept the offer to complete the transfer.
573
+ const offerTx = {
574
+ TransactionType: 'NFTokenCreateOffer',
575
+ Account: request.from,
576
+ NFTokenID: request.token.tokenId,
577
+ Destination: request.to,
578
+ Amount: '0', // Free transfer (not a sale)
579
+ Flags: 1, // tfSellNFToken
580
+ };
581
+ // autofill populates Sequence, Fee, LastLedgerSequence
582
+ const autofilled = await client.autofill(offerTx);
583
+ // Apply fee safety margin: (Fee * 120) / 100
584
+ const baseFee = BigInt(autofilled.Fee ?? '12');
585
+ const safeFee = (baseFee * FEE_SAFETY_NUMERATOR) / FEE_SAFETY_DENOMINATOR;
586
+ autofilled.Fee = safeFee.toString();
587
+ // Serialize to JSON bytes
588
+ const txJson = JSON.stringify(autofilled);
589
+ const serialized = new TextEncoder().encode(txJson);
590
+ // Calculate approximate expiry
591
+ const lastLedgerSeq = autofilled.LastLedgerSequence ?? 0;
592
+ const currentLedger = this.serverInfo?.ledgerIndex ?? 0;
593
+ const ledgersRemaining = lastLedgerSeq - currentLedger;
594
+ const expiresAt = new Date(Date.now() + ledgersRemaining * LEDGER_CLOSE_MS);
595
+ return {
596
+ chain: 'ripple',
597
+ serialized,
598
+ estimatedFee: safeFee,
599
+ expiresAt,
600
+ metadata: {
601
+ Sequence: autofilled.Sequence,
602
+ LastLedgerSequence: autofilled.LastLedgerSequence,
603
+ Fee: autofilled.Fee,
604
+ originalTx: autofilled,
605
+ pendingAccept: true,
606
+ nftTokenId: request.token.tokenId,
607
+ },
608
+ nonce: autofilled.Sequence,
609
+ };
610
+ }
611
+ async transferNft(request, privateKey) {
612
+ // Build the NFTokenCreateOffer transaction
613
+ const unsignedTx = await this.buildNftTransferTx(request);
614
+ // Sign the transaction
615
+ const signedTx = await this.signTransaction(unsignedTx, privateKey);
616
+ // Submit the signed transaction
617
+ const result = await this.submitTransaction(signedTx);
618
+ return {
619
+ ...result,
620
+ status: 'submitted',
621
+ };
622
+ }
623
+ async approveNft(_request) {
624
+ throw new ChainError('INVALID_INSTRUCTION', 'ripple', {
625
+ message: 'XRPL NFTs use offer-based transfers, not approvals',
626
+ });
627
+ }
628
+ // -- Private helpers --
629
+ /**
630
+ * Build an XRPL native transaction from a Transaction object.
631
+ * Shared autofill/fee-margin/serialize pattern used by buildContractCall.
632
+ */
633
+ async buildXrplNativeTx(tx) {
634
+ const client = this.getClient();
635
+ const autofilled = await client.autofill(tx);
636
+ // Apply fee safety margin: (Fee * 120) / 100
637
+ const baseFee = BigInt(autofilled.Fee ?? '12');
638
+ const safeFee = (baseFee * FEE_SAFETY_NUMERATOR) / FEE_SAFETY_DENOMINATOR;
639
+ autofilled.Fee = safeFee.toString();
640
+ // Serialize to JSON bytes
641
+ const txJson = JSON.stringify(autofilled);
642
+ const serialized = new TextEncoder().encode(txJson);
643
+ // Calculate approximate expiry from LastLedgerSequence
644
+ const lastLedgerSeq = autofilled.LastLedgerSequence ?? 0;
645
+ const currentLedger = this.serverInfo?.ledgerIndex ?? 0;
646
+ const ledgersRemaining = lastLedgerSeq - currentLedger;
647
+ const expiresAt = new Date(Date.now() + ledgersRemaining * LEDGER_CLOSE_MS);
648
+ return {
649
+ chain: 'ripple',
650
+ serialized,
651
+ estimatedFee: safeFee,
652
+ expiresAt,
653
+ metadata: {
654
+ Sequence: autofilled.Sequence,
655
+ LastLedgerSequence: autofilled.LastLedgerSequence,
656
+ Fee: autofilled.Fee,
657
+ originalTx: autofilled,
658
+ },
659
+ nonce: autofilled.Sequence,
660
+ };
661
+ }
662
+ getClient() {
663
+ if (!this.client || !this._connected) {
664
+ throw new ChainError('RPC_CONNECTION_ERROR', 'ripple', {
665
+ message: 'Not connected to XRPL. Call connect() first.',
666
+ });
667
+ }
668
+ return this.client;
669
+ }
670
+ async refreshServerInfo() {
671
+ const client = this.getClient();
672
+ try {
673
+ const response = await client.request({ command: 'server_info' });
674
+ const info = response.result.info;
675
+ const validatedLedger = info.validated_ledger;
676
+ if (validatedLedger) {
677
+ // base_reserve_xrp and reserve_inc_xrp are in XRP, convert to drops
678
+ this.serverInfo = {
679
+ baseReserve: BigInt(Math.round((validatedLedger.reserve_base_xrp ?? 10) * 1e6)),
680
+ ownerReserve: BigInt(Math.round((validatedLedger.reserve_inc_xrp ?? 2) * 1e6)),
681
+ baseFee: BigInt(Math.round((validatedLedger.base_fee_xrp ?? 0.00001) * 1e6)),
682
+ ledgerIndex: validatedLedger.seq ?? 0,
683
+ };
684
+ }
685
+ }
686
+ catch (_err) {
687
+ // If we can't refresh, keep the old info or use defaults
688
+ if (!this.serverInfo) {
689
+ this.serverInfo = {
690
+ baseReserve: 10n * DROPS_PER_XRP, // 10 XRP default
691
+ ownerReserve: 2n * DROPS_PER_XRP, // 2 XRP default
692
+ baseFee: 10n, // 10 drops default
693
+ ledgerIndex: 0,
694
+ };
695
+ }
696
+ }
697
+ }
698
+ /**
699
+ * Parse Destination Tag from memo field.
700
+ * Supports numeric string ("12345") or JSON with destinationTag field.
701
+ */
702
+ parseDestinationTag(memo) {
703
+ // Try as numeric string
704
+ const num = Number(memo);
705
+ if (Number.isInteger(num) && num >= 0 && num <= 4294967295) {
706
+ return num;
707
+ }
708
+ // Try as JSON
709
+ try {
710
+ const parsed = JSON.parse(memo);
711
+ const tag = parsed['destinationTag'] ?? parsed['DestinationTag'] ?? parsed['destination_tag'];
712
+ if (typeof tag === 'number' && Number.isInteger(tag) && tag >= 0) {
713
+ return tag;
714
+ }
715
+ }
716
+ catch {
717
+ // Not JSON, ignore
718
+ }
719
+ return undefined;
720
+ }
721
+ /**
722
+ * Check if error is "Account not found" (actNotFound).
723
+ */
724
+ isActNotFound(err) {
725
+ if (err instanceof Error) {
726
+ return err.message.includes('actNotFound') || err.message.includes('Account not found');
727
+ }
728
+ return false;
729
+ }
730
+ /**
731
+ * Check if error is "Transaction not found" (txnNotFound).
732
+ */
733
+ isTxNotFound(err) {
734
+ if (err instanceof Error) {
735
+ return err.message.includes('txnNotFound') || err.message.includes('Transaction not found');
736
+ }
737
+ return false;
738
+ }
739
+ /**
740
+ * Map xrpl.js errors to ChainError.
741
+ */
742
+ mapError(err) {
743
+ if (err instanceof ChainError)
744
+ return err;
745
+ const message = err instanceof Error ? err.message : String(err);
746
+ // Connection errors
747
+ if (message.includes('NotConnectedError') || message.includes('not connected') || message.includes('WebSocket')) {
748
+ return new ChainError('RPC_CONNECTION_ERROR', 'ripple', { message, cause: err instanceof Error ? err : undefined });
749
+ }
750
+ // Account not found
751
+ if (message.includes('actNotFound') || message.includes('Account not found')) {
752
+ return new ChainError('ACCOUNT_NOT_FOUND', 'ripple', { message, cause: err instanceof Error ? err : undefined });
753
+ }
754
+ // Rate limiting
755
+ if (message.includes('rate') || message.includes('slowDown')) {
756
+ return new ChainError('RATE_LIMITED', 'ripple', { message, cause: err instanceof Error ? err : undefined });
757
+ }
758
+ // Timeout
759
+ if (message.includes('timeout') || message.includes('Timeout')) {
760
+ return new ChainError('RPC_TIMEOUT', 'ripple', { message, cause: err instanceof Error ? err : undefined });
761
+ }
762
+ // Insufficient balance
763
+ if (message.includes('tecUNFUNDED') || message.includes('insufficient')) {
764
+ return new ChainError('INSUFFICIENT_BALANCE', 'ripple', { message, cause: err instanceof Error ? err : undefined });
765
+ }
766
+ // Default to RPC connection error for unknown errors
767
+ return new ChainError('RPC_CONNECTION_ERROR', 'ripple', { message, cause: err instanceof Error ? err : undefined });
768
+ }
769
+ }
770
+ // ---------------------------------------------------------------------------
771
+ // Helpers
772
+ // ---------------------------------------------------------------------------
773
+ /**
774
+ * Reconstruct an xrpl.js Wallet from a 32-byte sodium-native Ed25519 seed.
775
+ *
776
+ * The KeyStore stores the 32-byte Ed25519 seed (sodium secretKey[0:32]).
777
+ * We must use the same Ed25519 derivation (seed → keypair) that sodium-native
778
+ * uses, NOT Wallet.fromEntropy() which applies XRPL-specific HMAC-SHA512
779
+ * key derivation and produces a different keypair.
780
+ *
781
+ * Flow: seed → Node.js crypto Ed25519 → publicKey → "ED" + hex → Wallet
782
+ */
783
+ function walletFromSodiumSeed(seed) {
784
+ // PKCS#8 DER prefix for Ed25519 private key (RFC 8410): 16 bytes
785
+ const pkcs8Prefix = Buffer.from('302e020100300506032b657004220420', 'hex');
786
+ const privateKeyObj = createPrivateKey({
787
+ key: Buffer.concat([pkcs8Prefix, Buffer.from(seed)]),
788
+ format: 'der',
789
+ type: 'pkcs8',
790
+ });
791
+ // Derive the public key object from private key, then export raw 32 bytes
792
+ const publicKeyObj = createPublicKey(privateKeyObj);
793
+ // SPKI DER for Ed25519 = 12-byte prefix + 32-byte raw public key
794
+ const spkiDer = publicKeyObj.export({ type: 'spki', format: 'der' });
795
+ const publicKeyRaw = spkiDer.subarray(-32);
796
+ // XRPL Ed25519 key format: "ED" prefix + 32-byte hex (uppercase)
797
+ const publicKeyHex = `ED${Buffer.from(publicKeyRaw).toString('hex').toUpperCase()}`;
798
+ const privateKeyHex = `ED${Buffer.from(seed).toString('hex').toUpperCase()}`;
799
+ const rAddress = deriveAddress(publicKeyHex);
800
+ return new Wallet(publicKeyHex, privateKeyHex, { masterAddress: rAddress });
801
+ }
802
+ //# sourceMappingURL=adapter.js.map