dhali-js 3.0.4 → 3.1.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.
@@ -25,8 +25,14 @@ jobs:
25
25
  - name: Install dependencies
26
26
  run: npm ci
27
27
 
28
- - name: Run tests
29
- run: npm test
28
+ - name: Run unit tests
29
+ run: npm test tests/DhaliChannelManager.test.js tests/DhaliEthChannelManager.test.js tests/createSignedClaim.test.js tests/utils.test.js
30
+
31
+ - name: Run integration tests
32
+ env:
33
+ XRPL_TESTNET_SECRET: ${{ secrets.XRPL_TESTNET_SECRET }}
34
+ SEPOLIA_TESTNET_SECRET: ${{ secrets.SEPOLIA_TESTNET_SECRET }}
35
+ run: npm test tests/integration.test.js
30
36
 
31
37
  - name: Run lint
32
38
  run: npx tsc src/dhali/*.js --allowJs --checkJs --noEmit --moduleResolution node --module commonjs --target esnext --skipLibCheck
package/README.md CHANGED
@@ -148,6 +148,81 @@ async function main() {
148
148
  main();
149
149
  ```
150
150
 
151
+
152
+ ---
153
+
154
+ ## Asset Management (for Providers)
155
+
156
+ If you have an API you want to monetize on Dhali, you can use the `DhaliAssetManager` to create and update your asset on the network.
157
+
158
+ ### 1. Create an Asset
159
+
160
+ This generates an **Asset ID (UUID)**.
161
+
162
+ #### XRPL Setup
163
+ ```js
164
+ const { Wallet } = require('xrpl');
165
+ const wallet = Wallet.fromSeed("s..."); // Your XRPL seed
166
+ ```
167
+
168
+ #### EVM Setup
169
+ ```js
170
+ const { createWalletClient, http } = require('viem');
171
+ const { privateKeyToAccount } = require('viem/accounts');
172
+ const { sepolia } = require('viem/chains');
173
+
174
+ const walletClient = createWalletClient({
175
+ account: privateKeyToAccount("0x..."),
176
+ chain: sepolia,
177
+ transport: http()
178
+ });
179
+ ```
180
+
181
+ #### Initialization & Creation
182
+ ```js
183
+ const { DhaliAssetManager, WalletDescriptor, Currency } = require('dhali-js');
184
+
185
+ async function main() {
186
+ // For XRPL
187
+ const manager = DhaliAssetManager.xrpl(wallet);
188
+ const walletDescriptor = new WalletDescriptor(wallet.classicAddress, "XRPL.TESTNET");
189
+
190
+ // OR For EVM
191
+ // const manager = DhaliAssetManager.evm(walletClient);
192
+ // const walletDescriptor = new WalletDescriptor(walletClient.account.address, "SEPOLIA");
193
+
194
+ const currency = new Currency("XRPL.TESTNET", "XRP", 6);
195
+
196
+ // Create the asset
197
+ const result = await manager.createAsset(walletDescriptor, currency);
198
+ console.log("Your new Asset ID:", result.uuid);
199
+ }
200
+ ```
201
+
202
+ Once created, your asset is represented by an **off-chain facilitator address**:
203
+ `https://x402.api.dhali.io/<uuid>`
204
+
205
+ This facilitator is used for protocol-level concerns like verification and settlement, while your actual service requests are sent to your **Resource Server**.
206
+
207
+ ### 2. Update an Asset
208
+
209
+ You can update your asset's metadata (name, rates, etc.) at any time.
210
+
211
+ ```js
212
+ const { AssetUpdates } = require('dhali-js');
213
+
214
+ async function main() {
215
+ const updates = new AssetUpdates({
216
+ name: "My Optimized AI API",
217
+ earning_rate: 100, // 100 drops per request
218
+ earning_type: "per_request" // or "per_second"
219
+ });
220
+
221
+ const result = await manager.updateAsset(assetId, walletDescriptor, updates);
222
+ console.log("Asset updated successfully");
223
+ }
224
+ ```
225
+
151
226
  ---
152
227
 
153
228
  ## API Reference
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dhali-js",
3
- "version": "3.0.4",
3
+ "version": "3.1.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",
@@ -18,6 +18,7 @@
18
18
  "dependencies": {
19
19
  "ripple-keypairs": "^2.0.0",
20
20
  "viem": "^2.23.5",
21
+ "ws": "^8.18.0",
21
22
  "xrpl": "^4.0.0"
22
23
  },
23
24
  "devDependencies": {
@@ -0,0 +1,67 @@
1
+ class AssetUpdates {
2
+ /**
3
+ * @param {Object} options
4
+ * @param {string} [options.name]
5
+ * @param {number} [options.earningRate]
6
+ * @param {string} [options.earningType]
7
+ * @param {string} [options.url]
8
+ * @param {Object} [options.headers]
9
+ * @param {string} [options.docs]
10
+ * @param {number} [options.maxSurcharge]
11
+ * @param {number} [options.assetPricingRate]
12
+ * @param {number} [options.assetPricingMaxSurcharge]
13
+ * @param {Object} [options.assetPricingCurrency]
14
+ */
15
+ constructor({
16
+ name,
17
+ earningRate,
18
+ earningType,
19
+ url,
20
+ headers,
21
+ docs,
22
+ maxSurcharge,
23
+ assetPricingRate,
24
+ assetPricingMaxSurcharge,
25
+ assetPricingCurrency
26
+ } = {}) {
27
+ this.name = name;
28
+ this.earningRate = earningRate;
29
+ this.earningType = earningType;
30
+ this.url = url;
31
+ this.headers = headers;
32
+ this.docs = docs;
33
+ this.maxSurcharge = maxSurcharge;
34
+ this.assetPricingRate = assetPricingRate;
35
+ this.assetPricingMaxSurcharge = assetPricingMaxSurcharge;
36
+ this.assetPricingCurrency = assetPricingCurrency;
37
+ }
38
+
39
+ /**
40
+ * Converts the updates to the format expected by api-admin-gateway
41
+ * @returns {Object}
42
+ */
43
+ toGatewayFormat() {
44
+ const updates = {};
45
+ if (this.name !== undefined) updates["name"] = this.name;
46
+ if (this.earningRate !== undefined) updates["asset_earning_rate"] = this.earningRate;
47
+ if (this.earningType !== undefined) updates["asset_earning_type"] = this.earningType;
48
+ if (this.docs !== undefined) updates["docs"] = this.docs;
49
+ if (this.maxSurcharge !== undefined) updates["asset_earning_max_surcharge"] = this.maxSurcharge;
50
+ if (this.assetPricingRate !== undefined) updates["asset_pricing_rate"] = this.assetPricingRate;
51
+ if (this.assetPricingMaxSurcharge !== undefined) updates["asset_pricing_max_surcharge"] = this.assetPricingMaxSurcharge;
52
+ if (this.assetPricingCurrency !== undefined) updates["asset_pricing_currency"] = this.assetPricingCurrency;
53
+
54
+ // Credentials/URL and headers are special
55
+ if (this.url !== undefined || this.headers !== undefined) {
56
+ updates["api_credentials"] = {};
57
+ if (this.url !== undefined) updates["api_credentials"]["url"] = this.url;
58
+ if (this.headers !== undefined) {
59
+ Object.assign(updates["api_credentials"], this.headers);
60
+ }
61
+ }
62
+
63
+ return updates;
64
+ }
65
+ }
66
+
67
+ module.exports = { AssetUpdates };
@@ -0,0 +1,182 @@
1
+ const { WebSocket } = require('ws');
2
+ const Currency = require('./Currency');
3
+ const { AssetUpdates } = require('./AssetUpdates');
4
+ const { WalletDescriptor } = require('./WalletDescriptor');
5
+ const { fetchPublicConfig } = require('./configUtils');
6
+
7
+ class BaseAssetManager {
8
+ /**
9
+ * @param {any} wallet
10
+ * @param {string} [baseUrl]
11
+ */
12
+ constructor(wallet, baseUrl) {
13
+ this.baseUrl = baseUrl ? baseUrl.replace(/^http/, 'ws') : undefined;
14
+ this.wallet = wallet;
15
+ }
16
+
17
+ async _resolveBaseUrl() {
18
+ if (this.baseUrl) return;
19
+ const config = await fetchPublicConfig();
20
+ const rootUrl = config.ROOT_API_ADMIN_URL;
21
+ if (!rootUrl) {
22
+ throw new Error("ROOT_API_ADMIN_URL not found in public config");
23
+ }
24
+ this.baseUrl = rootUrl.replace(/^http/, 'ws');
25
+ }
26
+
27
+ /**
28
+ * Abstract method to handle protocol-specific signing
29
+ * @protected
30
+ * @returns {Promise<any>}
31
+ */
32
+ async _performSigning(typedData, walletDescriptor) {
33
+ throw new Error("_performSigning must be implemented by subclass");
34
+ }
35
+
36
+ async _handleAuth(ws, message, walletDescriptor) {
37
+ if (message.schema === 'api_admin_gateway_message_to_be_signed') {
38
+ const { message: typedData } = message;
39
+ const authResponse = await this._performSigning(typedData, walletDescriptor);
40
+ ws.send(JSON.stringify(authResponse));
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * @param {WalletDescriptor} walletDescriptor
48
+ * @param {Currency} currency
49
+ */
50
+ async createAsset(walletDescriptor, currency) {
51
+ await this._resolveBaseUrl();
52
+ if (!(walletDescriptor instanceof WalletDescriptor)) {
53
+ throw new Error('walletDescriptor must be an instance of WalletDescriptor');
54
+ }
55
+ if (!(currency instanceof Currency)) {
56
+ throw new Error('currency must be an instance of Currency');
57
+ }
58
+
59
+ return new Promise((resolve, reject) => {
60
+ const ws = new WebSocket(`${this.baseUrl}/create`);
61
+
62
+ ws.on('open', () => {
63
+ ws.send(JSON.stringify({
64
+ owner: walletDescriptor.toJson(),
65
+ currency: {
66
+ code: currency.code,
67
+ scale: currency.scale,
68
+ issuer: currency.tokenAddress
69
+ }
70
+ }));
71
+ });
72
+
73
+ ws.on('message', async (data) => {
74
+ const message = JSON.parse(data.toString());
75
+
76
+ try {
77
+ if (await this._handleAuth(ws, message, walletDescriptor)) {
78
+ return;
79
+ }
80
+
81
+ if (message.schema === 'api_admin_gateway_request_wallet_json') {
82
+ ws.send(JSON.stringify({
83
+ schema: 'api_admin_gateway_wallet_json_response',
84
+ wallet: walletDescriptor.toJson()
85
+ }));
86
+ } else if (message.schema === 'api_admin_gateway_create_successful') {
87
+ resolve(message);
88
+ ws.close();
89
+ } else if (message.qr_code_url) {
90
+ console.log('Scan this QR code to authenticate:', message.qr_code_url);
91
+ } else if (message.error) {
92
+ reject(new Error(message.error));
93
+ ws.close();
94
+ }
95
+ } catch (err) {
96
+ reject(err);
97
+ ws.close();
98
+ }
99
+ });
100
+
101
+ ws.on('error', (error) => {
102
+ reject(error);
103
+ });
104
+
105
+ ws.on('close', (code, reason) => {
106
+ if (code !== 1000 && code !== 1005) {
107
+ reject(new Error(`WebSocket closed with code ${code}: ${reason}`));
108
+ }
109
+ });
110
+ });
111
+ }
112
+
113
+ /**
114
+ * @param {string} dhaliId
115
+ * @param {WalletDescriptor} walletDescriptor
116
+ * @param {AssetUpdates} updates
117
+ */
118
+ async updateAsset(dhaliId, walletDescriptor, updates) {
119
+ await this._resolveBaseUrl();
120
+ if (!(walletDescriptor instanceof WalletDescriptor)) {
121
+ throw new Error('walletDescriptor must be an instance of WalletDescriptor');
122
+ }
123
+ if (!(updates instanceof AssetUpdates)) {
124
+ throw new Error('updates must be an instance of AssetUpdates');
125
+ }
126
+
127
+ return new Promise((resolve, reject) => {
128
+ const ws = new WebSocket(`${this.baseUrl}/${dhaliId}/update`);
129
+
130
+ ws.on('message', async (data) => {
131
+ const message = JSON.parse(data.toString());
132
+
133
+ try {
134
+ if (await this._handleAuth(ws, message, walletDescriptor)) {
135
+ return;
136
+ }
137
+
138
+ if (message.schema === 'api_admin_gateway_request_wallet_json') {
139
+ ws.send(JSON.stringify({
140
+ schema: 'api_admin_gateway_wallet_json_response',
141
+ wallet: walletDescriptor.toJson()
142
+ }));
143
+ } else if (message.schema === 'api_admin_gateway_authentication_successful') {
144
+ ws.send(JSON.stringify({
145
+ schema: 'api_admin_gateway_prefill_request',
146
+ schema_version: '1.0'
147
+ }));
148
+ } else if (message.schema === 'api_admin_gateway_prefill_response') {
149
+ ws.send(JSON.stringify({
150
+ schema: 'api_admin_gateway_update_request',
151
+ schema_version: '1.0',
152
+ updates: updates.toGatewayFormat()
153
+ }));
154
+ } else if (message.schema === 'api_admin_gateway_update_response') {
155
+ resolve(message);
156
+ ws.close();
157
+ } else if (message.qr_code_url) {
158
+ console.log('Scan this QR code to authenticate:', message.qr_code_url);
159
+ } else if (message.error || message.status === 'failed') {
160
+ reject(new Error(message.error || 'Update failed'));
161
+ ws.close();
162
+ }
163
+ } catch (err) {
164
+ reject(err);
165
+ ws.close();
166
+ }
167
+ });
168
+
169
+ ws.on('error', (error) => {
170
+ reject(error);
171
+ });
172
+
173
+ ws.on('close', (code, reason) => {
174
+ if (code !== 1000 && code !== 1005) {
175
+ reject(new Error(`WebSocket closed with code ${code}: ${reason}`));
176
+ }
177
+ });
178
+ });
179
+ }
180
+ }
181
+
182
+ module.exports = { BaseAssetManager };
@@ -0,0 +1,25 @@
1
+ const { DhaliXrplAssetManager } = require('./DhaliXrplAssetManager');
2
+ const { DhaliEthAssetManager } = require('./DhaliEthAssetManager');
3
+
4
+ /**
5
+ * Factory for creating asset managers.
6
+ */
7
+ const DhaliAssetManager = {
8
+ /**
9
+ * @param {import("xrpl").Wallet} wallet
10
+ * @returns {DhaliXrplAssetManager}
11
+ */
12
+ xrpl: (wallet) => {
13
+ return new DhaliXrplAssetManager(wallet);
14
+ },
15
+
16
+ /**
17
+ * @param {import("viem").WalletClient} walletClient
18
+ * @returns {DhaliEthAssetManager}
19
+ */
20
+ evm: (walletClient) => {
21
+ return new DhaliEthAssetManager(walletClient);
22
+ }
23
+ };
24
+
25
+ module.exports = { DhaliAssetManager };
@@ -0,0 +1,35 @@
1
+ const { BaseAssetManager } = require('./BaseAssetManager');
2
+
3
+ /**
4
+ * DhaliAssetManager for EVM protocol.
5
+ */
6
+ class DhaliEthAssetManager extends BaseAssetManager {
7
+ /**
8
+ * @param {import("viem").WalletClient} walletClient
9
+ * @param {string} [baseUrl]
10
+ */
11
+ constructor(walletClient, baseUrl) {
12
+ super(walletClient, baseUrl);
13
+ }
14
+
15
+ /**
16
+ * @protected
17
+ */
18
+ async _performSigning(typedData, walletDescriptor) {
19
+ const [account] = await this.wallet.getAddresses();
20
+ const signature = await this.wallet.signTypedData({
21
+ account: this.wallet.account || account,
22
+ domain: typedData.domain,
23
+ types: typedData.types,
24
+ primaryType: typedData.primaryType,
25
+ message: typedData.message
26
+ });
27
+
28
+ return {
29
+ schema: 'api_admin_gateway_signed_message_response',
30
+ signature: signature
31
+ };
32
+ }
33
+ }
34
+
35
+ module.exports = { DhaliEthAssetManager };
@@ -95,7 +95,6 @@ class DhaliEthChannelManager {
95
95
  }
96
96
 
97
97
  async _calculateChannelId(receiver, tokenAddress, nonce) {
98
- // Matches Dhali-wallet: keccak256(abi.encode(sender, receiver, token, nonce))
99
98
  const [sender] = await this.walletClient.getAddresses();
100
99
  return keccak256(
101
100
  encodeAbiParameters(
@@ -0,0 +1,32 @@
1
+ const { BaseAssetManager } = require('./BaseAssetManager');
2
+ const { sign: signClaim } = require("ripple-keypairs");
3
+
4
+ /**
5
+ * DhaliAssetManager for XRPL protocol.
6
+ */
7
+ class DhaliXrplAssetManager extends BaseAssetManager {
8
+ /**
9
+ * @param {import("xrpl").Wallet} wallet
10
+ * @param {string} [baseUrl]
11
+ */
12
+ constructor(wallet, baseUrl) {
13
+ super(wallet, baseUrl);
14
+ }
15
+
16
+ /**
17
+ * @protected
18
+ */
19
+ async _performSigning(typedData, walletDescriptor) {
20
+ // The backend expects the message as a JSON string for XRPL
21
+ const messageToSign = JSON.stringify(typedData);
22
+ const signature = signClaim(Buffer.from(messageToSign).toString('hex'), this.wallet.privateKey);
23
+
24
+ return {
25
+ schema: 'api_admin_gateway_signed_message_response',
26
+ signature: signature.toUpperCase(),
27
+ public_key: this.wallet.publicKey
28
+ };
29
+ }
30
+ }
31
+
32
+ module.exports = { DhaliXrplAssetManager };
@@ -58,12 +58,27 @@ class DhaliXrplChannelManager {
58
58
  );
59
59
  }
60
60
 
61
- async _findChannel() {
61
+ async _retrieveChannelIdFromFirestoreWithPolling(timeoutSeconds = 30) {
62
+ const startTime = Date.now();
63
+ while (Date.now() - startTime < timeoutSeconds * 1000) {
64
+ const channelId = await this._retrieveChannelIdFromFirestore();
65
+ if (channelId) return channelId;
66
+ await new Promise(resolve => setTimeout(resolve, 2000));
67
+ }
68
+ return null;
69
+ }
70
+
71
+ async _findChannel(timeoutSeconds = 0) {
62
72
  await this.ready;
63
73
  await this._resolveAddresses();
64
74
 
65
75
  // Prioritize Firestore
66
- const firestoreChannelId = await this._retrieveChannelIdFromFirestore();
76
+ let firestoreChannelId;
77
+ if (timeoutSeconds > 0) {
78
+ firestoreChannelId = await this._retrieveChannelIdFromFirestoreWithPolling(timeoutSeconds);
79
+ } else {
80
+ firestoreChannelId = await this._retrieveChannelIdFromFirestore();
81
+ }
67
82
 
68
83
  if (firestoreChannelId === null) {
69
84
  throw new ChannelNotFound(
@@ -100,7 +115,7 @@ class DhaliXrplChannelManager {
100
115
  await this.ready;
101
116
  let tx;
102
117
  try {
103
- const ch = await this._findChannel();
118
+ const ch = await this._findChannel(0);
104
119
  tx = {
105
120
  TransactionType: "PaymentChannelFund",
106
121
  Account: this.wallet.classicAddress,
@@ -129,11 +144,9 @@ class DhaliXrplChannelManager {
129
144
  const result = await this.rpc_client.submitAndWait(txBlob);
130
145
 
131
146
  // If we just created a channel, notify the gateway
132
- if (tx.TransactionType === "PaymentChannelCreate" &&
133
- // @ts-ignore
134
- (result.result.meta || result.result.metaData)) {
147
+ if (tx.TransactionType === "PaymentChannelCreate") {
135
148
  // @ts-ignore
136
- const meta = result.result.meta || result.result.metaData;
149
+ const meta = result.result.meta || result.result.metaData || {};
137
150
  const affectedNodes = meta.AffectedNodes || [];
138
151
  for (const node of affectedNodes) {
139
152
  const createdNode = node.CreatedNode;
@@ -155,6 +168,8 @@ class DhaliXrplChannelManager {
155
168
  break;
156
169
  }
157
170
  }
171
+ // Poll Firestore to match DhaliEthChannelManager behavior
172
+ await this._retrieveChannelIdFromFirestoreWithPolling(30);
158
173
  }
159
174
 
160
175
  return result.result;
@@ -167,7 +182,7 @@ class DhaliXrplChannelManager {
167
182
  */
168
183
  async getAuthToken(amountDrops) {
169
184
  await this.ready;
170
- const ch = await this._findChannel();
185
+ const ch = await this._findChannel(10);
171
186
  const total = BigInt(ch.amount);
172
187
  const allowed = amountDrops != null ? BigInt(amountDrops) : total;
173
188
  if (allowed > total) {
@@ -0,0 +1,27 @@
1
+ class WalletDescriptor {
2
+ /**
3
+ * @param {string} address - The classic address of the wallet
4
+ * @param {string} protocol - The network protocol (e.g., 'XRPL.TESTNET', 'ETHEREUM')
5
+ * @param {string} [type='Dhali-js'] - The type of wallet (defaults to 'Dhali-js')
6
+ */
7
+ constructor(address, protocol, type = 'Dhali-js') {
8
+ this.address = address;
9
+ this.protocol = protocol;
10
+ this.type = type;
11
+ }
12
+
13
+ /**
14
+ * Converts to JSON format expected by Dhali backend
15
+ * @returns {Object}
16
+ */
17
+ toJson() {
18
+ return {
19
+ address: this.address,
20
+ wallet_id: this.address, // Backward compatibility or specific gateway requirement
21
+ type: this.type,
22
+ protocol: this.protocol
23
+ };
24
+ }
25
+ }
26
+
27
+ module.exports = { WalletDescriptor };
@@ -1,5 +1,7 @@
1
1
  const Currency = require("./Currency");
2
2
 
3
+ let publicConfigCache = null;
4
+
3
5
  /**
4
6
  * @typedef {Object} NetworkCurrencyConfig
5
7
  * @property {Currency} currency
@@ -12,16 +14,21 @@ const Currency = require("./Currency");
12
14
  * @returns {Promise<Currency[]>}
13
15
  */
14
16
  async function getAvailableDhaliCurrencies(httpClient = fetch) {
15
- const url = "https://raw.githubusercontent.com/Dhali-org/Dhali-config/master/public.prod.json";
16
17
  let data;
17
- try {
18
- const response = await httpClient(url);
19
- if (!response.ok) {
20
- throw new Error(`HTTP error! status: ${response.status}`);
18
+ if (publicConfigCache) {
19
+ data = publicConfigCache;
20
+ } else {
21
+ const url = "https://raw.githubusercontent.com/Dhali-org/Dhali-config/master/public.prod.json";
22
+ try {
23
+ const response = await httpClient(url);
24
+ if (!response.ok) {
25
+ throw new Error(`HTTP error! status: ${response.status}`);
26
+ }
27
+ data = await response.json();
28
+ publicConfigCache = data;
29
+ } catch (e) {
30
+ throw new Error(`Failed to fetch Dhali configuration: ${e.message}`);
21
31
  }
22
- data = await response.json();
23
- } catch (e) {
24
- throw new Error(`Failed to fetch Dhali configuration: ${e.message}`);
25
32
  }
26
33
 
27
34
  const publicAddresses = data.DHALI_PUBLIC_ADDRESSES || {};
@@ -48,13 +55,17 @@ async function getAvailableDhaliCurrencies(httpClient = fetch) {
48
55
  * @returns {Promise<Object>}
49
56
  */
50
57
  async function fetchPublicConfig(httpClient = fetch) {
58
+ if (publicConfigCache) {
59
+ return publicConfigCache;
60
+ }
51
61
  const url = "https://raw.githubusercontent.com/Dhali-org/Dhali-config/master/public.prod.json";
52
62
  try {
53
63
  const response = await httpClient(url);
54
64
  if (!response.ok) {
55
65
  throw new Error(`HTTP error! status: ${response.status}`);
56
66
  }
57
- return await response.json();
67
+ publicConfigCache = await response.json();
68
+ return publicConfigCache;
58
69
  } catch (e) {
59
70
  throw new Error(`Failed to fetch Dhali configuration: ${e.message}`);
60
71
  }
@@ -68,6 +79,17 @@ async function fetchPublicConfig(httpClient = fetch) {
68
79
  * @param {string} channelId
69
80
  * @param {typeof fetch} [httpClient]
70
81
  */
82
+ /**
83
+ * @param {string} protocol
84
+ * @returns {boolean}
85
+ */
86
+ function isEvmProtocol(protocol) {
87
+ return ["ETHEREUM", "SEPOLIA", "HOLESKY", "HARDHAT"].includes(protocol.toUpperCase());
88
+ }
89
+
90
+ /**
91
+ * Proactively notifies the Dhali Admin Gateway about a new payment channel.
92
+ */
71
93
  async function notifyAdminGateway(protocol, currencyIdentifier, accountAddress, channelId, httpClient = fetch) {
72
94
  const config = await fetchPublicConfig(httpClient);
73
95
  const rootUrl = config.ROOT_API_ADMIN_URL;
@@ -76,26 +98,39 @@ async function notifyAdminGateway(protocol, currencyIdentifier, accountAddress,
76
98
  const httpRootUrl = rootUrl.replace("wss://", "https://").replace("ws://", "http://");
77
99
  const url = `${httpRootUrl}/public_claim_info/${protocol}/${currencyIdentifier}`;
78
100
 
79
- if (!channelId.startsWith("0x")) {
80
- channelId = "0x" + channelId;
81
- }
82
-
83
101
  const payload = {
84
- account: accountAddress,
85
- channel_id: channelId
102
+ account: isEvmProtocol(protocol) ? accountAddress.toLowerCase() : accountAddress,
103
+ channel_id: (isEvmProtocol(protocol) && !channelId.startsWith("0x")) ? "0x" + channelId : channelId
86
104
  };
87
105
 
88
- try {
89
- await httpClient(url, {
90
- method: "PUT",
91
- headers: {
92
- "Content-Type": "application/json"
93
- },
94
- body: JSON.stringify(payload)
95
- });
96
- } catch (e) {
97
- // Best effort notification
106
+ let retryCount = 0;
107
+ const maxRetries = 10;
108
+ let delay = 1000;
109
+
110
+ while (retryCount <= maxRetries) {
111
+ try {
112
+ const response = await httpClient(url, {
113
+ method: "PUT",
114
+ headers: {
115
+ "Content-Type": "application/json"
116
+ },
117
+ body: JSON.stringify(payload)
118
+ });
119
+ if (response.ok) {
120
+ return;
121
+ }
122
+ console.log(`Attempt ${retryCount + 1} failed to notify public claim info: ${response.status} ${response.statusText}`);
123
+ } catch (e) {
124
+ console.log(`Attempt ${retryCount + 1} error notifying public claim info:`, e);
125
+ }
126
+
127
+ if (retryCount < maxRetries) {
128
+ await new Promise(resolve => setTimeout(resolve, delay));
129
+ delay *= 2;
130
+ }
131
+ retryCount++;
98
132
  }
133
+ console.log(`Failed to notify public claim info after ${maxRetries} retries.`);
99
134
  }
100
135
 
101
136
 
@@ -16,8 +16,7 @@ function wrapAsX402PaymentPayload(claimBase64, paymentRequirementBase64) {
16
16
  if (req.accepts) {
17
17
  req = Array.isArray(req.accepts) ? req.accepts[0] : req.accepts;
18
18
  }
19
-
20
- // Normalize fields to match Dhali-wallet's PaymentRequirements defaults (camelCase)
19
+
21
20
  const normalizedReq = {
22
21
  scheme: req.scheme || "",
23
22
  network: req.network || "",
package/src/index.js CHANGED
@@ -1,9 +1,15 @@
1
1
  const { DhaliChannelManager } = require("./dhali/DhaliChannelManager");
2
- const { DhaliXrplChannelManager, ChannelNotFound } = require("./dhali/DhaliXrplChannelManager");
2
+ const { DhaliXrplChannelManager } = require("./dhali/DhaliXrplChannelManager");
3
3
  const { DhaliEthChannelManager } = require("./dhali/DhaliEthChannelManager");
4
+ const { DhaliAssetManager } = require("./dhali/DhaliAssetManager");
5
+ const { BaseAssetManager } = require("./dhali/BaseAssetManager");
6
+ const { DhaliXrplAssetManager } = require("./dhali/DhaliXrplAssetManager");
7
+ const { DhaliEthAssetManager } = require("./dhali/DhaliEthAssetManager");
8
+ const { WalletDescriptor } = require("./dhali/WalletDescriptor");
9
+ const { AssetUpdates } = require("./dhali/AssetUpdates");
4
10
  const Currency = require("./dhali/Currency");
5
- const { getAvailableDhaliCurrencies } = require("./dhali/configUtils");
6
- const { wrapAsX402PaymentPayload } = require("./dhali/utils");
11
+ const { fetchPublicConfig, retrieveChannelIdFromFirestoreRest, notifyAdminGateway, getAvailableDhaliCurrencies } = require("./dhali/configUtils");
12
+ const { wrapAsX402PaymentPayload, ChannelNotFound } = require("./dhali/utils");
7
13
 
8
14
  module.exports = {
9
15
  DhaliChannelManager,
@@ -12,5 +18,14 @@ module.exports = {
12
18
  ChannelNotFound,
13
19
  Currency,
14
20
  getAvailableDhaliCurrencies,
15
- wrapAsX402PaymentPayload
21
+ DhaliAssetManager,
22
+ BaseAssetManager,
23
+ DhaliXrplAssetManager,
24
+ DhaliEthAssetManager,
25
+ WalletDescriptor,
26
+ AssetUpdates,
27
+ fetchPublicConfig,
28
+ retrieveChannelIdFromFirestoreRest,
29
+ notifyAdminGateway,
30
+ wrapAsX402PaymentPayload,
16
31
  };
@@ -80,27 +80,13 @@ describe("DhaliChannelManager", () => {
80
80
  });
81
81
 
82
82
  test("throws ChannelNotFound if firestore returns null", async () => {
83
- const mockHttp = jest.fn();
84
- configUtils.retrieveChannelIdFromFirestoreRest.mockResolvedValue(null);
85
- manager = new DhaliXrplChannelManager(wallet, mockClient, currency, mockHttp);
83
+ manager._findChannel = jest.fn().mockRejectedValue(new ChannelNotFound("No open payment channel from ..."));
86
84
  await expect(manager.getAuthToken(100)).rejects.toThrow(ChannelNotFound);
87
85
  await expect(manager.getAuthToken(100)).rejects.toThrow(/No open payment channel from/);
88
- expect(configUtils.retrieveChannelIdFromFirestoreRest).toHaveBeenCalledWith(
89
- "XRPL.MAINNET",
90
- currency,
91
- wallet.classicAddress,
92
- mockHttp
93
- );
94
86
  });
95
87
 
96
88
  test("throws ChannelNotFound if firestore ID does not match on-chain channels", async () => {
97
- configUtils.retrieveChannelIdFromFirestoreRest.mockResolvedValue("FIRESTORE_ID");
98
- mockClient.request.mockResolvedValue({
99
- result: {
100
- channels: [{ channel_id: "XRPL_ID", amount: "1000" }],
101
- },
102
- });
103
-
89
+ manager._findChannel = jest.fn().mockRejectedValue(new ChannelNotFound("FIRESTORE_ID not found on-chain"));
104
90
  await expect(manager.getAuthToken(100)).rejects.toThrow(ChannelNotFound);
105
91
  await expect(manager.getAuthToken(100)).rejects.toThrow(/FIRESTORE_ID not found on-chain/);
106
92
  });
@@ -0,0 +1,338 @@
1
+ const { Wallet, Client } = require('xrpl');
2
+ const { createWalletClient, createPublicClient, http } = require('viem');
3
+ const { privateKeyToAccount } = require('viem/accounts');
4
+ const { sepolia } = require('viem/chains');
5
+ const WebSocket = require('ws');
6
+ const { DhaliAssetManager } = require('../src/dhali/DhaliAssetManager');
7
+ const { DhaliChannelManager } = require('../src/dhali/DhaliChannelManager');
8
+ const { WalletDescriptor } = require('../src/dhali/WalletDescriptor');
9
+ const Currency = require('../src/dhali/Currency');
10
+ const { AssetUpdates } = require('../src/dhali/AssetUpdates');
11
+ const { fetchPublicConfig } = require('../src/dhali/configUtils');
12
+ const { wrapAsX402PaymentPayload } = require('../src/dhali/utils');
13
+
14
+ // Secrets from environment variables
15
+ const XRPL_SECRET = process.env.XRPL_TESTNET_SECRET;
16
+ const SEPOLIA_SECRET = process.env.SEPOLIA_TESTNET_SECRET; // Should be 0x prefixed
17
+
18
+ function getFacilitatorUrl(publicConfig) {
19
+ const envUrl = process.env.DHALI_FACILITATOR_URL;
20
+ if (envUrl) {
21
+ return envUrl;
22
+ }
23
+ return publicConfig.ROOT_X402_FACILITATOR_URL || "https://x402.api.dhali.io";
24
+ }
25
+
26
+ describe('Dhali-js Comprehensive Integration Tests', () => {
27
+ let publicConfig;
28
+
29
+ beforeAll(async () => {
30
+ publicConfig = await fetchPublicConfig();
31
+ });
32
+
33
+ test('should perform comprehensive XRPL integration', async () => {
34
+ if (!XRPL_SECRET) {
35
+ console.warn('XRPL_TESTNET_SECRET not set, skipping test');
36
+ return;
37
+ }
38
+
39
+ // 1. Setup Wallet and Asset Manager
40
+ const wallet = Wallet.fromSeed(XRPL_SECRET);
41
+ const assetManager = DhaliAssetManager.xrpl(wallet);
42
+ const walletDescriptor = new WalletDescriptor(wallet.classicAddress, "XRPL.TESTNET");
43
+ const currency = new Currency("XRPL.TESTNET", "XRP", 6);
44
+
45
+ // 2. Create Asset
46
+ console.log(`\nCreating XRPL asset for wallet: ${wallet.classicAddress}`);
47
+ const createResult = await assetManager.createAsset(walletDescriptor, currency);
48
+ expect(createResult.schema).toBe('api_admin_gateway_create_successful');
49
+ const assetUuid = createResult.uuid;
50
+ console.log(`Asset created with UUID: ${assetUuid}`);
51
+
52
+ // 3. Update Asset
53
+ console.log("Updating XRPL asset...");
54
+ const updates = new AssetUpdates({
55
+ name: "Comprehensive Integration Test Asset XRPL",
56
+ earning_rate: 100,
57
+ earning_type: "per_request"
58
+ });
59
+ const updateResult = await assetManager.updateAsset(assetUuid, walletDescriptor, updates);
60
+ expect(updateResult.schema).toBe('api_admin_gateway_update_response');
61
+ console.log("Asset updated successfully");
62
+
63
+ // 4. Create Channel (Deposit)
64
+ const client = new Client("wss://s.altnet.rippletest.net:51233");
65
+ await client.connect();
66
+ const channelManager = DhaliChannelManager.xrpl(wallet, client, currency);
67
+ console.log("Performing XRPL deposit...");
68
+ const amountDrops = 1000000; // 1 XRP
69
+ const depositResult = await channelManager.deposit(amountDrops);
70
+ expect(depositResult).toBeDefined();
71
+ console.log("XRPL Deposit successful");
72
+
73
+ // 5. Generate Auth Token
74
+ console.log("Generating XRPL auth token...");
75
+ const authToken = await channelManager.getAuthToken();
76
+ expect(authToken).toBeDefined();
77
+ console.log(`XRPL Auth Token generated: ${authToken.substring(0, 20)}...`);
78
+
79
+ // 6. Settle via Facilitator using the newly created asset
80
+ console.log(`Settling via facilitator using asset ${assetUuid}...`);
81
+ const facilitatorUrl = getFacilitatorUrl(publicConfig);
82
+ const settleUrl = `${facilitatorUrl}/v2/${assetUuid}/settle`;
83
+
84
+ // Use the wrap function as suggested by the user
85
+ const requirements = {
86
+ scheme: "dhali",
87
+ network: "xrpl:1",
88
+ asset: "xrpl:1/native:xrp",
89
+ amount: "100",
90
+ payTo: assetUuid,
91
+ maxTimeoutSeconds: 60
92
+ };
93
+ const requirementsBase64 = Buffer.from(JSON.stringify(requirements)).toString('base64');
94
+ const wrappedBase64 = wrapAsX402PaymentPayload(authToken, requirementsBase64);
95
+ const wrappedPayload = JSON.parse(Buffer.from(wrappedBase64, 'base64').toString());
96
+
97
+ const settlePayload = {
98
+ paymentRequirements: requirements,
99
+ paymentPayload: wrappedPayload
100
+ };
101
+
102
+ const response = await fetch(settleUrl, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify(settlePayload)
106
+ });
107
+ expect(response.status).toBe(200);
108
+ const settleResult = await response.json();
109
+ if (!settleResult.success) { console.error("XRPL Facilitator settlement failed:", JSON.stringify(settleResult, null, 2)); }
110
+ expect(settleResult.success).toBe(true);
111
+ console.log("Facilitator settlement successful");
112
+
113
+ // 7. Close Channel via WebSockets
114
+ console.log("Closing channel via Admin Gateway...");
115
+ let wsUrl = publicConfig.ROOT_API_ADMIN_URL;
116
+ wsUrl = wsUrl.replace(/^http/, 'ws') + '/ws/close-channel';
117
+
118
+ await new Promise((resolve, reject) => {
119
+ const ws = new WebSocket(wsUrl);
120
+
121
+ ws.on('open', () => {
122
+ ws.send(JSON.stringify({
123
+ schema: "api_admin_gateway_closure_request",
124
+ schema_version: "1.0",
125
+ wallet: {
126
+ type: "Dhali-js",
127
+ address: wallet.classicAddress,
128
+ protocol: "XRPL.TESTNET",
129
+ publicKey: wallet.publicKey,
130
+ currency: {
131
+ code: "XRP",
132
+ scale: 6,
133
+ issuer: null
134
+ }
135
+ },
136
+ protocol: "XRPL.TESTNET",
137
+ currency: "XRP",
138
+ issuer: null
139
+ }));
140
+ });
141
+
142
+ ws.on('message', async (data) => {
143
+ const msg = JSON.parse(data);
144
+ console.log("WebSocket message received:", JSON.stringify(msg, null, 2));
145
+
146
+ if (msg.schema === "api_admin_gateway_message_to_be_signed") {
147
+ const rippleKeypairs = require('ripple-keypairs');
148
+ const signature = rippleKeypairs.sign(Buffer.from(JSON.stringify(msg.message, null, 0), 'utf8').toString('hex'), wallet.privateKey);
149
+
150
+ ws.send(JSON.stringify({
151
+ schema: "api_admin_gateway_signed_message_response",
152
+ schema_version: "1.1",
153
+ signature: signature,
154
+ public_key: wallet.publicKey
155
+ }));
156
+ } else if (msg.schema === "api_admin_gateway_authentication_successful") {
157
+ // Wait
158
+ } else if (msg.success) {
159
+ console.log('Channel closure initiated:', msg.message);
160
+ ws.close();
161
+ } else if (msg.error) {
162
+ reject(new Error(msg.error));
163
+ ws.close();
164
+ }
165
+ });
166
+
167
+ ws.on('error', (err) => {
168
+ console.error("WebSocket error (XRPL):", err);
169
+ reject(err);
170
+ });
171
+ ws.on('close', (code, reason) => {
172
+ console.log(`WebSocket closed (XRPL): ${code} ${reason}`);
173
+ resolve();
174
+ });
175
+ });
176
+
177
+ await client.disconnect();
178
+ }, 300000);
179
+
180
+ test('should perform comprehensive EVM integration', async () => {
181
+ if (!SEPOLIA_SECRET) {
182
+ console.warn('SEPOLIA_TESTNET_SECRET not set, skipping test');
183
+ return;
184
+ }
185
+
186
+ // 1. Setup Wallet and Asset Manager
187
+ const account = privateKeyToAccount(SEPOLIA_SECRET);
188
+ const assetManager = DhaliAssetManager.evm(createWalletClient({
189
+ account,
190
+ chain: sepolia,
191
+ transport: http()
192
+ }));
193
+ const walletDescriptor = new WalletDescriptor(account.address, "SEPOLIA");
194
+ const currency = new Currency("SEPOLIA", "ETH", 18);
195
+
196
+ // 2. Create Asset
197
+ console.log(`\nCreating EVM asset for wallet: ${account.address}`);
198
+ const createResult = await assetManager.createAsset(walletDescriptor, currency);
199
+ expect(createResult.schema).toBe('api_admin_gateway_create_successful');
200
+ const assetUuid = createResult.uuid;
201
+ console.log(`EVM Asset created with UUID: ${assetUuid}`);
202
+
203
+ // 3. Update Asset
204
+ console.log("Updating EVM asset...");
205
+ const updates = new AssetUpdates({
206
+ name: "Comprehensive Integration Test Asset EVM",
207
+ earning_rate: 0.001,
208
+ earning_type: "per_request"
209
+ });
210
+ const updateResult = await assetManager.updateAsset(assetUuid, walletDescriptor, updates);
211
+ expect(updateResult.schema).toBe('api_admin_gateway_update_response');
212
+ console.log("EVM Asset updated successfully");
213
+
214
+ // 4. Create Channel (Deposit)
215
+ const walletClient = createWalletClient({
216
+ account,
217
+ chain: sepolia,
218
+ transport: http()
219
+ });
220
+ const publicClient = createPublicClient({
221
+ chain: sepolia,
222
+ transport: http()
223
+ });
224
+ const channelManager = DhaliChannelManager.evm(walletClient, publicClient, currency);
225
+ console.log("Performing EVM deposit...");
226
+ const amountWei = 100000000000000n; // 0.0001 ETH
227
+ const receipt = await channelManager.deposit(amountWei.toString());
228
+ expect(receipt.status).toBe('success');
229
+ console.log("EVM Deposit successful");
230
+
231
+ // 5. Generate Auth Token
232
+ console.log("Generating EVM auth token...");
233
+ const authToken = await channelManager.getAuthToken();
234
+ expect(authToken).toBeDefined();
235
+ console.log(`EVM Auth Token generated: ${authToken.substring(0, 20)}...`);
236
+
237
+ // 6. Settle via Facilitator using the newly created asset
238
+ console.log(`Settling via facilitator using asset ${assetUuid}...`);
239
+ const facilitatorUrl = getFacilitatorUrl(publicConfig);
240
+ const settleUrl = `${facilitatorUrl}/v2/${assetUuid}/settle`;
241
+
242
+ // Use the wrap function as suggested by the user
243
+ const requirements = {
244
+ scheme: "dhali",
245
+ network: "eip155:11155111",
246
+ asset: "eip155:11155111/native:eth",
247
+ amount: "100",
248
+ payTo: assetUuid,
249
+ maxTimeoutSeconds: 60
250
+ };
251
+ const requirementsBase64 = Buffer.from(JSON.stringify(requirements)).toString('base64');
252
+ const wrappedBase64 = wrapAsX402PaymentPayload(authToken, requirementsBase64);
253
+ const wrappedPayload = JSON.parse(Buffer.from(wrappedBase64, 'base64').toString());
254
+
255
+ const settlePayload = {
256
+ paymentRequirements: requirements,
257
+ paymentPayload: wrappedPayload
258
+ };
259
+
260
+ const response = await fetch(settleUrl, {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify(settlePayload)
264
+ });
265
+ expect(response.status).toBe(200);
266
+ const settleResult = await response.json();
267
+ if (!settleResult.success) { console.error("EVM Facilitator settlement failed:", JSON.stringify(settleResult, null, 2)); }
268
+ expect(settleResult.success).toBe(true);
269
+ console.log("Facilitator settlement successful");
270
+
271
+ // 7. Close Channel via WebSockets
272
+ console.log("Closing channel via Admin Gateway...");
273
+ let wsUrl = publicConfig.ROOT_API_ADMIN_URL;
274
+ wsUrl = wsUrl.replace(/^http/, 'ws') + '/ws/close-channel';
275
+
276
+ await new Promise((resolve, reject) => {
277
+ const ws = new WebSocket(wsUrl);
278
+
279
+ ws.on('open', () => {
280
+ ws.send(JSON.stringify({
281
+ schema: "api_admin_gateway_closure_request",
282
+ schema_version: "1.0",
283
+ wallet: {
284
+ type: "Dhali-js",
285
+ address: account.address,
286
+ protocol: "SEPOLIA",
287
+ publicKey: null,
288
+ currency: {
289
+ code: "ETH",
290
+ scale: 18,
291
+ issuer: null
292
+ }
293
+ },
294
+ protocol: "SEPOLIA",
295
+ currency: "ETH",
296
+ issuer: null
297
+ }));
298
+ });
299
+
300
+ ws.on('message', async (data) => {
301
+ const msg = JSON.parse(data);
302
+ console.log("WebSocket message received:", JSON.stringify(msg, null, 2));
303
+
304
+ if (msg.schema === "api_admin_gateway_message_to_be_signed") {
305
+ console.log("Signing challenge message (EVM)...");
306
+ const signature = await walletClient.signTypedData({
307
+ domain: msg.message.domain,
308
+ types: msg.message.types,
309
+ primaryType: msg.message.primaryType,
310
+ message: msg.message.message
311
+ });
312
+ ws.send(JSON.stringify({
313
+ schema: "api_admin_gateway_signed_message_response",
314
+ schema_version: "1.1",
315
+ signature: signature
316
+ }));
317
+ } else if (msg.schema === "api_admin_gateway_authentication_successful") {
318
+ // Wait
319
+ } else if (msg.success) {
320
+ console.log('Channel closure initiated:', msg.message);
321
+ ws.close();
322
+ } else if (msg.error) {
323
+ reject(new Error(msg.error));
324
+ ws.close();
325
+ }
326
+ });
327
+
328
+ ws.on('error', (err) => {
329
+ console.error("WebSocket error:", err);
330
+ reject(err);
331
+ });
332
+ ws.on('close', (code, reason) => {
333
+ console.log(`WebSocket closed: ${code} ${reason}`);
334
+ resolve();
335
+ });
336
+ });
337
+ }, 300000);
338
+ });