Skip to content

Commit fae2187

Browse files
feat(cli): XRPL subcommand group (#907)
* wip: add xrpl command group with token utilities for iou/mtp * wip: add xrpl init command for onboarding * wip: custody account commands * wip: refine xrpl custody-account setup commands - standardize creator-signed commands (reserve-tickets, init, set-signer-list) on --issuer-seed / ISSUER_SEED; holder ops (trust-set, authorize-mpt) keep --seed - set-manager: require --account, drop seed-derivation/--algorithm - fund: drop --seed; faucet funds --account directly via destination (integer XRP), add XRPL_FAUCET_HOSTS - set-signer-list: default --manager-set-index to "latest" (resolved on-chain via getCurrentManagerSetIndex); log the resolved index - update xrpl README to match * feat(cli): add xrpl subcommand (emitter, parse-vaa, relay) Co-Authored-By: Douglas Galico <douglas.galico@gmail.com> * wip: add register-peer and rotate-admin commands (#5) * remove broken links in readme * apply prettier * feat(cli): add set-token xrpl subcommand * feat(cli): xrpl transfer subcommand to trigger ntt transfers with relay Co-authored-by: Douglas Galico <douglas.galico@gmail.com> * update .gitignore * prettier apply * cli(xrpl): address review comments - infer default value for some arguments based on the network - remove outdated comments - reuse exported enum RequestType * cli(xrpl): reuse sdk executor utility functions * limit checks for u64Hex * apply prettier * fix: relay request type values * fix: executor default api values * xrpl: add support for generated messages with XRPL prefix * address review comments * fix: github action tag and version mismatch --------- Co-authored-by: Douglas Galico <douglas.galico@gmail.com>
1 parent 7bef47b commit fae2187

43 files changed

Lines changed: 5108 additions & 20 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/cli.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ jobs:
8989
id: cache
9090
# zizmor flags this because it sees docker/build-push-action below and assumes it publishes
9191
# artifacts (so cache poisoning could leak out), but the build here doesn't publish anything.
92-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 # zizmor: ignore[cache-poisoning]
92+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 # zizmor: ignore[cache-poisoning]
9393
with:
9494
path: /tmp/solana-artifacts/v${{ matrix.version }}
9595
key: solana-v${{ matrix.version }}-${{ hashFiles('cli/test/build-solana.sh', 'Dockerfile.cli', 'Dockerfile.anchor-base', 'solana/Anchor.toml') }}

.github/workflows/solana.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ jobs:
8585
8686
- name: Restore cached SBF artifacts
8787
id: cache-sbf
88-
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
88+
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
8989
with:
9090
path: solana/target/deploy
9191
key: solana-sbf-${{ steps.solana.outputs.version }}-${{ hashFiles('solana/Cargo.lock', 'solana/**/Cargo.toml', 'solana/programs/**/*.rs', 'solana/modules/**/*.rs') }}
@@ -114,7 +114,7 @@ jobs:
114114

115115
- name: Cache solana tools
116116
id: cache-solana
117-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
117+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
118118
env:
119119
cache-name: solana-tools
120120
with:
@@ -143,7 +143,7 @@ jobs:
143143
144144
- name: Save SBF artifacts to cache
145145
if: steps.verify-cache.outputs.valid != 'true'
146-
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
146+
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
147147
with:
148148
path: solana/target/deploy
149149
key: solana-sbf-${{ steps.solana.outputs.version }}-${{ hashFiles('solana/Cargo.lock', 'solana/**/Cargo.toml', 'solana/programs/**/*.rs', 'solana/modules/**/*.rs') }}
@@ -202,7 +202,7 @@ jobs:
202202
node-version: ${{ env.node-version }}
203203

204204
- name: Cache node_modules
205-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
205+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
206206
with:
207207
path: ./solana/node_modules/
208208
key: node-modules-${{ runner.os }}-build-${{ env.node-version }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ crytic-export/
3737
corpus/
3838
# Bun lockb (binary format, we use text format bun.lock instead)
3939
*.lockb
40+
41+
.deployments
42+
.ignore

bun.lock

Lines changed: 7 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
},
2222
"dependencies": {
2323
"@msgpack/msgpack": "^3.1.3",
24+
"binary-layout": "^1.0.0",
25+
"xrpl": "^4.6.0",
2426
"yargs": "18.0.0"
2527
},
2628
"overrides": {

cli/src/__tests__/cli-help.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const EXPECTED_COMMANDS = [
4444
"solana",
4545
"hype",
4646
"manual",
47+
"xrpl",
4748
];
4849

4950
const SOLANA_SUBCOMMANDS = [
@@ -54,6 +55,14 @@ const SOLANA_SUBCOMMANDS = [
5455
"build",
5556
];
5657

58+
const XRPL_SUBCOMMANDS = [
59+
"emitter",
60+
"parse-vaa",
61+
"relay",
62+
"register-peer",
63+
"rotate-admin",
64+
];
65+
5766
const CONFIG_SUBCOMMANDS = ["set-chain", "unset-chain", "get-chain"];
5867

5968
// ── Probe: can the CLI start? ───────────────────────────────────────────────
@@ -128,6 +137,22 @@ describe("CLI Help Output", () => {
128137
expect(failures).toEqual([]);
129138
});
130139

140+
it("xrpl subcommands are listed and their --help exits with 0", async () => {
141+
if (!cliAvailable) return;
142+
const { stdout: xrplHelp } = await runCli("xrpl", "--help");
143+
for (const sub of XRPL_SUBCOMMANDS) {
144+
expect(xrplHelp).toContain(sub);
145+
}
146+
const results = await Promise.all(
147+
XRPL_SUBCOMMANDS.map(async (sub) => {
148+
const { exitCode, stdout } = await runCli("xrpl", sub, "--help");
149+
return { sub, exitCode, hasOutput: stdout.length > 0 };
150+
})
151+
);
152+
const failures = results.filter((r) => r.exitCode !== 0 || !r.hasOutput);
153+
expect(failures).toEqual([]);
154+
});
155+
131156
it("manual and hype subcommands are listed", async () => {
132157
if (!cliAvailable) return;
133158
const { stdout: manualHelp } = await runCli("manual", "--help");
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect } from "bun:test";
2+
import {
3+
buildRegisterPeerPayload,
4+
buildRotateAdminPayload,
5+
} from "../xrpl/admin";
6+
import { parseAdmin } from "../xrpl/payloads";
7+
import { toChainId } from "@wormhole-foundation/sdk";
8+
9+
// The XADM encoders (admin.ts) and decoder (payloads.ts::parseAdmin) must agree.
10+
describe("XADM admin payloads round-trip through parseAdmin", () => {
11+
const manager = "rwooXFqJZc5cwuG3XJT5eWUgmdvKs2br4Q";
12+
13+
it("RegisterPeer (0x01)", () => {
14+
const peer =
15+
"bd9ea0c58c349b4c6768e005c0b794418c46c71a1953f59e089f181bf6d81457";
16+
const hex = buildRegisterPeerPayload({
17+
manager,
18+
peerChainId: toChainId("Solana"),
19+
peerAddress: peer,
20+
});
21+
const parsed = parseAdmin(Buffer.from(hex, "hex"));
22+
expect(parsed.actionName).toBe("RegisterPeer");
23+
expect(parsed.targetAccount).toBe(manager);
24+
expect(parsed.chainId).toBe(toChainId("Solana"));
25+
expect(parsed.peerAddress).toBe(peer);
26+
});
27+
28+
it("RegisterPeer accepts a 0x-prefixed peer address", () => {
29+
const peer =
30+
"bd9ea0c58c349b4c6768e005c0b794418c46c71a1953f59e089f181bf6d81457";
31+
const hex = buildRegisterPeerPayload({
32+
manager,
33+
peerChainId: 1,
34+
peerAddress: "0x" + peer,
35+
});
36+
const parsed = parseAdmin(Buffer.from(hex, "hex"));
37+
expect(parsed.peerAddress).toBe(peer);
38+
});
39+
40+
it("rejects a peer address that is not 32 bytes", () => {
41+
expect(() =>
42+
buildRegisterPeerPayload({ manager, peerChainId: 1, peerAddress: "abcd" })
43+
).toThrow();
44+
});
45+
46+
it("RotateAdmin (0x02)", () => {
47+
const newAdmin = "rnv8uG8r7mewUqTZ7us3KESFE4cAEqsjm1";
48+
const hex = buildRotateAdminPayload({ manager, newAdmin });
49+
const parsed = parseAdmin(Buffer.from(hex, "hex"));
50+
expect(parsed.actionName).toBe("RotateAdmin");
51+
expect(parsed.targetAccount).toBe(manager);
52+
expect(parsed.newAdmin).toBe(newAdmin);
53+
});
54+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { Wallet, decodeAccountID } from "xrpl";
3+
import { validateRAddress } from "../xrpl/helpers";
4+
import {
5+
deriveSignerEntries,
6+
parseManagerSet,
7+
signerEntriesFromAddresses,
8+
} from "../xrpl/manager-set";
9+
10+
describe("validateRAddress", () => {
11+
test("accepts a valid classic address", () => {
12+
expect(validateRAddress("r9qAVHiq4gNPFJTHduy7fWEgUPvre2VLpG")).toBe(
13+
"r9qAVHiq4gNPFJTHduy7fWEgUPvre2VLpG"
14+
);
15+
});
16+
17+
test("rejects an invalid address", () => {
18+
expect(() => validateRAddress("not-an-address")).toThrow(/Invalid XRPL/);
19+
});
20+
});
21+
22+
describe("parseManagerSet", () => {
23+
function pk(byte: number): Buffer {
24+
const b = Buffer.alloc(33, byte);
25+
b[0] = 0x02; // compressed-key prefix-ish (value irrelevant to parsing)
26+
return b;
27+
}
28+
29+
test("parses version, threshold, total and 33-byte pubkeys", () => {
30+
const header = Buffer.from([1, 2, 3]); // version 1, mThreshold 2, nTotal 3
31+
const body = Buffer.concat([pk(0xaa), pk(0xbb), pk(0xcc)]);
32+
const set = parseManagerSet(Buffer.concat([header, body]));
33+
expect(set.mThreshold).toBe(2);
34+
expect(set.nTotal).toBe(3);
35+
expect(set.pubkeys.length).toBe(3);
36+
expect(set.pubkeys[0].length).toBe(33);
37+
});
38+
39+
test("rejects a bad version", () => {
40+
expect(() => parseManagerSet(Buffer.from([0, 1, 0]))).toThrow(/version/);
41+
});
42+
43+
test("rejects truncated pubkey data", () => {
44+
// nTotal says 2 but only one 33-byte key follows
45+
const buf = Buffer.concat([Buffer.from([1, 1, 2]), Buffer.alloc(33)]);
46+
expect(() => parseManagerSet(buf)).toThrow(/expected/);
47+
});
48+
});
49+
50+
describe("signer entries", () => {
51+
test("deriveSignerEntries: pubkey -> account, weight 1, sorted", () => {
52+
const w1 = Wallet.generate();
53+
const w2 = Wallet.generate();
54+
const entries = deriveSignerEntries([
55+
Buffer.from(w1.publicKey, "hex"),
56+
Buffer.from(w2.publicKey, "hex"),
57+
]);
58+
// derived accounts match the wallets' classic addresses
59+
const accounts = entries.map((e) => e.SignerEntry.Account);
60+
expect(accounts).toContain(w1.classicAddress);
61+
expect(accounts).toContain(w2.classicAddress);
62+
expect(entries.every((e) => e.SignerEntry.SignerWeight === 1)).toBe(true);
63+
// sorted ascending by account ID
64+
const ids = accounts.map((a) => Buffer.from(decodeAccountID(a)));
65+
expect(Buffer.compare(ids[0], ids[1])).toBeLessThanOrEqual(0);
66+
});
67+
68+
test("signerEntriesFromAddresses: trims, weight 1, sorted", () => {
69+
const a = "r9qAVHiq4gNPFJTHduy7fWEgUPvre2VLpG";
70+
const b = "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De";
71+
const entries = signerEntriesFromAddresses([` ${a} `, b]);
72+
expect(entries.length).toBe(2);
73+
expect(entries.every((e) => e.SignerEntry.SignerWeight === 1)).toBe(true);
74+
const ids = entries.map((e) =>
75+
Buffer.from(decodeAccountID(e.SignerEntry.Account))
76+
);
77+
expect(Buffer.compare(ids[0], ids[1])).toBeLessThanOrEqual(0);
78+
});
79+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect } from "bun:test";
2+
import {
3+
serializeRequest,
4+
deserializeRequest,
5+
buildGasInstructionHex,
6+
type RequestLayout,
7+
} from "../xrpl/executorLayouts";
8+
import { RequestPrefix } from "@wormhole-foundation/sdk-connect";
9+
10+
describe("request layout round-trip", () => {
11+
it("ERN1 (NTT transfer) serializes with prefix 0x45524e31 and round-trips", () => {
12+
const req: RequestLayout = {
13+
request: {
14+
prefix: RequestPrefix.ERN1,
15+
srcChain: 66,
16+
srcManager: `0x${"11".repeat(32)}`,
17+
messageId: `0x${"22".repeat(32)}`,
18+
},
19+
};
20+
const hex = serializeRequest(req);
21+
expect(hex.slice(2, 10)).toBe("45524e31"); // "ERN1"
22+
const back = deserializeRequest(hex);
23+
expect(back).toEqual(req);
24+
});
25+
26+
it("ERV1 (VAA_V1) serializes with prefix 0x45525631 and round-trips", () => {
27+
const req: RequestLayout = {
28+
request: {
29+
prefix: RequestPrefix.ERV1,
30+
chain: 66,
31+
address: `0x${"ab".repeat(32)}`,
32+
sequence: 1234567890n,
33+
},
34+
};
35+
const hex = serializeRequest(req);
36+
expect(hex.slice(2, 10)).toBe("45525631"); // "ERV1"
37+
const back = deserializeRequest(hex);
38+
expect(back).toEqual(req);
39+
});
40+
});
41+
42+
describe("buildGasInstructionHex", () => {
43+
it("encodes 0x01 + 16-byte gasLimit + 16-byte msgValue", () => {
44+
const hex = buildGasInstructionHex(250_000n, 0n);
45+
// 1 + 16 + 16 = 33 bytes => 66 hex chars + 0x
46+
expect(hex.length).toBe(2 + 66);
47+
expect(hex.slice(2, 4)).toBe("01");
48+
// gasLimit 250000 = 0x3d090 as a 16-byte big-endian field, then 16 zero bytes
49+
const gasLimitField = (250_000).toString(16).padStart(32, "0");
50+
const msgValueField = "00".repeat(16);
51+
expect(hex.slice(2)).toBe("01" + gasLimitField + msgValueField);
52+
});
53+
});

0 commit comments

Comments
 (0)