dhali-js 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yaml +28 -0
- package/.github/workflows/tests.yaml +29 -0
- package/.prettierignore +2 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/package.json +26 -0
- package/src/dhali/DhaliChannelManager.js +110 -0
- package/src/dhali/createSignedClaim.js +66 -0
- package/src/index.js +15 -0
- package/tests/DhaliChannelManager.test.js +220 -0
- package/tests/createSignedClaim.test.js +57 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
# Trigger on any semver tag push (e.g. v1.2.3)
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- 'v*.*.*'
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
publish:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout repository
|
|
14
|
+
uses: actions/checkout@v3
|
|
15
|
+
|
|
16
|
+
- name: Set up Node.js
|
|
17
|
+
uses: actions/setup-node@v3
|
|
18
|
+
with:
|
|
19
|
+
node-version: 18.x
|
|
20
|
+
registry-url: https://registry.npmjs.org
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: npm ci
|
|
24
|
+
|
|
25
|
+
- name: Publish to npm
|
|
26
|
+
run: npm publish --access public
|
|
27
|
+
env:
|
|
28
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
node-version: [22.x]
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout repository
|
|
17
|
+
uses: actions/checkout@v3
|
|
18
|
+
|
|
19
|
+
- name: Use Node.js ${{ matrix.node-version }}
|
|
20
|
+
uses: actions/setup-node@v3
|
|
21
|
+
with:
|
|
22
|
+
node-version: ${{ matrix.node-version }}
|
|
23
|
+
cache: 'npm'
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: npm ci
|
|
27
|
+
|
|
28
|
+
- name: Run tests
|
|
29
|
+
run: npm test
|
package/.prettierignore
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dhali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# dhali-js
|
|
2
|
+
|
|
3
|
+
A JavaScript library for managing XRPL payment channels and generating auth tokens for use with [Dhali](https://dhali.io) APIs.
|
|
4
|
+
Leverages [xrpl.js](https://github.com/XRPLF/xrpl.js) and **only ever performs local signing**—your private key never leaves your environment.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install dhali-js
|
|
12
|
+
````
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { Wallet, Client } from 'xrpl'
|
|
20
|
+
import {
|
|
21
|
+
ChannelNotFound,
|
|
22
|
+
DhaliChannelManager
|
|
23
|
+
} from 'dhali-js'
|
|
24
|
+
|
|
25
|
+
// 1. Load your wallet from secret
|
|
26
|
+
const seed = 'sXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
|
|
27
|
+
const wallet = Wallet.fromSeed(seed)
|
|
28
|
+
|
|
29
|
+
// 2. Create the manager (connects automatically under the hood)
|
|
30
|
+
const dhaliManager = new DhaliChannelManager(wallet)
|
|
31
|
+
|
|
32
|
+
async function getPaymentClaim() {
|
|
33
|
+
try {
|
|
34
|
+
// Get an auth token
|
|
35
|
+
return await dhaliManager.getAuthToken()
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err instanceof ChannelNotFound) {
|
|
38
|
+
// If no channel exists, create one with 1 XRP (1 000 000 drops)
|
|
39
|
+
await dhaliManager.deposit(1_000_000)
|
|
40
|
+
return await dhaliManager.getAuthToken()
|
|
41
|
+
}
|
|
42
|
+
throw err
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
;(async () => {
|
|
47
|
+
for (let i = 0; i < 2; i++) {
|
|
48
|
+
const token = await getPaymentClaim()
|
|
49
|
+
const url = `https://xrplcluster.dhali.io?payment-claim=${token}`
|
|
50
|
+
// ... use token in your fetch/post to Dhali API ...
|
|
51
|
+
}
|
|
52
|
+
})()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
### `new DhaliChannelManager(wallet: xrpl.Wallet)`
|
|
60
|
+
|
|
61
|
+
* **wallet**: an `xrpl.js` `Wallet` instance (e.g. `Wallet.fromSeed`).
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
### `async deposit(amountDrops: number) → Promise<object>`
|
|
66
|
+
|
|
67
|
+
* **amountDrops**: Number of XRP drops (e.g. `1_000_000` = 1 XRP).
|
|
68
|
+
* **Returns**: The JSON result of the `PaymentChannelCreate` or `PaymentChannelFund` transaction.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### `async getAuthToken(amountDrops?: number) → Promise<string>`
|
|
73
|
+
|
|
74
|
+
* **amountDrops** (optional): How many drops to authorize; defaults to full channel balance.
|
|
75
|
+
* **Returns**: A base64-encoded JSON string containing your signed claim.
|
|
76
|
+
* **Throws**:
|
|
77
|
+
|
|
78
|
+
* `ChannelNotFound` if there is no open channel.
|
|
79
|
+
* `Error` if `amountDrops` exceeds channel capacity.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
* **ChannelNotFound**
|
|
86
|
+
Thrown when `getAuthToken` finds no channel from your wallet to Dhali’s receiver.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Security
|
|
91
|
+
|
|
92
|
+
All XRPL interactions and claim-signatures are done locally via `xrpl.js` + `ripple-keypairs`.
|
|
93
|
+
Your private key never leaves your machine.
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dhali-js",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A JavaScript library for managing XRPL payment channels and generating auth tokens for Dhali APIs",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "jest",
|
|
9
|
+
"format": "prettier --write \"src/**/*.js\" \"tests/**/*.js\""
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"xrpl",
|
|
13
|
+
"payment-channel",
|
|
14
|
+
"dhali",
|
|
15
|
+
"xrpl.js"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"ripple-keypairs": "^2.0.0",
|
|
20
|
+
"xrpl": "^4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"jest": "^29.0.0",
|
|
24
|
+
"prettier": "^3.5.3"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const { Client } = require("xrpl");
|
|
2
|
+
const { buildPaychanAuthHexStringToBeSigned } = require("./createSignedClaim");
|
|
3
|
+
const { sign: signClaim } = require("ripple-keypairs");
|
|
4
|
+
|
|
5
|
+
class ChannelNotFound extends Error {}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A management tool for generating payment claims for use with Dhali APIs.
|
|
9
|
+
*/
|
|
10
|
+
class DhaliChannelManager {
|
|
11
|
+
/**
|
|
12
|
+
* @param {xrpl.Wallet} wallet
|
|
13
|
+
*/
|
|
14
|
+
constructor(wallet) {
|
|
15
|
+
this.client = new Client("wss://s1.ripple.com:51234/");
|
|
16
|
+
this.ready = this.client.connect();
|
|
17
|
+
this.wallet = wallet;
|
|
18
|
+
this.destination = "rLggTEwmTe3eJgyQbCSk4wQazow2TeKrtR";
|
|
19
|
+
this.protocol = "XRPL.MAINNET";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async _findChannel() {
|
|
23
|
+
await this.ready;
|
|
24
|
+
const resp = await this.client.request({
|
|
25
|
+
command: "account_channels",
|
|
26
|
+
account: this.wallet.classicAddress,
|
|
27
|
+
destination_account: this.destination,
|
|
28
|
+
ledger_index: "validated",
|
|
29
|
+
});
|
|
30
|
+
const channels = resp.result.channels || [];
|
|
31
|
+
if (channels.length === 0) {
|
|
32
|
+
throw new ChannelNotFound(
|
|
33
|
+
`No open payment channel from ${this.wallet.classicAddress} to ${this.destination}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return channels[0];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create or fund a payment channel.
|
|
41
|
+
* @param {number} amountDrops
|
|
42
|
+
* @returns {Promise<object>}
|
|
43
|
+
*/
|
|
44
|
+
async deposit(amountDrops) {
|
|
45
|
+
await this.ready;
|
|
46
|
+
let tx;
|
|
47
|
+
try {
|
|
48
|
+
const ch = await this._findChannel();
|
|
49
|
+
tx = {
|
|
50
|
+
TransactionType: "PaymentChannelFund",
|
|
51
|
+
Account: this.wallet.classicAddress,
|
|
52
|
+
Channel: ch.channel_id,
|
|
53
|
+
Amount: amountDrops.toString(),
|
|
54
|
+
};
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (!(err instanceof ChannelNotFound)) throw err;
|
|
57
|
+
tx = {
|
|
58
|
+
TransactionType: "PaymentChannelCreate",
|
|
59
|
+
Account: this.wallet.classicAddress,
|
|
60
|
+
Destination: this.destination,
|
|
61
|
+
Amount: amountDrops.toString(),
|
|
62
|
+
SettleDelay: 86400 * 14,
|
|
63
|
+
PublicKey: this.wallet.publicKey,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// autofill sequence, fee, etc.
|
|
67
|
+
const prepared = await this.client.autofill(tx);
|
|
68
|
+
// sign
|
|
69
|
+
const signed = this.wallet.sign(prepared);
|
|
70
|
+
const txBlob = signed.tx_blob || signed.signedTransaction;
|
|
71
|
+
// submit & wait
|
|
72
|
+
const result = await this.client.submitAndWait(txBlob);
|
|
73
|
+
return result.result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generate a base64-encoded payment claim.
|
|
78
|
+
* @param {number=} amountDrops
|
|
79
|
+
* @returns {Promise<string>}
|
|
80
|
+
*/
|
|
81
|
+
async getAuthToken(amountDrops) {
|
|
82
|
+
await this.ready;
|
|
83
|
+
const ch = await this._findChannel();
|
|
84
|
+
const total = BigInt(ch.amount);
|
|
85
|
+
const allowed = amountDrops != null ? BigInt(amountDrops) : total;
|
|
86
|
+
if (allowed > total) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Requested auth ${allowed} exceeds channel capacity ${total}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const claimHex = buildPaychanAuthHexStringToBeSigned(
|
|
92
|
+
ch.channel_id,
|
|
93
|
+
allowed.toString(),
|
|
94
|
+
);
|
|
95
|
+
const signature = signClaim(claimHex, this.wallet.privateKey);
|
|
96
|
+
const claim = {
|
|
97
|
+
version: "2",
|
|
98
|
+
account: this.wallet.classicAddress,
|
|
99
|
+
protocol: this.protocol,
|
|
100
|
+
currency: { code: "XRP", scale: 6 },
|
|
101
|
+
destination_account: this.destination,
|
|
102
|
+
authorized_to_claim: allowed.toString(),
|
|
103
|
+
channel_id: ch.channel_id,
|
|
104
|
+
signature,
|
|
105
|
+
};
|
|
106
|
+
return Buffer.from(JSON.stringify(claim)).toString("base64");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { DhaliChannelManager, ChannelNotFound };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const HASH_PREFIX_PAYMENT_CHANNEL_CLAIM = 0x434c4d00;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @private
|
|
5
|
+
*/
|
|
6
|
+
function _serializePaychanAuthorization(channelIdBytes, dropsBigInt) {
|
|
7
|
+
if (!Buffer.isBuffer(channelIdBytes) || channelIdBytes.length !== 32) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`Invalid channelId length ${channelIdBytes.length}; must be 32 bytes.`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
// 1) 4-byte prefix
|
|
13
|
+
const prefix = Buffer.alloc(4);
|
|
14
|
+
prefix.writeUInt32BE(HASH_PREFIX_PAYMENT_CHANNEL_CLAIM, 0);
|
|
15
|
+
|
|
16
|
+
// 2) channelIdBytes (32 bytes)
|
|
17
|
+
// 3) split drops into two 4-byte words
|
|
18
|
+
const highBig = dropsBigInt >> 32n;
|
|
19
|
+
const lowBig = dropsBigInt & 0xffffffffn;
|
|
20
|
+
const high = Number(highBig);
|
|
21
|
+
const low = Number(lowBig);
|
|
22
|
+
const amountBuf = Buffer.alloc(8);
|
|
23
|
+
amountBuf.writeUInt32BE(high, 0);
|
|
24
|
+
amountBuf.writeUInt32BE(low, 4);
|
|
25
|
+
|
|
26
|
+
return Buffer.concat([prefix, channelIdBytes, amountBuf]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the hex-string that you pass to an XRPL signer to sign a payment-channel claim.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} channelIdHex 64-char hex string
|
|
33
|
+
* @param {string} amountStr integer string of drops
|
|
34
|
+
* @returns {string} uppercase hex
|
|
35
|
+
*/
|
|
36
|
+
function buildPaychanAuthHexStringToBeSigned(channelIdHex, amountStr) {
|
|
37
|
+
let channelIdBytes;
|
|
38
|
+
|
|
39
|
+
channelIdBytes = Buffer.from(channelIdHex, "hex");
|
|
40
|
+
|
|
41
|
+
if (channelIdBytes.length !== 32) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Invalid channelId length ${channelIdBytes.length}; must be 32 bytes.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let dropsBig;
|
|
48
|
+
try {
|
|
49
|
+
dropsBig = BigInt(amountStr);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error("Invalid amount format.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (dropsBig < 0n) {
|
|
55
|
+
throw new Error("Amount cannot be negative.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const msg = _serializePaychanAuthorization(channelIdBytes, dropsBig);
|
|
59
|
+
return msg.toString("hex").toUpperCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
buildPaychanAuthHexStringToBeSigned,
|
|
64
|
+
// exposed for testing
|
|
65
|
+
_serializePaychanAuthorization,
|
|
66
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const {
|
|
2
|
+
buildPaychanAuthHexStringToBeSigned,
|
|
3
|
+
_serializePaychanAuthorization,
|
|
4
|
+
} = require("./dhali/createSignedClaim");
|
|
5
|
+
const {
|
|
6
|
+
DhaliChannelManager,
|
|
7
|
+
ChannelNotFound,
|
|
8
|
+
} = require("./dhali/DhaliChannelManager");
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
buildPaychanAuthHexStringToBeSigned,
|
|
12
|
+
_serializePaychanAuthorization,
|
|
13
|
+
DhaliChannelManager,
|
|
14
|
+
ChannelNotFound,
|
|
15
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// tests/DhaliChannelManager.test.js
|
|
2
|
+
|
|
3
|
+
// 1) MOCK xrpl.js BEFORE importing any code that instantiates Client
|
|
4
|
+
jest.mock("xrpl", () => {
|
|
5
|
+
// Return an object with the Client class stubbed
|
|
6
|
+
return {
|
|
7
|
+
Client: jest.fn().mockImplementation(() => {
|
|
8
|
+
return {
|
|
9
|
+
// connect() returns a resolved Promise so await this.ready never hangs
|
|
10
|
+
connect: () => Promise.resolve(),
|
|
11
|
+
// stubbed methods your code calls:
|
|
12
|
+
request: jest.fn(),
|
|
13
|
+
autofill: jest.fn(),
|
|
14
|
+
submitAndWait: jest.fn(),
|
|
15
|
+
disconnect: jest.fn(),
|
|
16
|
+
// preserve .url for the constructor test
|
|
17
|
+
url: "wss://s1.ripple.com:51234/",
|
|
18
|
+
};
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// 2) MOCK ripple-keypairs BEFORE importing DhaliChannelManager
|
|
24
|
+
jest.mock("ripple-keypairs", () => ({
|
|
25
|
+
// We'll override this mock's behavior inside individual tests
|
|
26
|
+
sign: jest.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock("../src/dhali/createSignedClaim", () => ({
|
|
30
|
+
buildPaychanAuthHexStringToBeSigned: jest.fn(),
|
|
31
|
+
// If you need to expose _serializePaychanAuthorization, add it here too:
|
|
32
|
+
// _serializePaychanAuthorization: jest.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// 3) Now import everything under test
|
|
36
|
+
const createSignedClaim = require("../src/dhali/createSignedClaim");
|
|
37
|
+
const { sign: mockRippleSign } = require("ripple-keypairs");
|
|
38
|
+
const {
|
|
39
|
+
DhaliChannelManager,
|
|
40
|
+
ChannelNotFound,
|
|
41
|
+
} = require("../src/dhali/DhaliChannelManager");
|
|
42
|
+
|
|
43
|
+
describe("DhaliChannelManager", () => {
|
|
44
|
+
const CHANNEL_ID = "AB".repeat(32);
|
|
45
|
+
let wallet;
|
|
46
|
+
let manager;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
// 4) Create a minimal fake wallet
|
|
50
|
+
wallet = {
|
|
51
|
+
classicAddress: "rTESTADDRESS",
|
|
52
|
+
publicKey: "PUBKEY",
|
|
53
|
+
privateKey: "PRIVKEY",
|
|
54
|
+
// sign() is used for transaction signing
|
|
55
|
+
sign: jest.fn().mockReturnValue({ signedTransaction: "TX_BLOB" }),
|
|
56
|
+
};
|
|
57
|
+
// 5) Instantiate the manager. client.connect() is already stubbed.
|
|
58
|
+
manager = new DhaliChannelManager(wallet);
|
|
59
|
+
// 6) Short-circuit any `await this.ready` if your constructor awaits client.connect()
|
|
60
|
+
manager.ready = Promise.resolve();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
// 7) Restore any spies on createSignedClaim so tests remain isolated
|
|
65
|
+
if (createSignedClaim.buildPaychanAuthHexStringToBeSigned.mockRestore) {
|
|
66
|
+
createSignedClaim.buildPaychanAuthHexStringToBeSigned.mockRestore();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("constructor sets defaults", () => {
|
|
71
|
+
expect(manager.wallet).toBe(wallet);
|
|
72
|
+
expect(manager.protocol).toBe("XRPL.MAINNET");
|
|
73
|
+
expect(manager.destination).toBe("rLggTEwmTe3eJgyQbCSk4wQazow2TeKrtR");
|
|
74
|
+
// client.url comes from our mock above
|
|
75
|
+
expect(manager.client.url).toBe("wss://s1.ripple.com:51234/");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("_findChannel", () => {
|
|
79
|
+
test("returns first channel when present", async () => {
|
|
80
|
+
const fakeChannel = { channel_id: "CHAN123", amount: "1000" };
|
|
81
|
+
manager.client.request.mockResolvedValue({
|
|
82
|
+
result: { channels: [fakeChannel] },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const ch = await manager._findChannel();
|
|
86
|
+
expect(ch).toBe(fakeChannel);
|
|
87
|
+
// verify the exact request payload
|
|
88
|
+
expect(manager.client.request).toHaveBeenCalledWith({
|
|
89
|
+
command: "account_channels",
|
|
90
|
+
account: wallet.classicAddress,
|
|
91
|
+
destination_account: manager.destination,
|
|
92
|
+
ledger_index: "validated",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("throws ChannelNotFound when none", async () => {
|
|
97
|
+
manager.client.request.mockResolvedValue({ result: { channels: [] } });
|
|
98
|
+
|
|
99
|
+
await expect(manager._findChannel()).rejects.toThrow(ChannelNotFound);
|
|
100
|
+
await expect(manager._findChannel()).rejects.toThrow(
|
|
101
|
+
wallet.classicAddress,
|
|
102
|
+
);
|
|
103
|
+
await expect(manager._findChannel()).rejects.toThrow(manager.destination);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("deposit", () => {
|
|
108
|
+
test("funds existing channel", async () => {
|
|
109
|
+
// _findChannel resolves => fund path
|
|
110
|
+
const fakeChannel = { channel_id: "CHANID", amount: "500" };
|
|
111
|
+
manager._findChannel = jest.fn().mockResolvedValue(fakeChannel);
|
|
112
|
+
|
|
113
|
+
// stub autofill + submitAndWait from our Client mock
|
|
114
|
+
manager.client.autofill.mockResolvedValue({ foo: "bar" });
|
|
115
|
+
manager.client.submitAndWait.mockResolvedValue({
|
|
116
|
+
result: { status: "funded" },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const res = await manager.deposit(100);
|
|
120
|
+
expect(res).toEqual({ status: "funded" });
|
|
121
|
+
|
|
122
|
+
// ensure autofill got the correct PaymentChannelFund payload
|
|
123
|
+
expect(manager.client.autofill).toHaveBeenCalledWith({
|
|
124
|
+
TransactionType: "PaymentChannelFund",
|
|
125
|
+
Account: wallet.classicAddress,
|
|
126
|
+
Channel: fakeChannel.channel_id,
|
|
127
|
+
Amount: "100",
|
|
128
|
+
});
|
|
129
|
+
// ensure we submitted the signed blob returned by wallet.sign()
|
|
130
|
+
expect(manager.client.submitAndWait).toHaveBeenCalledWith("TX_BLOB");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("creates channel if none exists", async () => {
|
|
134
|
+
// _findChannel rejects with ChannelNotFound => create path
|
|
135
|
+
manager._findChannel = jest
|
|
136
|
+
.fn()
|
|
137
|
+
.mockRejectedValue(new ChannelNotFound("nope"));
|
|
138
|
+
|
|
139
|
+
manager.client.autofill.mockResolvedValue({ baz: "qux" });
|
|
140
|
+
manager.client.submitAndWait.mockResolvedValue({
|
|
141
|
+
result: { status: "created" },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const res = await manager.deposit(200);
|
|
145
|
+
expect(res).toEqual({ status: "created" });
|
|
146
|
+
|
|
147
|
+
expect(manager.client.autofill).toHaveBeenCalledWith({
|
|
148
|
+
TransactionType: "PaymentChannelCreate",
|
|
149
|
+
Account: wallet.classicAddress,
|
|
150
|
+
Destination: manager.destination,
|
|
151
|
+
Amount: "200",
|
|
152
|
+
SettleDelay: 86400 * 14,
|
|
153
|
+
PublicKey: wallet.publicKey,
|
|
154
|
+
});
|
|
155
|
+
expect(manager.client.submitAndWait).toHaveBeenCalledWith("TX_BLOB");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("getAuthToken", () => {
|
|
160
|
+
test("success with default amount", async () => {
|
|
161
|
+
// stub channel lookup
|
|
162
|
+
manager._findChannel = jest.fn().mockResolvedValue({
|
|
163
|
+
channel_id: CHANNEL_ID,
|
|
164
|
+
amount: "1001",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// 8) SPY on the hex-builder instead of reassigning it
|
|
168
|
+
const claimSpy = jest
|
|
169
|
+
.spyOn(createSignedClaim, "buildPaychanAuthHexStringToBeSigned")
|
|
170
|
+
.mockReturnValue("CLAIMHEX");
|
|
171
|
+
|
|
172
|
+
// stub signature
|
|
173
|
+
mockRippleSign.mockReturnValue("SIGVALUE");
|
|
174
|
+
|
|
175
|
+
const token = await manager.getAuthToken();
|
|
176
|
+
const decoded = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
|
177
|
+
|
|
178
|
+
expect(decoded).toMatchObject({
|
|
179
|
+
version: "2",
|
|
180
|
+
account: wallet.classicAddress,
|
|
181
|
+
protocol: manager.protocol,
|
|
182
|
+
currency: { code: "XRP", scale: 6 },
|
|
183
|
+
destination_account: manager.destination,
|
|
184
|
+
authorized_to_claim: "1001",
|
|
185
|
+
channel_id: CHANNEL_ID,
|
|
186
|
+
signature: "SIGVALUE",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ensure our spy was called with correct args
|
|
190
|
+
expect(claimSpy).toHaveBeenCalledWith(CHANNEL_ID, "1001");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("success with specific amount", async () => {
|
|
194
|
+
manager._findChannel = jest.fn().mockResolvedValue({
|
|
195
|
+
channel_id: CHANNEL_ID,
|
|
196
|
+
amount: "500",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const claimSpy = jest
|
|
200
|
+
.spyOn(createSignedClaim, "buildPaychanAuthHexStringToBeSigned")
|
|
201
|
+
.mockReturnValue("CLAIMHEX2");
|
|
202
|
+
mockRippleSign.mockReturnValue("SIG2");
|
|
203
|
+
|
|
204
|
+
const token = await manager.getAuthToken(200);
|
|
205
|
+
const decoded = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
|
206
|
+
expect(decoded.authorized_to_claim).toBe("200");
|
|
207
|
+
expect(claimSpy).toHaveBeenCalledWith(CHANNEL_ID, "200");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("throws if amount exceeds capacity", async () => {
|
|
211
|
+
manager._findChannel = jest.fn().mockResolvedValue({
|
|
212
|
+
channel_id: CHANNEL_ID,
|
|
213
|
+
amount: "100",
|
|
214
|
+
});
|
|
215
|
+
await expect(manager.getAuthToken(200)).rejects.toThrow(
|
|
216
|
+
/exceeds channel capacity/,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const {
|
|
2
|
+
_serializePaychanAuthorization,
|
|
3
|
+
buildPaychanAuthHexStringToBeSigned,
|
|
4
|
+
} = require("../src/dhali/createSignedClaim");
|
|
5
|
+
|
|
6
|
+
describe("_serializePaychanAuthorization", () => {
|
|
7
|
+
test("throws if channelIdBytes length is not 32", () => {
|
|
8
|
+
expect(() =>
|
|
9
|
+
_serializePaychanAuthorization(Buffer.alloc(31), 1000n),
|
|
10
|
+
).toThrow("Invalid channelId length 31; must be 32 bytes.");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("serializes correctly", () => {
|
|
14
|
+
// 32 bytes of 0xAA
|
|
15
|
+
const channelIdBytes = Buffer.alloc(32, 0xaa);
|
|
16
|
+
// a 64-bit drops value
|
|
17
|
+
const drops = 0x1122334455667788n;
|
|
18
|
+
|
|
19
|
+
const buf = _serializePaychanAuthorization(channelIdBytes, drops);
|
|
20
|
+
|
|
21
|
+
// prefix = 0x434C4D00
|
|
22
|
+
expect(buf.slice(0, 4).toString("hex")).toBe("434c4d00");
|
|
23
|
+
// next 32 bytes are the channelId
|
|
24
|
+
expect(buf.slice(4, 4 + 32)).toEqual(channelIdBytes);
|
|
25
|
+
// final 8 bytes = high + low words
|
|
26
|
+
expect(buf.slice(36).toString("hex")).toBe("1122334455667788");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("buildPaychanAuthHexStringToBeSigned", () => {
|
|
31
|
+
const zeroHex64 = "00".repeat(32);
|
|
32
|
+
|
|
33
|
+
test("negative amount throws", () => {
|
|
34
|
+
expect(() => buildPaychanAuthHexStringToBeSigned(zeroHex64, "-1")).toThrow(
|
|
35
|
+
"Amount cannot be negative.",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("non-numeric amount throws", () => {
|
|
40
|
+
expect(() => buildPaychanAuthHexStringToBeSigned(zeroHex64, "foo")).toThrow(
|
|
41
|
+
"Invalid amount format.",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("produces correct uppercase hex", () => {
|
|
46
|
+
const channelHex = "AA".repeat(32);
|
|
47
|
+
const amountStr = "123456";
|
|
48
|
+
const hexStr = buildPaychanAuthHexStringToBeSigned(channelHex, amountStr);
|
|
49
|
+
const expected = _serializePaychanAuthorization(
|
|
50
|
+
Buffer.from(channelHex, "hex"),
|
|
51
|
+
BigInt(amountStr),
|
|
52
|
+
)
|
|
53
|
+
.toString("hex")
|
|
54
|
+
.toUpperCase();
|
|
55
|
+
expect(hexStr).toBe(expected);
|
|
56
|
+
});
|
|
57
|
+
});
|