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.
- package/.github/workflows/tests.yaml +1 -1
- package/README.md +118 -61
- package/package.json +2 -2
- package/src/dhali/Currency.js +2 -1
- package/src/dhali/DhaliChannelManager.js +7 -10
- package/src/dhali/DhaliEthChannelManager.js +313 -300
- package/src/dhali/DhaliXrplChannelManager.js +5 -7
- package/src/dhali/configUtils.js +5 -13
- package/src/index.js +1 -1
- package/tests/DhaliChannelManager.test.js +3 -3
- package/tests/DhaliEthChannelManager.test.js +76 -49
|
@@ -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
|
-
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
59
|
+
|
|
60
|
+
main();
|
|
54
61
|
```
|
|
55
62
|
|
|
56
63
|
### 2. Ethereum (EVM)
|
|
57
64
|
|
|
58
|
-
Uses `
|
|
65
|
+
Uses `viem` for EIP-712 signing.
|
|
59
66
|
|
|
60
67
|
```js
|
|
61
|
-
const {
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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": "
|
|
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": {
|
package/src/dhali/Currency.js
CHANGED
|
@@ -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,
|
|
15
|
-
return new DhaliXrplChannelManager(wallet, client,
|
|
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("
|
|
20
|
-
* @param {import("
|
|
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: (
|
|
25
|
+
evm: (walletClient, publicClient, currency, httpClient, publicConfig) => {
|
|
28
26
|
return new DhaliEthChannelManager(
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
42
|
+
async _resolveAddresses() {
|
|
43
|
+
if (this.destinationAddress && this.contractAddress) return;
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(),
|
package/src/dhali/configUtils.js
CHANGED
|
@@ -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<
|
|
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 {
|
|
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
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
|
10
|
-
let
|
|
9
|
+
let mockWalletClient;
|
|
10
|
+
let mockPublicClient;
|
|
11
11
|
let currency;
|
|
12
12
|
let publicConfig;
|
|
13
13
|
let manager;
|
|
14
14
|
|
|
15
15
|
beforeEach(() => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
78
|
-
|
|
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 =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
[
|
|
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(
|
|
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
|
-
|
|
106
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
156
|
+
const mockResult = { data: "0x" + "0".repeat(64 * 4) + amountHex };
|
|
157
|
+
mockPublicClient.call = jest.fn().mockResolvedValue(mockResult);
|
|
160
158
|
|
|
161
|
-
|
|
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(
|
|
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
|
-
|
|
212
|
+
const mockResult = { data: "0x" + "0".repeat(64 * 4) + amountHex };
|
|
213
|
+
mockPublicClient.call = jest.fn().mockResolvedValue(mockResult);
|
|
186
214
|
|
|
187
|
-
const manager = new DhaliEthChannelManager(
|
|
188
|
-
|
|
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(
|
|
205
|
-
|
|
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(
|
|
219
|
-
|
|
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();
|