dhali-js 1.0.4 → 2.1.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,331 @@
1
+ const { getEthereumClaimTypedData } = require("./createSignedClaim");
2
+ const { ethers } = require("ethers");
3
+
4
+ const { fetchPublicConfig, notifyAdminGateway, retrieveChannelIdFromFirestoreRest } = require("./configUtils");
5
+
6
+ class DhaliEthChannelManager {
7
+ /**
8
+ * @param {ethers.Signer} signer
9
+ * @param {import("ethers").Provider} rpc_client
10
+ * @param {string} protocol
11
+ * @param {import("./Currency")} currency
12
+ * @param {typeof fetch} [httpClient] - Injected HTTP client
13
+ * @param {object} [public_config] - Dhali public configuration
14
+ */
15
+ constructor(signer, rpc_client, protocol, currency, httpClient = fetch, public_config) {
16
+ this.signer = signer;
17
+ this.rpc_client = rpc_client;
18
+ this.protocol = protocol;
19
+ this.currency = currency;
20
+ this.httpClient = httpClient || fetch;
21
+ this.public_config = public_config;
22
+ this.chainId = this._getChainIdFromProtocol(protocol);
23
+ this.destinationAddress = undefined;
24
+ this.contractAddress = undefined;
25
+ }
26
+
27
+ _getChainIdFromProtocol(protocol) {
28
+ switch (protocol) {
29
+ case "ETHEREUM": return 1;
30
+ case "SEPOLIA": return 11155111;
31
+ case "HOLESKY": return 17000;
32
+ case "LOCALHOST": return 31337;
33
+ default: throw new Error(`Unsupported protocol: ${protocol}`);
34
+ }
35
+ }
36
+
37
+ _getProtocolName() {
38
+ return this.protocol;
39
+ }
40
+
41
+ async _resolveAddresses() {
42
+ if (this.destinationAddress && this.contractAddress) return;
43
+
44
+ if (!this.public_config) {
45
+ this.public_config = await fetchPublicConfig(this.httpClient);
46
+ }
47
+
48
+ if (!this.destinationAddress) {
49
+ try {
50
+ this.destinationAddress = this.public_config.DHALI_PUBLIC_ADDRESSES[this.protocol][this.currency.code].wallet_id;
51
+ } catch (e) {
52
+ throw new Error("Destination address not found in public_config for this protocol/currency: " + e.message);
53
+ }
54
+ }
55
+
56
+ if (!this.contractAddress) {
57
+ try {
58
+ // @ts-ignore
59
+ this.contractAddress = this.public_config.CONTRACTS[this.protocol].contract_address;
60
+ } catch (e) {
61
+ throw new Error("Contract address not found in public_config for this protocol: " + e.message);
62
+ }
63
+ }
64
+
65
+ if (!this.contractAddress) {
66
+ throw new Error("Contract address must be provided or resolved for this chainId");
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Queries Firestore for an existing open channel.
72
+ * Path: public_claim_info/<protocol>/<currency_identifier>
73
+ * Filter: account == my_address, closed != true
74
+ */
75
+ async _retrieveChannelIdFromFirestore() {
76
+ const address = (await this.signer.getAddress()).toLowerCase();
77
+ return await retrieveChannelIdFromFirestoreRest(
78
+ this.protocol,
79
+ this.currency,
80
+ address,
81
+ this.httpClient
82
+ );
83
+ }
84
+
85
+ async _retrieveChannelIdFromFirestoreWithPolling(timeoutSeconds = 30) {
86
+ const startTime = Date.now();
87
+ while (Date.now() - startTime < timeoutSeconds * 1000) {
88
+ const channelId = await this._retrieveChannelIdFromFirestore();
89
+ if (channelId) return channelId;
90
+ await new Promise(resolve => setTimeout(resolve, 2000));
91
+ }
92
+ return null;
93
+ }
94
+
95
+ async _calculateChannelId(receiver, tokenAddress, nonce) {
96
+ // Matches Dhali-wallet: keccak256(abi.encode(sender, receiver, token, nonce))
97
+ const sender = await this.signer.getAddress();
98
+ return ethers.keccak256(
99
+ ethers.AbiCoder.defaultAbiCoder().encode(
100
+ ["address", "address", "address", "uint256"],
101
+ [sender, receiver, tokenAddress, nonce]
102
+ )
103
+ );
104
+ }
105
+
106
+ _encodeAddress(address) {
107
+ return address.toLowerCase().replace("0x", "").padStart(64, '0');
108
+ }
109
+
110
+ _encodeUint(value) {
111
+ return BigInt(value).toString(16).padStart(64, '0');
112
+ }
113
+
114
+ _encodeBool(value) {
115
+ return value ? "1".padStart(64, '0') : "0".padStart(64, '0');
116
+ }
117
+
118
+ _encodeBytes32(value) {
119
+ return value.replace("0x", "").padStart(64, '0');
120
+ }
121
+
122
+ async _buildTx(to, data, value) {
123
+ const feeData = await this.rpc_client.getFeeData();
124
+ // Add 10% buffer to gas price
125
+ const gasPrice = (feeData.gasPrice * BigInt(110)) / BigInt(100);
126
+
127
+ const txParams = {
128
+ from: await this.signer.getAddress(),
129
+ to: to,
130
+ value: value,
131
+ data: data,
132
+ gasPrice: gasPrice,
133
+ // Use pending nonce
134
+ nonce: await this.signer.getNonce("pending"),
135
+ chainId: this.chainId
136
+ };
137
+
138
+ // Estimate gas
139
+ const gasLimit = await this.signer.estimateGas(txParams);
140
+ // Add 10% buffer to gas limit
141
+ txParams.gasLimit = (gasLimit * BigInt(110)) / BigInt(100);
142
+
143
+ return txParams;
144
+ }
145
+
146
+ /**
147
+ * Deposits funds into a payment channel.
148
+ * If an open channel exists, funds it.
149
+ * If not, opens a new one.
150
+ * @param {string|number} amount Amount in base units (wei/drops)
151
+ * @returns {Promise<ethers.TransactionReceipt>}
152
+ */
153
+ async deposit(amount) {
154
+ await this._resolveAddresses();
155
+ const existingChannelId = await this._retrieveChannelIdFromFirestore();
156
+ const tokenAddress = this.currency.tokenAddress || "0x0000000000000000000000000000000000000000";
157
+ const isNative = (tokenAddress === "0x0000000000000000000000000000000000000000");
158
+ const amountBig = BigInt(amount);
159
+
160
+ const OPEN_CHANNEL_SELECTOR = "3cd880a5";
161
+ const DEPOSIT_SELECTOR = "264d06c8";
162
+ const SETTLE_DELAY = 1209600; // 2 weeks
163
+
164
+ if (existingChannelId) {
165
+ // Deposit
166
+ const calldata = "0x" +
167
+ DEPOSIT_SELECTOR +
168
+ this._encodeBytes32(existingChannelId) +
169
+ this._encodeUint(amountBig) +
170
+ this._encodeBool(true); // renew
171
+
172
+ if (!isNative) {
173
+ await this._approveToken(tokenAddress, this.contractAddress, amountBig);
174
+ }
175
+
176
+ const txParams = await this._buildTx(this.contractAddress, calldata, isNative ? amountBig : 0);
177
+ const tx = await this.signer.sendTransaction(txParams);
178
+ return await tx.wait();
179
+
180
+ } else {
181
+ // Open Channel
182
+ const receiver = this.destinationAddress;
183
+ const nonce = this._generateNonce();
184
+ const dummySigner = "0x0000000000000000000000000000000000000000";
185
+
186
+ const calldata = "0x" +
187
+ OPEN_CHANNEL_SELECTOR +
188
+ this._encodeAddress(receiver) +
189
+ this._encodeAddress(tokenAddress) +
190
+ this._encodeUint(amountBig) +
191
+ this._encodeUint(SETTLE_DELAY) +
192
+ this._encodeUint(nonce) +
193
+ this._encodeAddress(dummySigner);
194
+
195
+ if (!isNative) {
196
+ await this._approveToken(tokenAddress, this.contractAddress, amountBig);
197
+ }
198
+
199
+ const txParams = await this._buildTx(this.contractAddress, calldata, isNative ? amountBig : 0);
200
+ const tx = await this.signer.sendTransaction(txParams);
201
+ const receipt = await tx.wait();
202
+
203
+ // Calculate channel ID and notify gateway
204
+ const calculatedChannelId = await this._calculateChannelId(receiver, tokenAddress, nonce);
205
+
206
+ let currencyIdentifier = this.currency.code;
207
+ if (this.currency.tokenAddress) {
208
+ currencyIdentifier = `${this.currency.code}.${this.currency.tokenAddress}`;
209
+ }
210
+
211
+ // Proactive notification
212
+ await notifyAdminGateway(
213
+ this.protocol,
214
+ currencyIdentifier,
215
+ await this.signer.getAddress(),
216
+ calculatedChannelId,
217
+ this.httpClient
218
+ );
219
+
220
+ // Poll Firestore to match setupBalanceListener behavior
221
+ await this._retrieveChannelIdFromFirestoreWithPolling(30);
222
+
223
+ return receipt;
224
+ }
225
+ }
226
+
227
+ _generateNonce() {
228
+ const bytes = ethers.randomBytes(32);
229
+ return BigInt(ethers.hexlify(bytes));
230
+ }
231
+
232
+ async _approveToken(tokenAddress, spender, amount) {
233
+ const APPROVE_SELECTOR = "095ea7b3";
234
+ const calldata = "0x" +
235
+ APPROVE_SELECTOR +
236
+ this._encodeAddress(spender) +
237
+ this._encodeUint(amount);
238
+
239
+ const txParams = await this._buildTx(tokenAddress, calldata, 0);
240
+ const tx = await this.signer.sendTransaction(txParams);
241
+ await tx.wait();
242
+ }
243
+ async _getOnChainChannelAmount(channelId) {
244
+ const cleanId = channelId.replace("0x", "").padStart(64, "0");
245
+ const calldata = "0x831c2b82" + cleanId;
246
+
247
+ try {
248
+ const result = await this.rpc_client.call({
249
+ to: this.contractAddress,
250
+ data: calldata
251
+ });
252
+
253
+ if (result === "0x" || result.length < 322) {
254
+ throw new Error("Invalid getChannel response length");
255
+ }
256
+
257
+ // The amount is the 5th 32-byte word (index 4).
258
+ // Result is a hex string "0x...".
259
+ // Word 0: 2 to 66
260
+ // Word 1: 66 to 130
261
+ // Word 2: 130 to 194
262
+ // Word 3: 194 to 258
263
+ // Word 4: 258 to 322
264
+ const amountHex = "0x" + result.substring(258, 322);
265
+ return BigInt(amountHex).toString();
266
+ } catch (e) {
267
+ throw new Error(`Failed to retrieve on-chain channel amount: ${e.message}`);
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Generate a base64-encoded payment claim.
273
+ * @param {number|string|null} amount - Defaults to total channel capacity if null
274
+ * @returns {Promise<string>}
275
+ */
276
+ async getAuthToken(amount = null) {
277
+ await this._resolveAddresses();
278
+ // Poll Firestore if not found (setupBalanceListener simulation)
279
+ const channelIdRaw = await this._retrieveChannelIdFromFirestoreWithPolling(10);
280
+ if (!channelIdRaw) {
281
+ throw new Error("No open payment channel found in Firestore. Please deposit first.");
282
+ }
283
+
284
+ let channelId = channelIdRaw;
285
+ if (!channelId.startsWith("0x")) {
286
+ channelId = "0x" + channelId;
287
+ }
288
+
289
+ const totalAmount = await this._getOnChainChannelAmount(channelId);
290
+ const allowed = amount !== null ? amount.toString() : totalAmount;
291
+
292
+ // BigInt comparison if needed, but for now simple check if it exceeds
293
+ if (BigInt(allowed) > BigInt(totalAmount)) {
294
+ throw new Error(`Requested auth ${allowed} exceeds channel capacity ${totalAmount}`);
295
+ }
296
+
297
+ if (!channelId.startsWith("0x")) {
298
+ channelId = "0x" + channelId;
299
+ }
300
+
301
+ const token = this.currency.tokenAddress || "0x0000000000000000000000000000000000000000";
302
+
303
+ const { domain, types, value } = getEthereumClaimTypedData(
304
+ channelId,
305
+ token,
306
+ allowed,
307
+ this.chainId,
308
+ this.contractAddress
309
+ );
310
+
311
+ const signature = await this.signer.signTypedData(domain, types, value);
312
+
313
+ const claim = {
314
+ version: "2",
315
+ account: await this.signer.getAddress(),
316
+ protocol: this.protocol,
317
+ currency: {
318
+ code: this.currency.code,
319
+ scale: this.currency.scale,
320
+ issuer: this.currency.tokenAddress || null
321
+ },
322
+ destination_account: this.destinationAddress,
323
+ authorized_to_claim: allowed,
324
+ channel_id: channelId,
325
+ signature: signature
326
+ };
327
+ return Buffer.from(JSON.stringify(claim)).toString("base64");
328
+ }
329
+ }
330
+
331
+ module.exports = { DhaliEthChannelManager };
@@ -0,0 +1,199 @@
1
+ const { Client } = require("xrpl");
2
+ const { buildPaychanAuthHexStringToBeSigned } = require("./createSignedClaim");
3
+ const { sign: signClaim } = require("ripple-keypairs");
4
+
5
+ const { fetchPublicConfig, notifyAdminGateway, retrieveChannelIdFromFirestoreRest } = require("./configUtils");
6
+
7
+ class ChannelNotFound extends Error { }
8
+
9
+ /**
10
+ * A management tool for generating payment claims for use with Dhali APIs (XRPL).
11
+ */
12
+ class DhaliXrplChannelManager {
13
+ /**
14
+ * @param {import("xrpl").Wallet} wallet
15
+ * @param {import("xrpl").Client} rpc_client
16
+ * @param {string} protocol
17
+ * @param {import("./Currency")} currency
18
+ * @param {typeof fetch} [httpClient]
19
+ * @param {object} [public_config]
20
+ */
21
+ constructor(wallet, rpc_client, protocol, currency, httpClient = fetch, public_config) {
22
+ this.wallet = wallet;
23
+ this.rpc_client = rpc_client;
24
+ this.protocol = protocol;
25
+ this.currency = currency;
26
+ this.httpClient = httpClient || fetch;
27
+ this.public_config = public_config;
28
+ this.ready = Promise.resolve(); // Assuming client is ready or handled by caller
29
+ this.destination = undefined;
30
+ }
31
+
32
+ async _resolveAddresses() {
33
+ if (this.destination) return;
34
+
35
+ if (!this.public_config) {
36
+ this.public_config = await fetchPublicConfig(this.httpClient);
37
+ }
38
+
39
+ if (!this.destination) {
40
+ try {
41
+ this.destination = this.public_config.DHALI_PUBLIC_ADDRESSES[this.protocol][this.currency.code].wallet_id;
42
+ } catch (e) {
43
+ // Fallback to default if needed, or throw
44
+ this.destination = "rJiAX3Xk2Fq3KJrjsGajrB5LENZq7VCwAd";
45
+ }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Queries Firestore for an existing open channel.
51
+ * Path: public_claim_info/<protocol>/<currency_identifier>
52
+ * Filter: account == wallet.classicAddress, closed != true
53
+ */
54
+ async _retrieveChannelIdFromFirestore() {
55
+ return await retrieveChannelIdFromFirestoreRest(
56
+ this.protocol,
57
+ this.currency,
58
+ this.wallet.address,
59
+ this.httpClient
60
+ );
61
+ }
62
+
63
+ async _findChannel() {
64
+ await this.ready;
65
+ await this._resolveAddresses();
66
+
67
+ // Prioritize Firestore
68
+ const firestoreChannelId = await this._retrieveChannelIdFromFirestore();
69
+
70
+ if (firestoreChannelId === null) {
71
+ throw new ChannelNotFound(
72
+ `No open payment channel from ${this.wallet.classicAddress} to ${this.destination}`,
73
+ );
74
+ }
75
+
76
+ const resp = await this.rpc_client.request({
77
+ command: "account_channels",
78
+ account: this.wallet.classicAddress,
79
+ destination_account: this.destination,
80
+ ledger_index: "validated",
81
+ });
82
+ const channels = resp.result.channels || [];
83
+
84
+ for (const ch of channels) {
85
+ if (ch.channel_id === firestoreChannelId) {
86
+ return ch;
87
+ }
88
+ }
89
+
90
+ throw new ChannelNotFound(
91
+ `Firestore channel ${firestoreChannelId} not found on-chain for ` +
92
+ `${this.wallet.classicAddress} to ${this.destination}`
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Create or fund a payment channel.
98
+ * @param {number} amountDrops
99
+ * @returns {Promise<object>}
100
+ */
101
+ async deposit(amountDrops) {
102
+ await this.ready;
103
+ let tx;
104
+ try {
105
+ const ch = await this._findChannel();
106
+ tx = {
107
+ TransactionType: "PaymentChannelFund",
108
+ Account: this.wallet.classicAddress,
109
+ Channel: ch.channel_id,
110
+ Amount: amountDrops.toString(),
111
+ };
112
+ } catch (err) {
113
+ if (!(err instanceof ChannelNotFound)) throw err;
114
+ tx = {
115
+ TransactionType: "PaymentChannelCreate",
116
+ Account: this.wallet.classicAddress,
117
+ Destination: this.destination,
118
+ Amount: amountDrops.toString(),
119
+ SettleDelay: 86400 * 14,
120
+ PublicKey: this.wallet.publicKey,
121
+ };
122
+ }
123
+ // autofill sequence, fee, etc.
124
+ // @ts-ignore
125
+ const prepared = await this.rpc_client.autofill(tx);
126
+ // sign
127
+ const signed = this.wallet.sign(prepared);
128
+ // @ts-ignore
129
+ const txBlob = signed.tx_blob || signed.signedTransaction;
130
+ // submit & wait
131
+ const result = await this.rpc_client.submitAndWait(txBlob);
132
+
133
+ // If we just created a channel, notify the gateway
134
+ if (tx.TransactionType === "PaymentChannelCreate" &&
135
+ // @ts-ignore
136
+ (result.result.meta || result.result.metaData)) {
137
+ // @ts-ignore
138
+ const meta = result.result.meta || result.result.metaData;
139
+ const affectedNodes = meta.AffectedNodes || [];
140
+ for (const node of affectedNodes) {
141
+ const createdNode = node.CreatedNode;
142
+ if (createdNode && createdNode.LedgerEntryType === "PayChannel") {
143
+ const channelId = createdNode.LedgerIndex;
144
+ if (channelId) {
145
+ let currencyIdentifier = this.currency.code;
146
+ if (this.currency.tokenAddress) {
147
+ currencyIdentifier = `${this.currency.code}.${this.currency.tokenAddress}`;
148
+ }
149
+ await notifyAdminGateway(
150
+ this.protocol,
151
+ currencyIdentifier,
152
+ this.wallet.classicAddress,
153
+ channelId,
154
+ this.httpClient
155
+ );
156
+ }
157
+ break;
158
+ }
159
+ }
160
+ }
161
+
162
+ return result.result;
163
+ }
164
+
165
+ /**
166
+ * Generate a base64-encoded payment claim.
167
+ * @param {number=} amountDrops
168
+ * @returns {Promise<string>}
169
+ */
170
+ async getAuthToken(amountDrops) {
171
+ await this.ready;
172
+ const ch = await this._findChannel();
173
+ const total = BigInt(ch.amount);
174
+ const allowed = amountDrops != null ? BigInt(amountDrops) : total;
175
+ if (allowed > total) {
176
+ throw new Error(
177
+ `Requested auth ${allowed} exceeds channel capacity ${total}`,
178
+ );
179
+ }
180
+ const claimHex = buildPaychanAuthHexStringToBeSigned(
181
+ ch.channel_id,
182
+ allowed.toString(),
183
+ );
184
+ const signature = signClaim(claimHex, this.wallet.privateKey);
185
+ const claim = {
186
+ version: "2",
187
+ account: this.wallet.classicAddress,
188
+ protocol: this.protocol,
189
+ currency: { code: "XRP", scale: 6 },
190
+ destination_account: this.destination,
191
+ authorized_to_claim: allowed.toString(),
192
+ channel_id: ch.channel_id,
193
+ signature,
194
+ };
195
+ return Buffer.from(JSON.stringify(claim)).toString("base64");
196
+ }
197
+ }
198
+
199
+ module.exports = { DhaliXrplChannelManager, ChannelNotFound };