dhali-js 2.1.0 → 3.0.1

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.
@@ -29,4 +29,4 @@ jobs:
29
29
  run: npm test
30
30
 
31
31
  - name: Run lint
32
- run: npx tsc src/dhali/*.js --allowJs --checkJs --noEmit --moduleResolution node --module commonjs --target esnext
32
+ run: npx tsc src/dhali/*.js --allowJs --checkJs --noEmit --moduleResolution node --module commonjs --target esnext --skipLibCheck
package/README.md CHANGED
@@ -16,6 +16,9 @@ Includes support for **Machine-to-Machine (M2M) payments** using seamless off-ch
16
16
  npm install dhali-js
17
17
  ```
18
18
 
19
+ > [!TIP]
20
+ > The examples below use CommonJS (`require`). Because they use `await`, the code is wrapped in an `async function main() { ... }` which is called immediately. To run these, simply save them to a `.js` file and run `node document.js`.
21
+
19
22
  ---
20
23
 
21
24
  ## Quick Start: Machine-to-Machine Payments
@@ -28,69 +31,85 @@ Uses `xrpl.js` for local signing.
28
31
  const { Client, Wallet } = require('xrpl')
29
32
  const { DhaliChannelManager, ChannelNotFound, Currency } = require('dhali-js')
30
33
 
31
- const seed = "sXXX..."
32
- const wallet = Wallet.fromSeed(seed)
33
- const client = new Client("wss://s.altnet.rippletest.net:51233")
34
- await client.connect()
35
-
36
- const currency = new Currency("XRP", 6)
37
-
38
- // Use Factory
39
- const manager = DhaliChannelManager.xrpl(wallet, client, "XRPL.TESTNET", currency)
40
-
41
- // Generate Claim
42
- let token;
43
- try {
44
- token = await manager.getAuthToken();
45
- } catch (error) {
46
- if (error.name === "ChannelNotFound") {
47
- await manager.deposit(1000000); // Deposit 1 XRP
48
- token = await manager.getAuthToken();
49
- } else {
50
- throw error;
34
+ async function main() {
35
+ const seed = "sXXX..."
36
+ const wallet = Wallet.fromSeed(seed)
37
+ const client = new Client("wss://s.altnet.rippletest.net:51233")
38
+ await client.connect()
39
+
40
+ const currency = new Currency("XRPL.TESTNET", "XRP", 6)
41
+
42
+ // Use Factory
43
+ const manager = DhaliChannelManager.xrpl(wallet, client, currency)
44
+
45
+ // Generate Claim
46
+ let token;
47
+ try {
48
+ token = await manager.getAuthToken();
49
+ } catch (error) {
50
+ if (error.name === "ChannelNotFound") {
51
+ await manager.deposit(1000000); // Deposit 1 XRP
52
+ token = await manager.getAuthToken();
53
+ } else {
54
+ throw error;
55
+ }
51
56
  }
57
+ console.log('XRPL Token:', token);
52
58
  }
53
- console.log('XRPL Token:', token);
59
+
60
+ main();
54
61
  ```
55
62
 
56
63
  ### 2. Ethereum (EVM)
57
64
 
58
- Uses `ethers` (v6) for EIP-712 signing.
65
+ Uses `viem` for EIP-712 signing.
59
66
 
60
67
  ```js
61
- const { ethers } = require('ethers')
68
+ const { createWalletClient, createPublicClient, http } = require('viem')
69
+ const { privateKeyToAccount } = require('viem/accounts')
70
+ const { mainnet, sepolia } = require('viem/chains')
62
71
  const { DhaliChannelManager, getAvailableDhaliCurrencies } = require('dhali-js')
63
72
 
64
- // 1. Setup Signer
65
- const provider = new ethers.JsonRpcProvider("https://rpc.ankr.com/eth_sepolia")
66
- const signer = new ethers.Wallet("0x...", provider)
67
-
68
- // 2. Fetch Available Currencies
69
- const configs = await getAvailableDhaliCurrencies()
70
- const sepoliaUsdc = configs["SEPOLIA"]["USDC"]
71
-
72
- // 3. Instantiate Manager with Dynamic Config
73
- const manager = DhaliChannelManager.evm(
74
- signer,
75
- provider,
76
- "SEPOLIA",
77
- sepoliaUsdc.currency
78
- )
79
-
80
- // 4. Generate Claim
81
- // 4. Generate Claim
82
- let token;
83
- try {
84
- token = await manager.getAuthToken(1000000); // 1.00 USDC
85
- } catch (error) {
86
- if (error.name === "ChannelNotFound") {
87
- await manager.deposit(1000000); // Deposit 1.00 USDC
88
- token = await manager.getAuthToken(1000000);
89
- } else {
90
- throw error;
73
+ async function main() {
74
+ // 1. Setup Clients
75
+ const account = privateKeyToAccount('0x...')
76
+ const publicClient = createPublicClient({
77
+ chain: sepolia,
78
+ transport: http()
79
+ })
80
+ const walletClient = createWalletClient({
81
+ account,
82
+ chain: sepolia,
83
+ transport: http()
84
+ })
85
+
86
+ // 2. Fetch Available Currencies
87
+ const currencies = await getAvailableDhaliCurrencies()
88
+ const sepoliaUsdc = currencies.find(c => c.network === "SEPOLIA" && c.code === "USDC")
89
+
90
+ // 3. Instantiate Manager with Dynamic Config
91
+ const manager = DhaliChannelManager.evm(
92
+ walletClient,
93
+ publicClient,
94
+ sepoliaUsdc
95
+ )
96
+
97
+ // 4. Generate Claim
98
+ let token;
99
+ try {
100
+ token = await manager.getAuthToken(1000000); // 1.00 USDC
101
+ } catch (error) {
102
+ if (error.name === "ChannelNotFound") {
103
+ await manager.deposit(1000000); // Deposit 1.00 USDC
104
+ token = await manager.getAuthToken(1000000);
105
+ } else {
106
+ throw error;
107
+ }
91
108
  }
109
+ console.log('EVM Token:', token);
92
110
  }
93
- console.log('EVM Token:', token);
111
+
112
+ main();
94
113
  ```
95
114
 
96
115
  ---
@@ -104,26 +123,64 @@ const url = `https://xrplcluster.dhali.io?payment-claim=${token}`
104
123
  const response = await fetch(url, { method: 'POST', body: ... })
105
124
  ```
106
125
 
126
+ ## Standardized x402 Payments
127
+
128
+ For APIs that follow the x402 standard, you may need to wrap your auth token with the payment requirement (retrieved from the `payment-required` header of a 402 response).
129
+
130
+ ```js
131
+ const { wrapAsX402PaymentPayload } = require('dhali-js');
132
+
133
+ async function main() {
134
+ // 1. Get your token as usual
135
+ const token = await manager.getAuthToken();
136
+
137
+ // 2. Get the payment requirement from the 'payment-required' header of a 402 response
138
+ const paymentRequirement = response.headers.get("payment-required");
139
+
140
+ // 3. Wrap into an x402 payload
141
+ const x402Payload = wrapAsX402PaymentPayload(token, paymentRequirement);
142
+
143
+ // 4. Use 'x402Payload' in the 'Payment' header
144
+ }
145
+
146
+ main();
147
+ ```
148
+
107
149
  ---
108
150
 
109
151
  ## API Reference
110
152
 
111
- ### `DhaliChannelManager`
153
+ ### `DhaliChannelManager` (Factory)
154
+
155
+ * `.xrpl(wallet, client, currency)`: Returns `DhaliXrplChannelManager`.
156
+ * `.evm(walletClient, publicClient, currency)`: Returns `DhaliEthChannelManager`.
112
157
 
113
- * `.xrpl(wallet, client, protocol, currency)`: Returns `DhaliXrplChannelManager`.
114
- * `.evm(signer, provider, protocol, currency)`: Returns `DhaliEthChannelManager`.
158
+ ### `DhaliEthChannelManager` & `DhaliXrplChannelManager`
159
+
160
+ Both managers provide the following core methods:
161
+
162
+ * `async deposit(amount)`: Deposits funds into a payment channel. For EVM/XRPL, `amount` is in base units (wei/drops, etc). If no channel exists, it creates one; if it exists, it funds it.
163
+ * `async getAuthToken(amount = null)`: Generates a base64-encoded payment claim. If `amount` is provided, the claim is authorized up to that value. Defaults to total channel capacity if `amount` is `null`.
115
164
 
116
165
  ### `getAvailableDhaliCurrencies()`
117
166
 
118
- Returns a Promise resolving to:
167
+ Returns a Promise resolving to an array of `Currency` objects:
119
168
  ```js
120
- {
121
- "SEPOLIA": {
122
- "USDC": { currency: ..., destinationAddress: ... },
123
- ...
124
- },
169
+ [
170
+ { network: "SEPOLIA", code: "USDC", scale: 6, tokenAddress: "..." },
125
171
  ...
126
- }
172
+ ]
127
173
  ```
128
174
 
175
+ ---
176
+
177
+ ## Utilities
178
+
179
+ ### `wrapAsX402PaymentPayload(token, paymentRequirement)`
180
+
181
+ Wraps an auth token and a payment requirement (retrieved from a 402 response header) into a base64-encoded x402-compliant payload.
182
+
183
+ * **`token`**: The base64-encoded claim generated by `getAuthToken()`.
184
+ * **`paymentRequirement`**: The base64-encoded requirement string from the `payment-required` header.
185
+
129
186
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dhali-js",
3
- "version": "2.1.0",
3
+ "version": "3.0.1",
4
4
  "description": "A JavaScript library for managing XRPL payment channels and generating auth tokens for Dhali APIs",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
@@ -16,8 +16,8 @@
16
16
  ],
17
17
  "license": "MIT",
18
18
  "dependencies": {
19
- "ethers": "^6.0.0",
20
19
  "ripple-keypairs": "^2.0.0",
20
+ "viem": "^2.23.5",
21
21
  "xrpl": "^4.0.0"
22
22
  },
23
23
  "devDependencies": {
@@ -1,5 +1,6 @@
1
1
  class Currency {
2
- constructor(code, scale, tokenAddress = null) {
2
+ constructor(network, code, scale, tokenAddress = null) {
3
+ this.network = network;
3
4
  this.code = code;
4
5
  this.scale = scale;
5
6
  this.tokenAddress = tokenAddress;
@@ -5,30 +5,27 @@ const DhaliChannelManager = {
5
5
  /**
6
6
  * @param {import("xrpl").Wallet} wallet
7
7
  * @param {import("xrpl").Client} client
8
- * @param {string} protocol
9
8
  * @param {import("./Currency")} currency
10
9
  * @param {typeof fetch} [httpClient] - Injected HTTP client
11
10
  * @param {object} [publicConfig]
12
11
  * @returns {DhaliXrplChannelManager}
13
12
  */
