@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.
- package/dist/adapter.d.ts +68 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +802 -0
- package/dist/adapter.js.map +1 -0
- package/dist/address-utils.d.ts +37 -0
- package/dist/address-utils.d.ts.map +1 -0
- package/dist/address-utils.js +65 -0
- package/dist/address-utils.js.map +1 -0
- package/dist/currency-utils.d.ts +40 -0
- package/dist/currency-utils.d.ts.map +1 -0
- package/dist/currency-utils.js +118 -0
- package/dist/currency-utils.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/nft-utils.d.ts +41 -0
- package/dist/nft-utils.d.ts.map +1 -0
- package/dist/nft-utils.js +54 -0
- package/dist/nft-utils.js.map +1 -0
- package/dist/tx-parser.d.ts +12 -0
- package/dist/tx-parser.d.ts.map +1 -0
- package/dist/tx-parser.js +97 -0
- package/dist/tx-parser.js.map +1 -0
- package/package.json +47 -0
package/dist/adapter.js
ADDED
|
@@ -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
|