14
- xrpl: (wallet, client, protocol, currency, httpClient, publicConfig) => {
15
- return new DhaliXrplChannelManager(wallet, client, protocol, currency, httpClient, publicConfig);
13
+ xrpl: (wallet, client, currency, httpClient, publicConfig) => {
14
+ return new DhaliXrplChannelManager(wallet, client, currency, httpClient, publicConfig);
16
15
  },
17
16
 
18
17
  /**
19
- * @param {import("ethers").Signer} signer
20
- * @param {import("ethers").Provider} provider
21
- * @param {string} protocol
18
+ * @param {import("viem").WalletClient} walletClient
19
+ * @param {import("viem").PublicClient} publicClient
22
20
  * @param {import("./Currency")} currency
23
21
  * @param {typeof fetch} [httpClient] - Injected HTTP client
24
22
  * @param {object} [publicConfig]
25
23
  * @returns {DhaliEthChannelManager}
26
24
  */
27
- evm: (signer, provider, protocol, currency, httpClient, publicConfig) => {
25
+ evm: (walletClient, publicClient, currency, httpClient, publicConfig) => {
28
26
  return new DhaliEthChannelManager(
29
- signer,
30
- provider,
31
- protocol,
27
+ walletClient,
28
+ publicClient,
32
29
  currency,
33
30
  httpClient,
34
31
  publicConfig
@@ -1,331 +1,344 @@
1
1
  const { getEthereumClaimTypedData } = require("./createSignedClaim");
2
- const { ethers } = require("ethers");
2
+ const {
3
+ keccak256,
4
+ encodeAbiParameters,
5
+ parseAbiParameters,
6
+ toHex
7
+ } = require("viem");
8
+ const crypto = require("crypto");
3
9
 
4
10
  const { fetchPublicConfig, notifyAdminGateway, retrieveChannelIdFromFirestoreRest } = require("./configUtils");
5
11
 
6
12
  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;
13
+ /**
14
+ * @param {import("viem").WalletClient} walletClient
15
+ * @param {import("viem").PublicClient} publicClient
16
+ * @param {import("./Currency")} currency
17
+ * @param {typeof fetch} [httpClient] - Injected HTTP client
18
+ * @param {object} [public_config] - Dhali public configuration
19
+ */
20
+ constructor(walletClient, publicClient, currency, httpClient = fetch, public_config) {
21
+ this.walletClient = walletClient;
22
+ this.publicClient = publicClient;
23
+ this.currency = currency;
24
+ this.httpClient = httpClient || fetch;
25
+ this.public_config = public_config;
26
+ this.chainId = this._getChainIdFromProtocol(this.currency.network);
27
+ this.destinationAddress = undefined;
28
+ this.contractAddress = undefined;
29
+ }
30
+
31
+ _getChainIdFromProtocol(protocol) {
32
+ switch (protocol) {
33
+ case "ETHEREUM": return 1;
34
+ case "SEPOLIA": return 11155111;
35
+ case "HOLESKY": return 17000;
36
+ case "LOCALHOST": return 31337;
37
+ default: throw new Error(`Unsupported protocol: ${protocol}`);
25
38
  }
39
+ }
26
40
 
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
41
 
37
- _getProtocolName() {
38
- return this.protocol;
39
- }
42
+ async _resolveAddresses() {
43
+ if (this.destinationAddress && this.contractAddress) return;
40
44
 
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
- }
45
+ if (!this.public_config) {
46
+ this.public_config = await fetchPublicConfig(this.httpClient);
68
47
  }
69
48
 
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
- );
49
+ if (!this.destinationAddress) {
50
+ try {
51
+ this.destinationAddress = this.public_config.DHALI_PUBLIC_ADDRESSES[this.currency.network][this.currency.code].wallet_id;
52
+ } catch (e) {
53
+ throw new Error("Destination address not found in public_config for this protocol/currency: " + e.message);
54
+ }
83
55
  }
84
56
 
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;
57
+ if (!this.contractAddress) {
58
+ try {
59
+ // @ts-ignore
60
+ this.contractAddress = this.public_config.CONTRACTS[this.currency.network].contract_address;
61
+ } catch (e) {
62
+ throw new Error("Contract address not found in public_config for this protocol: " + e.message);
63
+ }
93
64
  }
94
65
 
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
- );
66
+ if (!this.contractAddress) {
67
+ throw new Error("Contract address must be provided or resolved for this chainId");
104
68
  }
105
-
106
- _encodeAddress(address) {
107
- return address.toLowerCase().replace("0x", "").padStart(64, '0');
69
+ }
70
+
71
+ /**
72
+ * Queries Firestore for an existing open channel.
73
+ * Path: public_claim_info/<protocol>/<currency_identifier>
74
+ * Filter: account == my_address, closed != true
75
+ */
76
+ async _retrieveChannelIdFromFirestore() {
77
+ const [address] = await this.walletClient.getAddresses();
78
+ return await retrieveChannelIdFromFirestoreRest(
79
+ this.currency.network,
80
+ this.currency,
81
+ address.toLowerCase(),
82
+ this.httpClient
83
+ );
84
+ }
85
+
86
+ async _retrieveChannelIdFromFirestoreWithPolling(timeoutSeconds = 30) {
87
+ const startTime = Date.now();
88
+ while (Date.now() - startTime < timeoutSeconds * 1000) {
89
+ const channelId = await this._retrieveChannelIdFromFirestore();
90
+ if (channelId) return channelId;
91
+ await new Promise(resolve => setTimeout(resolve, 2000));
108
92
  }
109
-
110
- _encodeUint(value) {
111
- return BigInt(value).toString(16).padStart(64, '0');
93
+ return null;
94
+ }
95
+
96
+ async _calculateChannelId(receiver, tokenAddress, nonce) {
97
+ // Matches Dhali-wallet: keccak256(abi.encode(sender, receiver, token, nonce))
98
+ const [sender] = await this.walletClient.getAddresses();
99
+ return keccak256(
100
+ encodeAbiParameters(
101
+ parseAbiParameters("address, address, address, uint256"),
102
+ [sender, receiver, tokenAddress, nonce]
103
+ )
104
+ );
105
+ }
106
+
107
+ _encodeAddress(address) {
108
+ return address.toLowerCase().replace("0x", "").padStart(64, '0');
109
+ }
110
+
111
+ _encodeUint(value) {
112
+ return BigInt(value).toString(16).padStart(64, '0');
113
+ }
114
+
115
+ _encodeBool(value) {
116
+ return value ? "1".padStart(64, '0') : "0".padStart(64, '0');
117
+ }
118
+
119
+ _encodeBytes32(value) {
120
+ return value.replace("0x", "").padStart(64, '0');
121
+ }
122
+
123
+ async _buildTx(to, data, value) {
124
+ const accountString = (await this.walletClient.getAddresses())[0];
125
+ const account = this.walletClient.account || accountString;
126
+ const gasPrice = await this.publicClient.getGasPrice();
127
+ // Add 10% buffer to gas price
128
+ const gasPriceWithBuffer = (gasPrice * BigInt(110)) / BigInt(100);
129
+
130
+ /** @type {any} */
131
+ const txParams = {
132
+ account: account,
133
+ to: to,
134
+ value: value,
135
+ data: data,
136
+ gasPrice: gasPriceWithBuffer,
137
+ nonce: await this.publicClient.getTransactionCount({ address: accountString, blockTag: "pending" }),
138
+ chain: { id: this.chainId }
139
+ };
140
+
141
+ // Estimate gas
142
+ const gasLimit = await this.publicClient.estimateGas(txParams);
143
+ // Add 10% buffer to gas limit
144
+ txParams.gas = (gasLimit * BigInt(110)) / BigInt(100);
145
+
146
+ return txParams;
147
+ }
148
+
149
+ /**
150
+ * Deposits funds into a payment channel.
151
+ * If an open channel exists, funds it.
152
+ * If not, opens a new one.
153
+ * @param {string|number} amount Amount in base units (wei/drops)
154
+ * @returns {Promise<import("viem").TransactionReceipt>}
155
+ */
156
+ async deposit(amount) {
157
+ await this._resolveAddresses();
158
+ const existingChannelId = await this._retrieveChannelIdFromFirestore();
159
+ const tokenAddress = this.currency.tokenAddress || "0x0000000000000000000000000000000000000000";
160
+ const isNative = (tokenAddress === "0x0000000000000000000000000000000000000000");
161
+ const amountBig = BigInt(amount);
162
+
163
+ const OPEN_CHANNEL_SELECTOR = "3cd880a5";
164
+ const DEPOSIT_SELECTOR = "264d06c8";
165
+ const SETTLE_DELAY = 1209600n; // 2 weeks
166
+
167
+ if (existingChannelId) {
168
+ // Deposit
169
+ const calldata = "0x" +
170
+ DEPOSIT_SELECTOR +
171
+ this._encodeBytes32(existingChannelId) +
172
+ this._encodeUint(amountBig) +
173
+ this._encodeBool(true); // renew
174
+
175
+ if (!isNative) {
176
+ await this._approveToken(tokenAddress, this.contractAddress, amountBig);
177
+ }
178
+
179
+ const txParams = await this._buildTx(this.contractAddress, calldata, isNative ? amountBig : 0n);
180
+ const hash = await this.walletClient.sendTransaction(txParams);
181
+ return await this.publicClient.waitForTransactionReceipt({ hash });
182
+
183
+ } else {
184
+ // Open Channel
185
+ const receiver = this.destinationAddress;
186
+ const nonce = this._generateNonce();
187
+ const dummySigner = "0x0000000000000000000000000000000000000000";
188
+
189
+ const calldata = "0x" +
190
+ OPEN_CHANNEL_SELECTOR +
191
+ this._encodeAddress(receiver) +
192
+ this._encodeAddress(tokenAddress) +
193
+ this._encodeUint(amountBig) +
194
+ this._encodeUint(SETTLE_DELAY) +
195
+ this._encodeUint(nonce) +
196
+ this._encodeAddress(dummySigner);
197
+
198
+ if (!isNative) {
199
+ await this._approveToken(tokenAddress, this.contractAddress, amountBig);
200
+ }
201
+
202
+ const txParams = await this._buildTx(this.contractAddress, calldata, isNative ? amountBig : 0n);
203
+ const hash = await this.walletClient.sendTransaction(txParams);
204
+ const receipt = await this.publicClient.waitForTransactionReceipt({ hash });
205
+
206
+ // Calculate channel ID and notify gateway
207
+ const calculatedChannelId = await this._calculateChannelId(receiver, tokenAddress, nonce);
208
+
209
+ let currencyIdentifier = this.currency.code;
210
+ if (this.currency.tokenAddress) {
211
+ currencyIdentifier = `${this.currency.code}.${this.currency.tokenAddress}`;
212
+ }
213
+
214
+ const [address] = await this.walletClient.getAddresses();
215
+ // Proactive notification
216
+ await notifyAdminGateway(
217
+ this.currency.network,
218
+ currencyIdentifier,
219
+ address.toLowerCase(),
220
+ calculatedChannelId,
221
+ this.httpClient
222
+ );
223
+
224
+ // Poll Firestore to match setupBalanceListener behavior
225
+ await this._retrieveChannelIdFromFirestoreWithPolling(30);
226
+
227
+ return receipt;
112
228
  }
113
-
114
- _encodeBool(value) {
115
- return value ? "1".padStart(64, '0') : "0".padStart(64, '0');
229
+ }
230
+
231
+ _generateNonce() {
232
+ const bytes = crypto.randomBytes(32);
233
+ return BigInt(toHex(bytes));
234
+ }
235
+
236
+ async _approveToken(tokenAddress, spender, amount) {
237
+ const APPROVE_SELECTOR = "095ea7b3";
238
+ const calldata = "0x" +
239
+ APPROVE_SELECTOR +
240
+ this._encodeAddress(spender) +
241
+ this._encodeUint(amount);
242
+
243
+ const txParams = await this._buildTx(tokenAddress, calldata, 0n);
244
+ const hash = await this.walletClient.sendTransaction(txParams);
245
+ await this.publicClient.waitForTransactionReceipt({ hash });
246
+ }
247
+ async _getOnChainChannelAmount(channelId) {
248
+ const cleanId = channelId.replace("0x", "").padStart(64, "0");
249
+ const calldata = "0x831c2b82" + cleanId;
250
+
251
+ try {
252
+ const result = await this.publicClient.call({
253
+ to: this.contractAddress,
254
+ data: calldata
255
+ });
256
+
257
+ if (!result || result.data === "0x" || result.data.length < 322) {
258
+ throw new Error("Invalid getChannel response length");
259
+ }
260
+
261
+ // The amount is the 5th 32-byte word (index 4).
262
+ // Result is a hex string "0x...".
263
+ // Word 0: 2 to 66
264
+ // Word 1: 66 to 130
265
+ // Word 2: 130 to 194
266
+ // Word 3: 194 to 258
267
+ // Word 4: 258 to 322
268
+ const amountHex = "0x" + result.data.substring(258, 322);
269
+ return BigInt(amountHex).toString();
270
+ } catch (e) {
271
+ throw new Error(`Failed to retrieve on-chain channel amount: ${e.message}`);
116
272
  }
117
-
118
- _encodeBytes32(value) {
119
- return value.replace("0x", "").padStart(64, '0');
273
+ }
274
+
275
+ /**
276
+ * Generate a base64-encoded payment claim.
277
+ * @param {number|string|null} amount - Defaults to total channel capacity if null
278
+ * @returns {Promise<string>}
279
+ */
280
+ async getAuthToken(amount = null) {
281
+ await this._resolveAddresses();
282
+ // Poll Firestore if not found (setupBalanceListener simulation)
283
+ const channelIdRaw = await this._retrieveChannelIdFromFirestoreWithPolling(10);
284
+ if (!channelIdRaw) {
285
+ throw new Error("No open payment channel found in Firestore. Please deposit first.");
120
286
  }
121
287
 
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;
288
+ let channelId = channelIdRaw;
289
+ if (!channelId.startsWith("0x")) {
290
+ channelId = "0x" + channelId;
144
291
  }
145
292
 
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
- }
293
+ const totalAmount = await this._getOnChainChannelAmount(channelId);
294
+ const allowed = amount !== null ? amount.toString() : totalAmount;
226
295
 
227
- _generateNonce() {
228
- const bytes = ethers.randomBytes(32);
229
- return BigInt(ethers.hexlify(bytes));
296
+ // BigInt comparison if needed, but for now simple check if it exceeds
297
+ if (BigInt(allowed) > BigInt(totalAmount)) {
298
+ throw new Error(`Requested auth ${allowed} exceeds channel capacity ${totalAmount}`);
230
299
  }
231
300
 
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
- }
301
+ if (!channelId.startsWith("0x")) {
302
+ channelId = "0x" + channelId;
269
303
  }
270
304
 
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
- }
305
+ const token = this.currency.tokenAddress || "0x0000000000000000000000000000000000000000";
306
+
307
+ const { domain, types, value } = getEthereumClaimTypedData(
308
+ channelId,
309
+ token,
310
+ BigInt(allowed),
311
+ this.chainId,
312
+ this.contractAddress
313
+ );
314
+
315
+ const accountString = (await this.walletClient.getAddresses())[0];
316
+ const account = this.walletClient.account || accountString;
317
+ const signature = await this.walletClient.signTypedData({
318
+ account,
319
+ domain,
320
+ types,
321
+ primaryType: 'DhaliClaim',
322
+ message: value
323
+ });
324
+
325
+ const claim = {
326
+ version: "2",
327
+ account: accountString,
328
+ protocol: this.currency.network,
329
+ currency: {
330
+ code: this.currency.code,
331
+ scale: this.currency.scale,
332
+ issuer: this.currency.tokenAddress || null
333
+ },
334
+ destination_account: this.destinationAddress,
335
+ authorized_to_claim: allowed,
336
+ channel_id: channelId,
337
+ signature: signature
338
+ };
339
+ return Buffer.from(JSON.stringify(claim)).toString("base64");
340
+ }
329
341
  }
330
342
 
331
343
  module.exports = { DhaliEthChannelManager };
344
+
@@ -13,15 +13,13 @@ class DhaliXrplChannelManager {
13
13
  /**
14
14
  * @param {import("xrpl").Wallet} wallet
15
15
  * @param {import("xrpl").Client} rpc_client
16
- * @param {string} protocol
17
16
  * @param {import("./Currency")} currency
18
17
  * @param {typeof fetch} [httpClient]
19
18
  * @param {object} [public_config]
20
19
  */
21
- constructor(wallet, rpc_client, protocol, currency, httpClient = fetch, public_config) {
20
+ constructor(wallet, rpc_client, currency, httpClient = fetch, public_config) {
22
21
  this.wallet = wallet;
23
22
  this.rpc_client = rpc_client;
24
- this.protocol = protocol;
25
23
  this.currency = currency;
26
24
  this.httpClient = httpClient || fetch;
27
25
  this.public_config = public_config;
@@ -38,7 +36,7 @@ class DhaliXrplChannelManager {
38
36
 
39
37
  if (!this.destination) {
40
38
  try {
41
- this.destination = this.public_config.DHALI_PUBLIC_ADDRESSES[this.protocol][this.currency.code].wallet_id;
39
+ this.destination = this.public_config.DHALI_PUBLIC_ADDRESSES[this.currency.network][this.currency.code].wallet_id;
42
40
  } catch (e) {
43
41
  // Fallback to default if needed, or throw
44
42
  this.destination = "rJiAX3Xk2Fq3KJrjsGajrB5LENZq7VCwAd";
@@ -53,7 +51,7 @@ class DhaliXrplChannelManager {
53
51
  */
54
52
  async _retrieveChannelIdFromFirestore() {
55
53
  return await retrieveChannelIdFromFirestoreRest(
56
- this.protocol,
54
+ this.currency.network,
57
55
  this.currency,
58
56
  this.wallet.address,
59
57
  this.httpClient
@@ -147,7 +145,7 @@ class DhaliXrplChannelManager {
147
145
  currencyIdentifier = `${this.currency.code}.${this.currency.tokenAddress}`;
148
146
  }
149
147
  await notifyAdminGateway(
150
- this.protocol,
148
+ this.currency.network,
151
149
  currencyIdentifier,
152
150
  this.wallet.classicAddress,
153
151
  channelId,
@@ -185,7 +183,7 @@ class DhaliXrplChannelManager {
185
183
  const claim = {
186
184
  version: "2",
187
185
  account: this.wallet.classicAddress,
188
- protocol: this.protocol,
186
+ protocol: this.currency.network,
189
187
  currency: { code: "XRP", scale: 6 },
190
188
  destination_account: this.destination,
191
189
  authorized_to_claim: allowed.toString(),
@@ -8,11 +8,8 @@ const Currency = require("./Currency");
8
8
 
9
9
  /**
10
10
  * Fetches and parses available Dhali currencies and configurations.
11
- * @returns {Promise<Object.<string, Object.<string, NetworkCurrencyConfig>>>}
12
- */
13
- /**
14
11
  * @param {typeof fetch} [httpClient]
15
- * @returns {Promise<Object>}
12
+ * @returns {Promise<Currency[]>}
16
13
  */
17
14
  async function getAvailableDhaliCurrencies(httpClient = fetch) {
18
15
  const url = "https://raw.githubusercontent.com/Dhali-org/Dhali-config/master/public.prod.json";
@@ -28,11 +25,10 @@ async function getAvailableDhaliCurrencies(httpClient = fetch) {
28
25
  }
29
26
 
30
27
  const publicAddresses = data.DHALI_PUBLIC_ADDRESSES || {};
31
- /** @type {Object.<string, Object.<string, NetworkCurrencyConfig>>} */
32
- const result = {};
28
+ /** @type {Currency[]} */
29
+ const result = [];
33
30
 
34
31
  for (const [network, currencies] of Object.entries(publicAddresses)) {
35
- result[network] = {};
36
32
  for (const [code, details] of Object.entries(currencies)) {
37
33
  const tokenAddress = details.issuer || null;
38
34
  const scale = details.scale || 6;
@@ -40,12 +36,8 @@ async function getAvailableDhaliCurrencies(httpClient = fetch) {
40
36
 
41
37
  if (!destination) continue;
42
38
 
43
- const curr = new Currency(code, scale, tokenAddress);
44
-
45
- result[network][code] = {
46
- currency: curr,
47
- destinationAddress: destination
48
- };
39
+ const curr = new Currency(network, code, scale, tokenAddress);
40
+ result.push(curr);
49
41
  }
50
42
  }
51
43
  return result;
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const { DhaliChannelManager } = require("./dhali/DhaliChannelManager");
2
2
  const { DhaliXrplChannelManager, ChannelNotFound } = require("./dhali/DhaliXrplChannelManager");
3
3
  const { DhaliEthChannelManager } = require("./dhali/DhaliEthChannelManager");
4
- const { Currency } = require("./dhali/Currency");
4
+ const Currency = require("./dhali/Currency");
5
5
  const { getAvailableDhaliCurrencies } = require("./dhali/configUtils");
6
6
  const { wrapAsX402PaymentPayload } = require("./dhali/utils");
7
7
 
@@ -53,11 +53,11 @@ describe("DhaliChannelManager", () => {
53
53
  submitAndWait: jest.fn(),
54
54
  autofill: jest.fn().mockResolvedValue({}),
55
55
  };
56
- currency = new Currency("XRP", 6);
56
+ currency = new Currency("XRPL.MAINNET", "XRP", 6);
57
57
 
58
58
  const mockHttp = jest.fn();
59
59
  configUtils.retrieveChannelIdFromFirestoreRest.mockResolvedValue("CHAN123");
60
- manager = new DhaliXrplChannelManager(wallet, mockClient, "XRPL.MAINNET", currency, mockHttp);
60
+ manager = new DhaliXrplChannelManager(wallet, mockClient, currency, mockHttp);
61
61
  });
62
62
 
63
63
  afterEach(() => {
@@ -82,7 +82,7 @@ describe("DhaliChannelManager", () => {
82
82
  test("throws ChannelNotFound if firestore returns null", async () => {
83
83
  const mockHttp = jest.fn();
84
84
  configUtils.retrieveChannelIdFromFirestoreRest.mockResolvedValue(null);
85
- manager = new DhaliXrplChannelManager(wallet, mockClient, "XRPL.MAINNET", currency, mockHttp);
85
+ manager = new DhaliXrplChannelManager(wallet, mockClient, currency, mockHttp);
86
86
  await expect(manager.getAuthToken(100)).rejects.toThrow(ChannelNotFound);
87
87
  await expect(manager.getAuthToken(100)).rejects.toThrow(/No open payment channel from/);
88
88
  expect(configUtils.retrieveChannelIdFromFirestoreRest).toHaveBeenCalledWith(
@@ -1,27 +1,31 @@
1
1
  const { DhaliEthChannelManager } = require("../src/dhali/DhaliEthChannelManager");
2
2
  const Currency = require("../src/dhali/Currency");
3
- const { ethers } = require("ethers");
3
+ const { keccak256, encodeAbiParameters, parseAbiParameters } = require("viem");
4
4
  const configUtils = require("../src/dhali/configUtils");
5
5
 
6
6
  jest.mock("../src/dhali/configUtils");
7
7
 
8
8
  describe("DhaliEthChannelManager", () => {
9
- let mockSigner;
10
- let mockProvider;
9
+ let mockWalletClient;
10
+ let mockPublicClient;
11
11
  let currency;
12
12
  let publicConfig;
13
13
  let manager;
14
14
 
15
15
  beforeEach(() => {
16
- mockSigner = {
17
- getAddress: jest.fn().mockResolvedValue("0x0000000000000000000000000000000000000001"),
18
- getNonce: jest.fn(),
19
- estimateGas: jest.fn(),
20
- signTypedData: jest.fn(),
21
- sendTransaction: jest.fn()
16
+ mockWalletClient = {
17
+ getAddresses: jest.fn().mockResolvedValue(["0x0000000000000000000000000000000000000001"]),
18
+ sendTransaction: jest.fn(),
19
+ signTypedData: jest.fn()
22
20
  };
23
- mockProvider = {};
24
- currency = new Currency("ETH", 18);
21
+ mockPublicClient = {
22
+ getGasPrice: jest.fn().mockResolvedValue(BigInt(1000000000)),
23
+ estimateGas: jest.fn().mockResolvedValue(BigInt(21000)),
24
+ getTransactionCount: jest.fn().mockResolvedValue(10),
25
+ waitForTransactionReceipt: jest.fn(),
26
+ call: jest.fn()
27
+ };
28
+ currency = new Currency("ETHEREUM", "ETH", 18);
25
29
  publicConfig = {
26
30
  DHALI_PUBLIC_ADDRESSES: {
27
31
  ETHEREUM: {
@@ -33,30 +37,30 @@ describe("DhaliEthChannelManager", () => {
33
37
  }
34
38
  };
35
39
  configUtils.fetchPublicConfig.mockResolvedValue(publicConfig);
36
- manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, null, publicConfig);
40
+ manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, null, publicConfig);
37
41
  jest.clearAllMocks();
38
42
  });
39
43
 
40
44
  test("initializes without default http client", () => {
41
- const localManager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, null, publicConfig);
45
+ const localManager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, null, publicConfig);
42
46
  expect(localManager.httpClient).toBe(fetch);
43
47
  expect(localManager.chainId).toBe(1);
44
48
  });
45
49
 
46
50
  test("initializes with provided http client", () => {
47
51
  const mockHttp = jest.fn();
48
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, mockHttp, publicConfig);
52
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, mockHttp, publicConfig);
49
53
  expect(manager.httpClient).toBe(mockHttp);
50
54
  });
51
55
 
52
56
  test("initializes without config or addresses (lazy resolution)", () => {
53
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, null);
57
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, null);
54
58
  expect(manager.destinationAddress).toBeUndefined();
55
59
  expect(manager.contractAddress).toBeUndefined();
56
60
  });
57
61
 
58
62
  test("resolves addresses lazily", async () => {
59
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, null);
63
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, null);
60
64
 
61
65
  await manager._resolveAddresses();
62
66
 
@@ -66,7 +70,7 @@ describe("DhaliEthChannelManager", () => {
66
70
  });
67
71
 
68
72
  test("resolves destination and contract addresses from provided config", async () => {
69
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, null, publicConfig);
73
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, null, publicConfig);
70
74
  await manager._resolveAddresses();
71
75
  expect(manager.destinationAddress).toBe("0x0000000000000000000000000000000000000002");
72
76
  expect(manager.contractAddress).toBe("0x0000000000000000000000000000000000000003");
@@ -74,16 +78,16 @@ describe("DhaliEthChannelManager", () => {
74
78
  });
75
79
 
76
80
  test("calculates channel ID correctly", async () => {
77
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, null, publicConfig);
78
- mockSigner.address = "0x0000000000000000000000000000000000000001";
81
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, null, publicConfig);
82
+ const sender = "0x0000000000000000000000000000000000000001";
79
83
  const receiver = "0x0000000000000000000000000000000000000002";
80
84
  const token = "0x0000000000000000000000000000000000000003";
81
85
  const nonce = 12345n;
82
86
 
83
- const expectedId = ethers.keccak256(
84
- ethers.AbiCoder.defaultAbiCoder().encode(
85
- ["address", "address", "address", "uint256"],
86
- ["0x0000000000000000000000000000000000000001", receiver, token, nonce]
87
+ const expectedId = keccak256(
88
+ encodeAbiParameters(
89
+ parseAbiParameters("address, address, address, uint256"),
90
+ [sender, receiver, token, nonce]
87
91
  )
88
92
  );
89
93
 
@@ -98,18 +102,12 @@ describe("DhaliEthChannelManager", () => {
98
102
  .mockResolvedValueOnce(null) // First poll
99
103
  .mockResolvedValueOnce("0x0000000000000000000000000000000000000000000000000000000000000004"); // Second poll
100
104
 
101
- const localManager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, mockHttp, publicConfig);
105
+ const localManager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, mockHttp, publicConfig);
102
106
  localManager._generateNonce = jest.fn().mockReturnValue(54321n);
103
107
  localManager._calculateChannelId = jest.fn().mockReturnValue("0xCalculatedId");
104
108
 
105
- mockSigner.getAddress.mockResolvedValue("0x0000000000000000000000000000000000000001");
106
- mockSigner.getNonce.mockResolvedValue(10);
107
- mockSigner.estimateGas.mockResolvedValue(BigInt(21000));
108
- mockSigner.sendTransaction.mockResolvedValue({
109
- wait: jest.fn().mockResolvedValue({ status: 1 })
110
- });
111
-
112
- mockProvider.getFeeData = jest.fn().mockResolvedValue({ gasPrice: BigInt(1000000000) });
109
+ mockWalletClient.sendTransaction.mockResolvedValue("0xTxHash");
110
+ mockPublicClient.waitForTransactionReceipt.mockResolvedValue({ status: "success" });
113
111
 
114
112
  configUtils.notifyAdminGateway.mockResolvedValue();
115
113
 
@@ -118,7 +116,7 @@ describe("DhaliEthChannelManager", () => {
118
116
 
119
117
  const receipt = await localManager.deposit(100);
120
118
 
121
- expect(receipt.status).toBe(1);
119
+ expect(receipt.status).toBe("success");
122
120
  expect(configUtils.notifyAdminGateway).toHaveBeenCalledWith(
123
121
  "ETHEREUM",
124
122
  "ETH",
@@ -135,7 +133,7 @@ describe("DhaliEthChannelManager", () => {
135
133
  test("getAuthToken throws if channel not found after polling (REST)", async () => {
136
134
  const mockHttp = jest.fn();
137
135
  configUtils.retrieveChannelIdFromFirestoreRest.mockResolvedValue(null);
138
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, mockHttp, publicConfig);
136
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, mockHttp, publicConfig);
139
137
 
140
138
  const originalTimeout = global.setTimeout;
141
139
  global.setTimeout = (cb) => cb();
@@ -148,18 +146,17 @@ describe("DhaliEthChannelManager", () => {
148
146
  test("getAuthToken defaults to channel capacity if amount is null", async () => {
149
147
  const mockHttp = jest.fn();
150
148
  configUtils.retrieveChannelIdFromFirestoreRest.mockResolvedValue("0x0000000000000000000000000000000000000000000000000000000000000005");
151
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, mockHttp, publicConfig);
149
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, mockHttp, publicConfig);
152
150
 
153
151
  // Mock on-chain response for getChannel
154
152
  // Selector (4) + 5 words (5 * 32 bytes = 160 bytes = 320 chars)
155
153
  // Amount is word 4 (index 4).
156
154
  // 0x + 64*4 chars of padding + 5000 in hex (padded to 64 chars)
157
155
  const amountHex = BigInt(5000).toString(16).padStart(64, '0');
158
- const mockResult = "0x" + "0".repeat(64 * 4) + amountHex;
159
- mockProvider.call = jest.fn().mockResolvedValue(mockResult);
156
+ const mockResult = { data: "0x" + "0".repeat(64 * 4) + amountHex };
157
+ mockPublicClient.call = jest.fn().mockResolvedValue(mockResult);
160
158
 
161
- mockSigner.getAddress.mockResolvedValue("0x0000000000000000000000000000000000000001");
162
- mockSigner.signTypedData.mockResolvedValue("0xSignature");
159
+ mockWalletClient.signTypedData.mockResolvedValue("0xSignature");
163
160
 
164
161
  const originalTimeout = global.setTimeout;
165
162
  global.setTimeout = (cb) => cb();
@@ -169,11 +166,42 @@ describe("DhaliEthChannelManager", () => {
169
166
 
170
167
  expect(decoded.authorized_to_claim).toBe("5000");
171
168
  expect(decoded.channel_id).toBe("0x0000000000000000000000000000000000000000000000000000000000000005");
172
- expect(mockProvider.call).toHaveBeenCalled();
169
+ expect(mockPublicClient.call).toHaveBeenCalled();
173
170
 
174
171
  global.setTimeout = originalTimeout;
175
172
  });
176
173
 
174
+ test("deposit notifies admin gateway with lowercase address", async () => {
175
+ const mockHttp = jest.fn();
176
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, mockHttp, publicConfig);
177
+
178
+ // Setup state for new channel creation
179
+ manager._retrieveChannelIdFromFirestore = jest.fn().mockResolvedValue(null);
180
+ // Provide a valid mixed-case 40-character EIP-55 Ethereum address
181
+ mockWalletClient.getAddresses.mockResolvedValue(["0x71C7656EC7ab88b098defB751B7401B5f6d8976F"]);
182
+ mockWalletClient.sendTransaction = jest.fn().mockResolvedValue("0xhash");
183
+ mockPublicClient.waitForTransactionReceipt = jest.fn().mockResolvedValue({ status: 1 });
184
+
185
+ // Mock polling so deposit finishes
186
+ manager._retrieveChannelIdFromFirestoreWithPolling = jest.fn().mockResolvedValue("0xnewid");
187
+
188
+ // Mock crypto so we can predict channel id or at least verify it's called
189
+ const originalBytes = crypto.randomBytes;
190
+ crypto.randomBytes = jest.fn().mockReturnValue(Buffer.from("00".repeat(32), "hex"));
191
+
192
+ await manager.deposit(100);
193
+
194
+ expect(configUtils.notifyAdminGateway).toHaveBeenCalledWith(
195
+ "ETHEREUM",
196
+ "ETH",
197
+ "0x71c7656ec7ab88b098defb751b7401b5f6d8976f", // Expect perfect lowercase
198
+ expect.any(String),
199
+ mockHttp
200
+ );
201
+
202
+ crypto.randomBytes = originalBytes;
203
+ });
204
+
177
205
  test("getAuthToken polls Firestore (REST)", async () => {
178
206
  const mockHttp = jest.fn();
179
207
  configUtils.retrieveChannelIdFromFirestoreRest
@@ -181,12 +209,11 @@ describe("DhaliEthChannelManager", () => {
181
209
  .mockResolvedValueOnce("0x0000000000000000000000000000000000000000000000000000000000000005"); // Second poll
182
210
 
183
211
  const amountHex = BigInt(1000).toString(16).padStart(64, '0');
184
- const mockResult = "0x" + "0".repeat(64 * 4) + amountHex;
185
- mockProvider.call = jest.fn().mockResolvedValue(mockResult);
212
+ const mockResult = { data: "0x" + "0".repeat(64 * 4) + amountHex };
213
+ mockPublicClient.call = jest.fn().mockResolvedValue(mockResult);
186
214
 
187
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, mockHttp, publicConfig);
188
- mockSigner.getAddress.mockResolvedValue("0x0000000000000000000000000000000000000001");
189
- mockSigner.signTypedData.mockResolvedValue("0xSignature");
215
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, mockHttp, publicConfig);
216
+ mockWalletClient.signTypedData.mockResolvedValue("0xSignature");
190
217
 
191
218
  const originalTimeout = global.setTimeout;
192
219
  global.setTimeout = (cb) => cb();
@@ -201,8 +228,8 @@ describe("DhaliEthChannelManager", () => {
201
228
 
202
229
  test("queries Firestore with lowercase address (REST)", async () => {
203
230
  const mockHttp = jest.fn();
204
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, mockHttp, publicConfig);
205
- mockSigner.getAddress.mockResolvedValue("0xMixEdCaSeAdDrEsS");
231
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, mockHttp, publicConfig);
232
+ mockWalletClient.getAddresses.mockResolvedValue(["0xMixEdCaSeAdDrEsS"]);
206
233
 
207
234
  await manager._retrieveChannelIdFromFirestore();
208
235
 
@@ -215,8 +242,8 @@ describe("DhaliEthChannelManager", () => {
215
242
  });
216
243
 
217
244
  test("uses default REST if no function provided", async () => {
218
- const manager = new DhaliEthChannelManager(mockSigner, mockProvider, "ETHEREUM", currency, null, publicConfig);
219
- mockSigner.getAddress.mockResolvedValue("0xMyAddr");
245
+ const manager = new DhaliEthChannelManager(mockWalletClient, mockPublicClient, currency, null, publicConfig);
246
+ mockWalletClient.getAddresses.mockResolvedValue(["0xMyAddr"]);
220
247
  configUtils.retrieveChannelIdFromFirestoreRest.mockResolvedValue("0xRestId");
221
248
 
222
249
  const id = await manager._retrieveChannelIdFromFirestore();