From 51c9e4f2599fd13d25e00b664c6f845b5836e805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=BA=CE=B1=CF=83=CF=83=CE=AC=CE=BD=CE=B4=CF=81=CE=B1=2Ee?= =?UTF-8?q?th?= <0xDADA@protonmail.com> Date: Sat, 22 Aug 2026 13:00:38 -0400 Subject: [PATCH 1/2] feat: add unit tests and basic ci --- .github/workflows/ci.yml | 19 ++++ src/commands/unshield.ts | 54 +-------- src/utils/unshield-tail-calls.ts | 60 ++++++++++ tests/aes-storage.test.ts | 96 ++++++++++++++++ tests/cli-errors.test.ts | 53 +++++++++ tests/mnemonic.test.ts | 116 ++++++++++++++++++++ tests/names-contenthash.test.ts | 46 ++++++++ tests/names-ops.test.ts | 78 +++++++++++++ tests/names-parse.test.ts | 98 +++++++++++++++++ tests/network-traffic-log.test.ts | 90 +++++++++++++++ tests/plugins-protocol.test.ts | 124 +++++++++++++++++++++ tests/proving-artifacts.test.ts | 105 ++++++++++++++++++ tests/pure-helpers.test.ts | 61 +++++++++++ tests/railgun-unshield-max.test.ts | 76 +++++++++++++ tests/resolve-name.test.ts | 54 +++++++++ tests/shield-txs.test.ts | 117 ++++++++++++++++++++ tests/stealth-keys.test.ts | 48 ++++++++ tests/stealth-selectors.test.ts | 60 ++++++++++ tests/stealth-storage.test.ts | 92 ++++++++++++++++ tests/tokens-util.test.ts | 164 ++++++++++++++++++++++++++++ tests/tornado-paymaster-gas.test.ts | 71 ++++++++++++ tests/tornado-pools.test.ts | 159 +++++++++++++++++++++++++++ tests/transfer-max.test.ts | 48 ++++++++ tests/unshield-amount.test.ts | 70 ++++++++++++ tests/unshield-tail-calls.test.ts | 86 +++++++++++++++ tests/wallets-util-path.test.ts | 45 ++++++++ 26 files changed, 2037 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/utils/unshield-tail-calls.ts create mode 100644 tests/aes-storage.test.ts create mode 100644 tests/cli-errors.test.ts create mode 100644 tests/mnemonic.test.ts create mode 100644 tests/names-contenthash.test.ts create mode 100644 tests/names-ops.test.ts create mode 100644 tests/names-parse.test.ts create mode 100644 tests/network-traffic-log.test.ts create mode 100644 tests/plugins-protocol.test.ts create mode 100644 tests/proving-artifacts.test.ts create mode 100644 tests/pure-helpers.test.ts create mode 100644 tests/railgun-unshield-max.test.ts create mode 100644 tests/resolve-name.test.ts create mode 100644 tests/shield-txs.test.ts create mode 100644 tests/stealth-keys.test.ts create mode 100644 tests/stealth-selectors.test.ts create mode 100644 tests/stealth-storage.test.ts create mode 100644 tests/tokens-util.test.ts create mode 100644 tests/tornado-paymaster-gas.test.ts create mode 100644 tests/tornado-pools.test.ts create mode 100644 tests/transfer-max.test.ts create mode 100644 tests/unshield-amount.test.ts create mode 100644 tests/unshield-tail-calls.test.ts create mode 100644 tests/wallets-util-path.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c0d7b6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test diff --git a/src/commands/unshield.ts b/src/commands/unshield.ts index 15474fd..f67c4d6 100644 --- a/src/commands/unshield.ts +++ b/src/commands/unshield.ts @@ -104,6 +104,7 @@ import { tornadoDelegationConfig, tornadoUnshieldConfirmExtraLines, } from "../utils/tornado-unshield-delegation.js"; +import { parseTailCalls } from "../utils/unshield-tail-calls.js"; import { resolveWalletDir, resolveWalletNameOrPrompt, @@ -137,59 +138,6 @@ function as0xPrivateKey(priv: string): `0x${string}` { return (priv.startsWith("0x") ? priv : `0x${priv}`) as `0x${string}`; } -function parseTailCalls(raw: string): UnshieldTailCall[] { - const entries = raw.split(",").map((entry) => entry.trim()); - if (entries.length === 0 || entries.some((entry) => !entry)) { - throw new Error( - "--tail-calls must contain comma-separated TARGET:CALLDATA or TARGET:CALLDATA:VALUE entries." - ); - } - - return entries.map((entry, index) => { - const parts = entry.split(":").map((part) => part.trim()); - if (parts.length < 2 || parts.length > 3 || parts.some((part) => !part)) { - throw new Error( - `Invalid tail call at index ${index}: expected TARGET:CALLDATA or TARGET:CALLDATA:VALUE.` - ); - } - - const [target, data, valueRaw] = parts; - if (!isAddress(target!)) { - throw new Error(`Invalid tail call target at index ${index}: ${target}`); - } - if (!/^0x(?:[0-9a-fA-F]{2})*$/.test(data!)) { - throw new Error( - `Invalid tail call calldata at index ${index}: expected 0x-prefixed, byte-aligned hex.` - ); - } - - let value = 0n; - if (valueRaw !== undefined) { - if (!/^0x[0-9a-fA-F]+$/.test(valueRaw) && !/^[0-9]+$/.test(valueRaw)) { - throw new Error( - `Invalid tail call value at index ${index}: expected 0x-hex or decimal wei (${valueRaw}).` - ); - } - try { - value = BigInt(valueRaw); - } catch { - throw new Error( - `Invalid tail call value at index ${index}: ${valueRaw}` - ); - } - if (value < 0n) { - throw new Error(`Invalid tail call value at index ${index}: must be >= 0.`); - } - } - - return { - to: getAddress(target!) as `0x${string}`, - data: data! as `0x${string}`, - value, - }; - }); -} - /** Next fresh public account without advancing storage (persist after successful broadcast). */ function takeNextFreshPublicAccount( storage: ReturnType diff --git a/src/utils/unshield-tail-calls.ts b/src/utils/unshield-tail-calls.ts new file mode 100644 index 0000000..920998d --- /dev/null +++ b/src/utils/unshield-tail-calls.ts @@ -0,0 +1,60 @@ +import { getAddress, isAddress } from "viem"; + +import type { UnshieldTailCall } from "./plugins.js"; + +/** + * Parse `--tail-calls` as comma-separated `TARGET:CALLDATA` or + * `TARGET:CALLDATA:VALUE` entries (decimal wei or 0x-hex value). + */ +export function parseTailCalls(raw: string): UnshieldTailCall[] { + const entries = raw.split(",").map((entry) => entry.trim()); + if (entries.length === 0 || entries.some((entry) => !entry)) { + throw new Error( + "--tail-calls must contain comma-separated TARGET:CALLDATA or TARGET:CALLDATA:VALUE entries." + ); + } + + return entries.map((entry, index) => { + const parts = entry.split(":").map((part) => part.trim()); + if (parts.length < 2 || parts.length > 3 || parts.some((part) => !part)) { + throw new Error( + `Invalid tail call at index ${index}: expected TARGET:CALLDATA or TARGET:CALLDATA:VALUE.` + ); + } + + const [target, data, valueRaw] = parts; + if (!isAddress(target!)) { + throw new Error(`Invalid tail call target at index ${index}: ${target}`); + } + if (!/^0x(?:[0-9a-fA-F]{2})*$/.test(data!)) { + throw new Error( + `Invalid tail call calldata at index ${index}: expected 0x-prefixed, byte-aligned hex.` + ); + } + + let value = 0n; + if (valueRaw !== undefined) { + if (!/^0x[0-9a-fA-F]+$/.test(valueRaw) && !/^[0-9]+$/.test(valueRaw)) { + throw new Error( + `Invalid tail call value at index ${index}: expected 0x-hex or decimal wei (${valueRaw}).` + ); + } + try { + value = BigInt(valueRaw); + } catch { + throw new Error( + `Invalid tail call value at index ${index}: ${valueRaw}` + ); + } + if (value < 0n) { + throw new Error(`Invalid tail call value at index ${index}: must be >= 0.`); + } + } + + return { + to: getAddress(target!) as `0x${string}`, + data: data! as `0x${string}`, + value, + }; + }); +} diff --git a/tests/aes-storage.test.ts b/tests/aes-storage.test.ts new file mode 100644 index 0000000..a95cd3d --- /dev/null +++ b/tests/aes-storage.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import { + decrypt, + deriveKeyFromPassword, + encrypt, + generateSalt, + isEncryptedEnvelopeV1, + loadStore, + saveStore, +} from "../src/utils/aes-storage.js"; + +describe("isEncryptedEnvelopeV1", () => { + it("accepts a well-formed envelope and rejects plaintext JSON", () => { + assert.equal( + isEncryptedEnvelopeV1({ + v: 1, + salt: "a", + iv: "b", + tag: "c", + ciphertext: "d", + }), + true + ); + assert.equal(isEncryptedEnvelopeV1({ foo: "bar" }), false); + assert.equal(isEncryptedEnvelopeV1(null), false); + assert.equal(isEncryptedEnvelopeV1("secret"), false); + }); +}); + +describe("deriveKeyFromPassword", () => { + it("rejects an empty password", () => { + assert.throws( + () => deriveKeyFromPassword("", generateSalt()), + /Password cannot be empty/ + ); + }); +}); + +describe("encrypt/decrypt", () => { + it("round-trips UTF-8 plaintext", () => { + const salt = generateSalt(); + const envelope = encrypt("hello stealth", "pw", salt); + assert.equal(isEncryptedEnvelopeV1(envelope), true); + assert.equal(decrypt(envelope, "pw"), "hello stealth"); + }); + + it("fails closed on the wrong password", () => { + const envelope = encrypt("secret", "right", generateSalt()); + assert.throws(() => decrypt(envelope, "wrong")); + }); +}); + +describe("loadStore / saveStore", () => { + it("returns an empty store when the file is missing", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-aes-")); + try { + const missing = join(dir, "nope.json"); + assert.deepEqual(loadStore(missing, "pw"), { + store: JSON.stringify({}), + salt: null, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("throws on a non-envelope file instead of returning it as plaintext", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-aes-")); + try { + const path = join(dir, "store.json"); + writeFileSync(path, JSON.stringify({ accounts: [] })); + assert.throws(() => loadStore(path, "pw"), /Invalid storage file/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("round-trips through saveStore", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-aes-")); + try { + const path = join(dir, "store.json"); + const saltRef = { current: null as Uint8Array | null }; + saveStore(path, JSON.stringify({ ok: 1 }), "pw", saltRef); + const loaded = loadStore(path, "pw"); + assert.equal(loaded.store, JSON.stringify({ ok: 1 })); + assert.ok(loaded.salt); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cli-errors.test.ts b/tests/cli-errors.test.ts new file mode 100644 index 0000000..adcd91c --- /dev/null +++ b/tests/cli-errors.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + formatCaughtError, + withTorBootstrapHint, +} from "../src/utils/cli-errors.js"; + +describe("formatCaughtError", () => { + it("returns primitives and strings as text", () => { + assert.equal(formatCaughtError("boom"), "boom"); + assert.equal(formatCaughtError(null), "null"); + assert.equal(formatCaughtError(undefined), "undefined"); + assert.equal(formatCaughtError(3n), "3"); + }); + + it("does not stringify a plain object as [object Object]", () => { + const text = formatCaughtError({ message: "rpc failed", code: -32000 }); + assert.equal(text.includes("[object Object]"), false); + assert.ok(text.includes("rpc failed")); + assert.ok(text.includes("-32000")); + }); + + it("prefers Error.message and appends a cause", () => { + const err = new Error("outer"); + err.cause = { message: "inner" }; + const text = formatCaughtError(err); + assert.ok(text.startsWith("outer")); + assert.ok(text.includes("cause:")); + assert.ok(text.includes("inner")); + }); + + it("falls back to shortMessage when message is useless", () => { + const err = new Error("[object Object]"); + (err as Error & { shortMessage: string }).shortMessage = "execution reverted"; + const text = formatCaughtError(err); + assert.ok(text.includes("execution reverted")); + assert.equal(text.includes("[object Object]"), false); + }); +}); + +describe("withTorBootstrapHint", () => { + it("appends clear-tor-cache once on a bootstrap failure", () => { + const msg = "Bootstrap failed: tor: timed out"; + const hinted = withTorBootstrapHint(msg); + assert.ok(hinted.includes("kohaku clear-tor-cache")); + assert.equal(withTorBootstrapHint(hinted), hinted); + }); + + it("leaves unrelated errors alone", () => { + assert.equal(withTorBootstrapHint("insufficient funds"), "insufficient funds"); + }); +}); diff --git a/tests/mnemonic.test.ts b/tests/mnemonic.test.ts new file mode 100644 index 0000000..a375950 --- /dev/null +++ b/tests/mnemonic.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import { + SEED_FILENAME, + isSeedKeystoreV1, + normalizeValidatedMnemonic, + peekAddressesFromMnemonic, + readSeedKeystore, + writeSeedKeystore, +} from "../src/utils/mnemonic.js"; + +const MNEMONIC = + "test test test test test test test test test test test junk"; + +describe("normalizeValidatedMnemonic", () => { + it("trims a valid BIP-39 phrase", () => { + assert.equal(normalizeValidatedMnemonic(` ${MNEMONIC} `), MNEMONIC); + }); + + it("rejects empty and invalid phrases", () => { + assert.throws(() => normalizeValidatedMnemonic(""), /cannot be empty/); + assert.throws( + () => normalizeValidatedMnemonic("not a mnemonic"), + /Invalid BIP-39 mnemonic phrase/ + ); + }); +}); + +describe("isSeedKeystoreV1", () => { + it("requires kind, version, and an AES envelope", () => { + assert.equal( + isSeedKeystoreV1({ + kind: "kohaku-cli/seed", + version: 1, + crypto: { v: 1, salt: "a", iv: "b", tag: "c", ciphertext: "d" }, + }), + true + ); + assert.equal( + isSeedKeystoreV1({ + v: 1, + salt: "a", + iv: "b", + tag: "c", + ciphertext: "d", + }), + false + ); + }); +}); + +describe("writeSeedKeystore / readSeedKeystore", () => { + it("round-trips a mnemonic and refuses to overwrite", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-seed-")); + try { + writeSeedKeystore(MNEMONIC, "pw", dir); + assert.equal(readSeedKeystore("pw", dir), MNEMONIC); + assert.throws( + () => writeSeedKeystore(MNEMONIC, "pw", dir), + /already exists. Never overwrite existing seed/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails closed on the wrong password", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-seed-")); + try { + writeSeedKeystore(MNEMONIC, "right", dir); + assert.throws( + () => readSeedKeystore("wrong", dir), + /wrong password or corrupted file/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("throws when the seed file is missing", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-seed-")); + try { + assert.throws( + () => readSeedKeystore("pw", dir), + /Seed keystore not found/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a JSON file that is not a seed keystore", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-seed-")); + try { + writeFileSync(join(dir, SEED_FILENAME), JSON.stringify({ v: 1 })); + assert.throws( + () => readSeedKeystore("pw", dir), + /Invalid or unsupported seed keystore/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("peekAddressesFromMnemonic", () => { + it("derives the well-known junk-mnemonic account 0", () => { + assert.deepEqual(peekAddressesFromMnemonic(MNEMONIC, [0]), [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + ]); + }); +}); diff --git a/tests/names-contenthash.test.ts b/tests/names-contenthash.test.ts new file mode 100644 index 0000000..4afbeb8 --- /dev/null +++ b/tests/names-contenthash.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { encodeWebsiteContenthash } from "../src/lib/names/contenthash.js"; + +describe("encodeWebsiteContenthash", () => { + it("encodes a CIDv0 ipfs:// URI as an ENSIP-7 contenthash", () => { + const encoded = encodeWebsiteContenthash( + "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" + ); + assert.ok(encoded.startsWith("0xe3")); + assert.equal(encoded, encodeWebsiteContenthash( + "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG/" + )); + }); + + it("rejects an ipfs URI with a path", () => { + assert.throws( + () => + encodeWebsiteContenthash( + "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG/index.html" + ), + /must be a CID without a path/ + ); + }); + + it("encodes a 32-byte Swarm reference", () => { + const hash = "aa".repeat(32); + assert.equal(encodeWebsiteContenthash(`bzz://${hash}`), `0xe40101fa011b20${hash}`); + }); + + it("rejects missing prefixes and unsupported CIDs", () => { + assert.throws( + () => encodeWebsiteContenthash("https://example.com"), + /ipfs:\/\/ or bzz:\/\// + ); + assert.throws( + () => encodeWebsiteContenthash("ipfs://hello"), + /Unsupported CID format/ + ); + assert.throws( + () => encodeWebsiteContenthash("bzz://abcd"), + /Invalid Swarm reference/ + ); + }); +}); diff --git a/tests/names-ops.test.ts b/tests/names-ops.test.ts new file mode 100644 index 0000000..90a18a4 --- /dev/null +++ b/tests/names-ops.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { keccak256, toBytes } from "viem"; +import { namehash } from "viem/ens"; + +import { GNS_CONTRACT, GWEI_NODE, ONE_YEAR_SECONDS, WNS_CONTRACT } from "../src/lib/names/constants.js"; +import { + parseTransferRole, + requiredAddressForRecords, + requiredAddressForTransfer, +} from "../src/lib/names/ownership.js"; +import { + defaultDurationSeconds, + ensLabelTokenId, + nftContract, + nftParentId, +} from "../src/lib/names/ops.js"; +import type { NameOwnership } from "../src/lib/names/types.js"; + +const OWNERSHIP: NameOwnership = { + owner: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + manager: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + wrapped: false, + node: "0x01", +}; + +describe("parseTransferRole", () => { + it("defaults to both and rejects unknown roles", () => { + assert.equal(parseTransferRole(undefined), "both"); + assert.equal(parseTransferRole(" OWNER "), "owner"); + assert.equal(parseTransferRole("manager"), "manager"); + assert.throws(() => parseTransferRole("admin"), /must be owner, manager, or both/); + }); +}); + +describe("requiredAddressForTransfer / records", () => { + it("picks the owner unless the role is manager-only; records always need the manager", () => { + assert.equal(requiredAddressForTransfer(OWNERSHIP, "owner"), OWNERSHIP.owner); + assert.equal(requiredAddressForTransfer(OWNERSHIP, "both"), OWNERSHIP.owner); + assert.equal( + requiredAddressForTransfer(OWNERSHIP, "manager"), + OWNERSHIP.manager + ); + assert.equal(requiredAddressForRecords(OWNERSHIP), OWNERSHIP.manager); + }); +}); + +describe("ensLabelTokenId", () => { + it("is keccak256(label bytes), not the namehash of the full name", () => { + assert.equal(ensLabelTokenId("vitalik"), BigInt(keccak256(toBytes("vitalik")))); + assert.notEqual(ensLabelTokenId("vitalik"), BigInt(namehash("vitalik.eth"))); + assert.notEqual(ensLabelTokenId("vitalik"), BigInt(namehash("vitalik"))); + }); +}); + +describe("nftParentId", () => { + it("is 0 for top-level .gwei/.wei, not the TLD namehash", () => { + assert.equal(nftParentId("gns"), 0n); + assert.equal(nftParentId("wns"), 0n); + assert.notEqual(nftParentId("gns"), BigInt(GWEI_NODE)); + }); +}); + +describe("nftContract", () => { + it("maps gns/wns onto the canonical NFT addresses", () => { + assert.equal(nftContract("gns"), GNS_CONTRACT); + assert.equal(nftContract("wns"), WNS_CONTRACT); + }); +}); + +describe("defaultDurationSeconds", () => { + it("defaults to one year and rejects non-integer years", () => { + assert.equal(defaultDurationSeconds(undefined), ONE_YEAR_SECONDS); + assert.equal(defaultDurationSeconds(2), 2n * ONE_YEAR_SECONDS); + assert.throws(() => defaultDurationSeconds(0), /positive integer/); + assert.throws(() => defaultDurationSeconds(1.5), /positive integer/); + }); +}); diff --git a/tests/names-parse.test.ts b/tests/names-parse.test.ts new file mode 100644 index 0000000..059fcfd --- /dev/null +++ b/tests/names-parse.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + parseManagedName, + parseNameLabelOrFull, + parseNameProtocol, + parseRegisterName, + protocolFromNameTld, +} from "../src/lib/names/parse.js"; + +describe("parseNameProtocol", () => { + it("accepts ens/gns/wns case-insensitively", () => { + assert.equal(parseNameProtocol("ENS"), "ens"); + assert.equal(parseNameProtocol("gns"), "gns"); + assert.equal(parseNameProtocol("wns"), "wns"); + }); + + it("rejects a missing or unknown protocol", () => { + assert.throws(() => parseNameProtocol(undefined), /must be one of/); + assert.throws(() => parseNameProtocol("ensv2"), /must be one of/); + }); +}); + +describe("protocolFromNameTld", () => { + it("maps supported TLDs and ignores everything else", () => { + assert.equal(protocolFromNameTld("Alice.ETH"), "ens"); + assert.equal(protocolFromNameTld("x.gwei"), "gns"); + assert.equal(protocolFromNameTld("x.wei"), "wns"); + assert.equal(protocolFromNameTld("alice"), null); + assert.equal(protocolFromNameTld("alice.com"), null); + }); +}); + +describe("parseManagedName", () => { + it("parses a top-level name and rejects subdomains", () => { + assert.deepEqual(parseManagedName(" Alice.ETH "), { + label: "alice", + protocol: "ens", + name: "alice.eth", + }); + assert.deepEqual(parseManagedName("Bob.gwei"), { + label: "bob", + protocol: "gns", + name: "bob.gwei", + }); + assert.throws( + () => parseManagedName("alice.bob.eth"), + /Only top-level names/ + ); + assert.throws(() => parseManagedName("alice"), /must end with/); + }); +}); + +describe("parseNameLabelOrFull", () => { + it("treats a bare label as bare and a TLD name as full", () => { + assert.deepEqual(parseNameLabelOrFull("alice"), { + kind: "bare", + label: "alice", + }); + const full = parseNameLabelOrFull("alice.eth"); + assert.equal(full.kind, "full"); + assert.equal(full.label, "alice"); + assert.equal(full.parsed?.protocol, "ens"); + }); + + it("rejects empty input and unsupported TLDs", () => { + assert.throws(() => parseNameLabelOrFull(" "), /must not be empty/); + assert.throws( + () => parseNameLabelOrFull("alice.com"), + /Unsupported TLD/ + ); + }); +}); + +describe("parseRegisterName", () => { + it("applies --protocol to a bare label", () => { + assert.deepEqual(parseRegisterName("Alice", "gns"), { + label: "alice", + protocol: "gns", + name: "alice.gwei", + }); + }); + + it("rejects a TLD that does not match --protocol", () => { + assert.throws( + () => parseRegisterName("alice.eth", "gns"), + /is a ENS name but --protocol is gns/ + ); + }); + + it("rejects subdomains even when the TLD matches", () => { + assert.throws( + () => parseRegisterName("alice.bob.eth", "ens"), + /Only top-level names/ + ); + }); +}); diff --git a/tests/network-traffic-log.test.ts b/tests/network-traffic-log.test.ts new file mode 100644 index 0000000..018404d --- /dev/null +++ b/tests/network-traffic-log.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + categorizeUrl, + isLocalEntry, + redactUrl, + type NetworkTrafficEntry, +} from "../src/utils/network-traffic-log.js"; + +function entry( + overrides: Partial & Pick +): NetworkTrafficEntry { + return { + ts: "2026-01-01T00:00:00.000Z", + kind: "http", + method: "GET", + host: "example.com", + via: "clearnet", + category: "other", + ...overrides, + }; +} + +describe("redactUrl", () => { + it("redacts Infura-style /v3/ path segments", () => { + const redacted = redactUrl("https://mainnet.infura.io/v3/abcd1234efgh5678"); + assert.equal(redacted.includes("abcd1234efgh5678"), false); + assert.match(redacted, /v3\/(?:|%3Credacted%3E)/); + }); + + it("does not redact keyless Pimlico /v2//rpc URLs", () => { + const url = "https://public.pimlico.io/v2/1/rpc"; + assert.equal(redactUrl(url), url); + }); + + it("redacts apikey-style query params", () => { + const redacted = redactUrl("https://eth.example/rpc?apikey=supersecret"); + assert.equal(redacted.includes("supersecret"), false); + assert.match(redacted, /apikey=(?:|%3Credacted%3E)/); + }); + + it("leaves localhost RPC URLs intact", () => { + const url = "http://127.0.0.1:8545/v3/notakeybecauseitslocal"; + assert.equal(redactUrl(url), url); + }); +}); + +describe("categorizeUrl", () => { + it("classifies known kohaku backends", () => { + assert.equal(categorizeUrl("https://public.pimlico.io/v2/1/rpc"), "pimlico"); + assert.equal( + categorizeUrl("https://saga.gordosoluciones.xyz/foo"), + "saga" + ); + assert.equal( + categorizeUrl("https://artifacts.0000000000.org/railgun/01x01/wasm.br"), + "artifacts" + ); + assert.equal(categorizeUrl("https://example.com/foo"), "other"); + }); +}); + +describe("isLocalEntry", () => { + it("treats loopback and local-artifact reasons as local", () => { + assert.equal( + isLocalEntry( + entry({ url: "https://example.com", clearnetReason: "loopback" }) + ), + true + ); + assert.equal( + isLocalEntry( + entry({ + url: "https://artifacts.0000000000.org/x", + clearnetReason: "local-artifact", + }) + ), + true + ); + assert.equal( + isLocalEntry(entry({ url: "http://localhost:8545" })), + true + ); + assert.equal( + isLocalEntry(entry({ url: "https://public.pimlico.io/v2/1/rpc" })), + false + ); + }); +}); diff --git a/tests/plugins-protocol.test.ts b/tests/plugins-protocol.test.ts new file mode 100644 index 0000000..90bde9c --- /dev/null +++ b/tests/plugins-protocol.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +import { + parseIncludeProtocols, + pluginIdForProtocol, + resolveDefaultPrivacyProtocol, + resolveIncludeProtocols, + resolveProtocolOption, + shouldIncludeProtocol, +} from "../src/utils/plugins.js"; + +const ORIG_DEFAULT = process.env.DEFAULT_PRIVACY_PROTOCOL; + +afterEach(() => { + if (ORIG_DEFAULT === undefined) { + delete process.env.DEFAULT_PRIVACY_PROTOCOL; + } else { + process.env.DEFAULT_PRIVACY_PROTOCOL = ORIG_DEFAULT; + } +}); + +describe("resolveProtocolOption", () => { + it("prefers the flag over the env default", () => { + process.env.DEFAULT_PRIVACY_PROTOCOL = "railgun"; + assert.deepEqual(resolveProtocolOption("tornado"), { + ok: true, + protocol: "tornado", + }); + }); + + it("falls back to DEFAULT_PRIVACY_PROTOCOL", () => { + process.env.DEFAULT_PRIVACY_PROTOCOL = "privacy-pools"; + assert.deepEqual(resolveProtocolOption(undefined), { + ok: true, + protocol: "privacy-pools", + }); + assert.deepEqual(resolveProtocolOption(" "), { + ok: true, + protocol: "privacy-pools", + }); + }); + + it("returns invalid for an unknown flag", () => { + assert.deepEqual(resolveProtocolOption("nope"), { + ok: false, + error: "invalid", + }); + }); + + it("returns missing when neither flag nor env is set", () => { + delete process.env.DEFAULT_PRIVACY_PROTOCOL; + assert.deepEqual(resolveProtocolOption(undefined), { + ok: false, + error: "missing", + }); + }); + + it("ignores a garbage env default", () => { + process.env.DEFAULT_PRIVACY_PROTOCOL = "Railgun"; + assert.deepEqual(resolveProtocolOption(undefined), { + ok: false, + error: "missing", + }); + }); +}); + +describe("parseIncludeProtocols", () => { + it("returns null when omitted", () => { + assert.equal(parseIncludeProtocols(undefined), null); + assert.equal(parseIncludeProtocols(" "), null); + }); + + it("splits on commas and whitespace and dedupes", () => { + assert.deepEqual(parseIncludeProtocols("railgun, tornado tornado"), [ + "railgun", + "tornado", + ]); + }); + + it("throws on an unknown protocol or empty list after split", () => { + assert.throws( + () => parseIncludeProtocols("railgun,nope"), + /Invalid protocol in --include/ + ); + }); +}); + +describe("resolveIncludeProtocols", () => { + it("uses --include when present, else the env default, else none", () => { + delete process.env.DEFAULT_PRIVACY_PROTOCOL; + assert.deepEqual(resolveIncludeProtocols(undefined), []); + process.env.DEFAULT_PRIVACY_PROTOCOL = "tornado"; + assert.deepEqual(resolveIncludeProtocols(undefined), ["tornado"]); + assert.deepEqual(resolveIncludeProtocols("railgun"), ["railgun"]); + }); +}); + +describe("shouldIncludeProtocol", () => { + it("includes everything when the filter is null", () => { + assert.equal(shouldIncludeProtocol("railgun", null), true); + assert.equal(shouldIncludeProtocol("tornado", ["tornado"]), true); + assert.equal(shouldIncludeProtocol("railgun", ["tornado"]), false); + }); +}); + +describe("pluginIdForProtocol", () => { + it("maps CLI protocol names onto Host plugin ids", () => { + assert.equal(pluginIdForProtocol("railgun"), "rg"); + assert.equal(pluginIdForProtocol("tornado"), "tc"); + assert.equal(pluginIdForProtocol("privacy-pools"), "ppv1"); + }); +}); + +describe("resolveDefaultPrivacyProtocol", () => { + it("only accepts exact supported ids", () => { + delete process.env.DEFAULT_PRIVACY_PROTOCOL; + assert.equal(resolveDefaultPrivacyProtocol(), undefined); + process.env.DEFAULT_PRIVACY_PROTOCOL = "railgun"; + assert.equal(resolveDefaultPrivacyProtocol(), "railgun"); + process.env.DEFAULT_PRIVACY_PROTOCOL = "railgun "; + assert.equal(resolveDefaultPrivacyProtocol(), "railgun"); + }); +}); diff --git a/tests/proving-artifacts.test.ts b/tests/proving-artifacts.test.ts new file mode 100644 index 0000000..1751124 --- /dev/null +++ b/tests/proving-artifacts.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +import { + DEFAULT_ARTIFACTS_BASE_URL, + artifactRelativeKeyFromUrl, + listAllArtifactRelativeKeys, + parseFetchArtifactSelection, + railgunTransactVariants, +} from "../src/utils/proving-artifacts.js"; + +const ORIG_BASE = process.env.KOHAKU_ARTIFACTS_BASE_URL; + +afterEach(() => { + if (ORIG_BASE === undefined) { + delete process.env.KOHAKU_ARTIFACTS_BASE_URL; + } else { + process.env.KOHAKU_ARTIFACTS_BASE_URL = ORIG_BASE; + } +}); + +const MACWHA = + "https://github.com/Robert-MacWha/privacy-protocol-artifacts/raw/refs/heads/main/artifacts"; +const TORNADO_JSON = + "https://raw.githubusercontent.com/tornadocash/tornado-cli/refs/heads/master/build/circuits/tornado.json"; +const TORNADO_KEY = + "https://raw.githubusercontent.com/tornadocash/tornado-cli/refs/heads/master/build/circuits/tornadoProvingKey.bin"; + +describe("railgunTransactVariants", () => { + it("covers the 01x01…05x05 transact grid", () => { + const v = railgunTransactVariants(); + assert.equal(v.length, 25); + assert.equal(v[0], "01x01"); + assert.equal(v.at(-1), "05x05"); + }); +}); + +describe("parseFetchArtifactSelection", () => { + it("returns the full set when no filters are given", () => { + const all = listAllArtifactRelativeKeys(); + assert.deepEqual(parseFetchArtifactSelection({}), all); + assert.ok(all.includes("tornado/tornado.json")); + assert.ok(all.includes("railgun/01x01/proving_key.bin.br")); + assert.ok(all.includes("railgun/poi/03x03/wasm.br")); + }); + + it("unions explicit keys, variants, and tornado", () => { + const keys = parseFetchArtifactSelection({ + keys: ["railgun/01x01/wasm.br"], + variants: ["01x02"], + tornado: true, + }); + assert.ok(keys.includes("railgun/01x01/wasm.br")); + assert.ok(keys.includes("railgun/01x02/proving_key.bin.br")); + assert.ok(keys.includes("tornado/tornado.json")); + assert.ok(keys.includes("tornado/tornadoProvingKey.bin")); + }); + + it("rejects an unknown key or variant", () => { + assert.throws( + () => parseFetchArtifactSelection({ keys: ["nope.bin"] }), + /Unknown artifact key/ + ); + assert.throws( + () => parseFetchArtifactSelection({ variants: ["99x99"] }), + /Unknown Railgun transact variant/ + ); + assert.throws( + () => parseFetchArtifactSelection({ poi: ["01x01"] }), + /Unknown Railgun POI variant/ + ); + }); +}); + +describe("artifactRelativeKeyFromUrl", () => { + it("maps MacWha artifact URLs onto cache-relative paths", () => { + assert.equal( + artifactRelativeKeyFromUrl(`${MACWHA}/01x01/proving_key.bin.br`), + "01x01/proving_key.bin.br" + ); + }); + + it("maps Tornado GitHub circuit URLs", () => { + assert.equal(artifactRelativeKeyFromUrl(TORNADO_JSON), "tornado/tornado.json"); + assert.equal( + artifactRelativeKeyFromUrl(TORNADO_KEY), + "tornado/tornadoProvingKey.bin" + ); + }); + + it("maps the default artifacts mirror when the env is unset", () => { + delete process.env.KOHAKU_ARTIFACTS_BASE_URL; + assert.equal( + artifactRelativeKeyFromUrl( + `${DEFAULT_ARTIFACTS_BASE_URL}/railgun/01x01/wasm.br` + ), + "railgun/01x01/wasm.br" + ); + }); + + it("returns null for an unrelated HTTP URL", () => { + assert.equal(artifactRelativeKeyFromUrl("https://example.com/foo"), null); + assert.equal(artifactRelativeKeyFromUrl("not a url"), null); + }); +}); diff --git a/tests/pure-helpers.test.ts b/tests/pure-helpers.test.ts new file mode 100644 index 0000000..bc1b247 --- /dev/null +++ b/tests/pure-helpers.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { formatUsdCents } from "../src/lib/usd-values.js"; +import { jsonStringifyWithBigInt } from "../src/utils/json-bigint.js"; +import { estimatedGetLogsWindowCount } from "../src/host/chunked-get-logs.js"; +import { formatLegacyTornadoNote } from "../src/commands/export-tornado-note.js"; +import { normalizeTornadoNoteInput } from "../src/commands/import-tornado-note.js"; + +describe("formatUsdCents", () => { + it("rounds half-up to cents", () => { + assert.equal(formatUsdCents(1_234_567n), "1.23"); + assert.equal(formatUsdCents(1_235_000n), "1.24"); + assert.equal(formatUsdCents(0n), "0.00"); + assert.equal(formatUsdCents(-12_345_000n), "-12.34"); + }); +}); + +describe("jsonStringifyWithBigInt", () => { + it("renders bigint values as decimal strings", () => { + assert.equal(jsonStringifyWithBigInt({ a: 1n }), '{"a":"1"}'); + assert.equal(jsonStringifyWithBigInt([10n]), '["10"]'); + }); +}); + +describe("estimatedGetLogsWindowCount", () => { + it("counts inclusive windows of at most chunkSpan blocks", () => { + assert.equal(estimatedGetLogsWindowCount(0n, 498n, 499n), 1); + assert.equal(estimatedGetLogsWindowCount(0n, 499n, 499n), 2); + assert.equal(estimatedGetLogsWindowCount(100n, 99n, 499n), 0); + assert.equal(estimatedGetLogsWindowCount(0n, 10n, 0n), 0); + }); +}); + +describe("normalizeTornadoNoteInput", () => { + it("accepts classic notes and prefixes the short form", () => { + const classic = + "tornado-eth-1-1-0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123"; + assert.equal(normalizeTornadoNoteInput(` ${classic} `), classic); + assert.equal( + normalizeTornadoNoteInput("eth-1-1-0xab"), + "tornado-eth-1-1-0xab" + ); + assert.equal(normalizeTornadoNoteInput(""), ""); + }); +}); + +describe("formatLegacyTornadoNote", () => { + it("lowercases the currency and embeds chain id and 62-byte preimage", () => { + const note = formatLegacyTornadoNote({ + currency: "ETH", + denominationLabel: "1", + chainId: 1n, + nullifier: 1n, + salt: 2n, + }); + assert.ok(note.startsWith("tornado-eth-1-1-0x")); + const hex = note.slice("tornado-eth-1-1-0x".length); + assert.equal(hex.length, 124); + }); +}); diff --git a/tests/railgun-unshield-max.test.ts b/tests/railgun-unshield-max.test.ts new file mode 100644 index 0000000..e5e699c --- /dev/null +++ b/tests/railgun-unshield-max.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + RAILGUN_UNSHIELD_GAS_UNITS, + estimateRailgunBundlerFeeWei, + isRailgunFeeToken, + railgunMaxReceivableFromBalance, +} from "../src/utils/railgun-unshield-max.js"; + +const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; + +describe("railgunMaxReceivableFromBalance", () => { + it("subtracts the reserve then takes the treasury BPS from the remainder", () => { + // Recipient gets floor((balance - reserve) * (10000 - bps) / 10000). + assert.equal(railgunMaxReceivableFromBalance(1000n, 25, 100n), 897n); + }); + + it("returns 0 when the reserve consumes the whole balance", () => { + assert.equal(railgunMaxReceivableFromBalance(100n, 25, 100n), 0n); + assert.equal(railgunMaxReceivableFromBalance(99n, 25, 100n), 0n); + }); + + it("returns 0 when the unshield fee is 100% or more", () => { + assert.equal(railgunMaxReceivableFromBalance(1_000n, 10_000, 0n), 0n); + assert.equal(railgunMaxReceivableFromBalance(1_000n, 10_001, 0n), 0n); + }); + + it("leaves the full remainder when the treasury fee is 0", () => { + assert.equal(railgunMaxReceivableFromBalance(500n, 0, 50n), 450n); + }); +}); + +describe("estimateRailgunBundlerFeeWei", () => { + const baselineGas = + RAILGUN_UNSHIELD_GAS_UNITS.verificationGasLimit + + RAILGUN_UNSHIELD_GAS_UNITS.callGasLimit + + RAILGUN_UNSHIELD_GAS_UNITS.paymasterVerificationGasLimit + + RAILGUN_UNSHIELD_GAS_UNITS.preVerificationGas + + RAILGUN_UNSHIELD_GAS_UNITS.paymasterPostOpGasLimit; + + it("applies the 1.2× safety margin to the static UserOp gas units", () => { + assert.equal(estimateRailgunBundlerFeeWei(1n), (baselineGas * 12n) / 10n); + assert.equal( + estimateRailgunBundlerFeeWei(10n), + (baselineGas * 10n * 12n) / 10n + ); + }); + + it("adds native-unwrap call gas on top of the baseline, not instead of it", () => { + const withUnwrap = + baselineGas + RAILGUN_UNSHIELD_GAS_UNITS.nativeUnwrapCallGas; + assert.equal( + estimateRailgunBundlerFeeWei(1n, { nativeUnwrap: true }), + (withUnwrap * 12n) / 10n + ); + }); + + it("adds measured tail-call gas on top of the call-gas baseline", () => { + const tail = 50_000n; + const withTail = baselineGas + tail; + assert.equal( + estimateRailgunBundlerFeeWei(1n, { tailCallsGasEstimate: tail }), + (withTail * 12n) / 10n + ); + }); +}); + +describe("isRailgunFeeToken", () => { + it("treats native ETH as the fee token on every chain", () => { + assert.equal( + isRailgunFeeToken({ isEth: true, tokenAddress: DAI }, 1n), + true + ); + }); +}); diff --git a/tests/resolve-name.test.ts b/tests/resolve-name.test.ts new file mode 100644 index 0000000..26510cf --- /dev/null +++ b/tests/resolve-name.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getAddress } from "viem"; + +import { + looksLikeName, + maybeResolveName, + resolveAddressOrName, +} from "../src/utils/resolve-name.js"; + +const ADDR = "0x9dAEf1EA5CC90C0F9DA9a5F0B49DA10510c34502"; +const ADDR_LOWER = ADDR.toLowerCase(); + +describe("looksLikeName", () => { + it("is true only for a non-address with a supported TLD", () => { + assert.equal(looksLikeName("alice.eth"), true); + assert.equal(looksLikeName("Alice.GWEI"), true); + assert.equal(looksLikeName("x.wei"), true); + assert.equal(looksLikeName(ADDR), false); + assert.equal(looksLikeName("alice"), false); + assert.equal(looksLikeName("alice.com"), false); + }); +}); + +describe("resolveAddressOrName", () => { + it("checksums a plain address without touching RPC", async () => { + assert.equal(await resolveAddressOrName(ADDR_LOWER), getAddress(ADDR_LOWER)); + assert.equal(await resolveAddressOrName(` ${ADDR} `), ADDR); + }); + + it("refuses to forward a typo as an address", async () => { + await assert.rejects( + () => resolveAddressOrName("not-an-address"), + /not a valid Ethereum address/ + ); + await assert.rejects( + () => resolveAddressOrName("alice"), + /must end with \.eth, \.gwei, or \.wei/ + ); + }); + + it("does not fall back to a public RPC when resolving a name without rpcUrl", async () => { + await assert.rejects( + () => resolveAddressOrName("alice.eth"), + /requires an RPC URL/ + ); + }); +}); + +describe("maybeResolveName", () => { + it("marks a plain address as not resolved-from-name", async () => { + assert.deepEqual(await maybeResolveName(ADDR), { address: ADDR }); + }); +}); diff --git a/tests/shield-txs.test.ts b/tests/shield-txs.test.ts new file mode 100644 index 0000000..505bfe8 --- /dev/null +++ b/tests/shield-txs.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { encodeFunctionData, getAddress } from "viem"; + +import { + parseFromIndex, + partitionShieldTxs, + toShieldTxs, + tryDecodeErc20Approve, + type ShieldCall, +} from "../src/lib/shield-flow.js"; +import { ERC20_ABI } from "../src/utils/tokens-util.js"; + +const TOKEN = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const POOL_A = getAddress("0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc"); +const POOL_B = getAddress("0x47ce0c6ed5b0ce3d3a51fdb1c52dc66a7c3c2936"); +const OTHER = "0x9dAEf1EA5CC90C0F9DA9a5F0B49DA10510c34502"; + +function approveCall(spender: string, amount: bigint): ShieldCall { + return { + to: TOKEN, + value: 0n, + data: encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [spender as `0x${string}`, amount], + }), + }; +} + +function depositCall(to: string, value = 0n): ShieldCall { + return { to, data: "0xdead", value }; +} + +describe("parseFromIndex", () => { + it("parses a non-negative decimal HD index", () => { + assert.equal(parseFromIndex("0"), 0); + assert.equal(parseFromIndex("12"), 12); + }); + + it("does not treat a stealth selector as an HD index", () => { + assert.equal(parseFromIndex("s0"), null); + assert.equal(parseFromIndex("stealth:0"), null); + }); + + it("rejects signed or empty values", () => { + assert.equal(parseFromIndex("-1"), null); + assert.equal(parseFromIndex(""), null); + assert.equal(parseFromIndex("1.5"), null); + }); +}); + +describe("tryDecodeErc20Approve", () => { + it("decodes spender and amount from approve calldata", () => { + const data = encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [POOL_A, 1_000n], + }); + assert.deepEqual(tryDecodeErc20Approve(data), { + spender: getAddress(POOL_A), + amount: 1_000n, + }); + }); + + it("returns null for non-approve calldata", () => { + assert.equal(tryDecodeErc20Approve("0x"), null); + assert.equal(tryDecodeErc20Approve("0xdeadbeef"), null); + }); +}); + +describe("partitionShieldTxs", () => { + it("aggregates plugin approve amounts per spender and keeps deposits", () => { + const txs = [ + approveCall(POOL_A, 100n), + depositCall(POOL_A, 100n), + approveCall(POOL_A, 100n), + depositCall(POOL_A, 100n), + approveCall(POOL_B, 1_000n), + depositCall(POOL_B, 1_000n), + ]; + const { deposits, approvalNeededBySpender, approvalToken } = + partitionShieldTxs(txs); + assert.equal(approvalToken, getAddress(TOKEN)); + assert.equal(deposits.length, 3); + assert.equal( + approvalNeededBySpender.get(getAddress(POOL_A)), + 200n + ); + assert.equal( + approvalNeededBySpender.get(getAddress(POOL_B)), + 1_000n + ); + }); + + it("does not treat a payable approve-shaped call as an approve", () => { + const payable = { ...approveCall(OTHER, 1n), value: 1n }; + const { deposits, approvalNeededBySpender } = partitionShieldTxs([ + payable, + ]); + assert.equal(deposits.length, 1); + assert.equal(approvalNeededBySpender.size, 0); + }); +}); + +describe("toShieldTxs", () => { + it("accepts a raw array or a { txns } envelope", () => { + const call = depositCall(OTHER); + assert.deepEqual(toShieldTxs([call]), [call]); + assert.deepEqual(toShieldTxs({ txns: [call] }), [call]); + }); + + it("rejects an empty or unknown prepareShield shape", () => { + assert.throws(() => toShieldTxs([]), /no transactions/); + assert.throws(() => toShieldTxs({}), /Unsupported shield operation/); + }); +}); diff --git a/tests/stealth-keys.test.ts b/tests/stealth-keys.test.ts new file mode 100644 index 0000000..aada685 --- /dev/null +++ b/tests/stealth-keys.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { deriveStealthKeypair } from "../src/lib/stealth/keys.js"; +import { + STEALTH_SPENDING_PATH, + STEALTH_VIEWING_PATH, +} from "../src/lib/stealth/constants.js"; + +const MNEMONIC = + "test test test test test test test test test test test junk"; + +const MAINNET = { + spendingPrivateKey: + "0xf479f0cd06fda1a64f9396262b70f1da9091b040ffab3ea5d328cf26386efdc0", + viewingPrivateKey: + "0xae9338e966e7ba84db65064a2ac6d6f0e6ca1c00bd2853a1fb45acafea3437de", + spendingPublicKey: + "0x035c121b62d407c9a3ba672628785cd1fb07fb77aca6f31b20a4f9301dbbc051b5", + viewingPublicKey: + "0x037c69e66586e2768e8693328755e464b8b9d555e744d7c1ce2357554746ee8c4f", + stealthMetaAddress: + "0x035c121b62d407c9a3ba672628785cd1fb07fb77aca6f31b20a4f9301dbbc051b5037c69e66586e2768e8693328755e464b8b9d555e744d7c1ce2357554746ee8c4f", + stealthMetaAddressURI: + "st:eth:0x035c121b62d407c9a3ba672628785cd1fb07fb77aca6f31b20a4f9301dbbc051b5037c69e66586e2768e8693328755e464b8b9d555e744d7c1ce2357554746ee8c4f", +} as const; + +describe("deriveStealthKeypair", () => { + it("derives a stable spending/viewing pair from the junk mnemonic", () => { + const keys = deriveStealthKeypair(MNEMONIC, 1n); + assert.deepEqual(keys, MAINNET); + assert.equal(STEALTH_SPENDING_PATH, "m/44'/60'/0'/5564'/1'/0'"); + assert.equal(STEALTH_VIEWING_PATH, "m/44'/60'/0'/5564'/1'/1'"); + }); + + it("changes the URI chain prefix without changing the keys", () => { + const mainnet = deriveStealthKeypair(MNEMONIC, 1n); + const sepolia = deriveStealthKeypair(MNEMONIC, 11155111n); + assert.equal(sepolia.spendingPrivateKey, mainnet.spendingPrivateKey); + assert.equal(sepolia.viewingPrivateKey, mainnet.viewingPrivateKey); + assert.equal(sepolia.stealthMetaAddress, mainnet.stealthMetaAddress); + assert.equal( + sepolia.stealthMetaAddressURI, + "st:sep:0x035c121b62d407c9a3ba672628785cd1fb07fb77aca6f31b20a4f9301dbbc051b5037c69e66586e2768e8693328755e464b8b9d555e744d7c1ce2357554746ee8c4f" + ); + assert.notEqual(sepolia.stealthMetaAddressURI, mainnet.stealthMetaAddressURI); + }); +}); diff --git a/tests/stealth-selectors.test.ts b/tests/stealth-selectors.test.ts new file mode 100644 index 0000000..e7e677a --- /dev/null +++ b/tests/stealth-selectors.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { parseStealthStartBlock } from "../src/lib/stealth/scan.js"; +import { + formatStealthSelector, + parseStealthIndex, +} from "../src/lib/stealth/storage.js"; + +describe("parseStealthIndex", () => { + it("accepts sN and stealth:N case-insensitively", () => { + assert.equal(parseStealthIndex("s0"), 0); + assert.equal(parseStealthIndex("S1"), 1); + assert.equal(parseStealthIndex(" stealth:12 "), 12); + assert.equal(parseStealthIndex("STEALTH:3"), 3); + }); + + it("does not treat an HD index as a stealth selector", () => { + assert.equal(parseStealthIndex("0"), null); + assert.equal(parseStealthIndex("12"), null); + }); + + it("rejects malformed selectors", () => { + assert.equal(parseStealthIndex("s-1"), null); + assert.equal(parseStealthIndex("s0/1"), null); + assert.equal(parseStealthIndex("s"), null); + assert.equal(parseStealthIndex("stealth"), null); + }); +}); + +describe("formatStealthSelector", () => { + it("round-trips through parseStealthIndex", () => { + assert.equal(formatStealthSelector(0), "s0"); + assert.equal(parseStealthIndex(formatStealthSelector(7)), 7); + }); +}); + +describe("parseStealthStartBlock", () => { + it("accepts decimal and 0x-hex block numbers", () => { + assert.equal(parseStealthStartBlock("0"), 0n); + assert.equal(parseStealthStartBlock("18000000"), 18000000n); + assert.equal(parseStealthStartBlock(" 18000000 "), 18000000n); + assert.equal(parseStealthStartBlock("0x10"), 16n); + }); + + it("rejects empty and garbage values", () => { + assert.throws( + () => parseStealthStartBlock(""), + /non-empty block number/ + ); + assert.throws( + () => parseStealthStartBlock("latest"), + /decimal or 0x-hex/ + ); + assert.throws( + () => parseStealthStartBlock("-1"), + /decimal or 0x-hex/ + ); + }); +}); diff --git a/tests/stealth-storage.test.ts b/tests/stealth-storage.test.ts new file mode 100644 index 0000000..a45cfee --- /dev/null +++ b/tests/stealth-storage.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { getAddress, type Hex } from "viem"; + +import { + hasCachedStealthProfile, + makeStealthAccountsStorage, +} from "../src/lib/stealth/storage.js"; + +const ADDR = "0x9dAEf1EA5CC90C0F9DA9a5F0B49DA10510c34502"; +const ADDR2 = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +function accountFields(address: string) { + return { + address, + priv: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ephemeralPublicKey: + "0x02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as Hex, + schemeId: 1, + lastUpdated: 1, + ethBalance: "0", + erc20Balances: {}, + }; +} + +describe("makeStealthAccountsStorage", () => { + it("merges a mixed-case re-upsert and does not bump nextStealthIndex", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-stealth-")); + try { + const storage = makeStealthAccountsStorage(dir, "pw"); + const first = storage.upsertAccount(accountFields(ADDR.toLowerCase())); + assert.equal(first.stealthIndex, 0); + assert.equal(first.address, getAddress(ADDR)); + assert.equal(storage.getStore().nextStealthIndex, 1); + + const merged = storage.upsertAccount({ + ...accountFields(ADDR), + ethBalance: "42", + name: "alice.gwei", + }); + assert.equal(merged.stealthIndex, 0); + assert.equal(merged.ethBalance, "42"); + assert.equal(merged.name, "alice.gwei"); + assert.equal(storage.getStore().nextStealthIndex, 1); + assert.equal(storage.getAccounts().length, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("finds accounts by address case-insensitively", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-stealth-")); + try { + const storage = makeStealthAccountsStorage(dir, "pw"); + storage.upsertAccount(accountFields(ADDR)); + assert.equal( + storage.findByAddress(ADDR.toLowerCase())?.address, + getAddress(ADDR) + ); + assert.equal(storage.findByAddress(ADDR2), undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("treats a missing or cleared profile as not cached", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-stealth-")); + try { + const storage = makeStealthAccountsStorage(dir, "pw"); + assert.equal(hasCachedStealthProfile(storage.getStore()), false); + + storage.setProfile({ + name: "alice.gwei", + index: 0, + address: ADDR, + stealthMetaAddressURI: "st:eth:0x01", + }); + assert.equal(hasCachedStealthProfile(storage.getStore()), true); + assert.equal(storage.getStore().name, "alice.gwei"); + + storage.clearProfile(); + assert.equal(storage.getStore().profile, null); + assert.equal(storage.getStore().name, undefined); + assert.equal(hasCachedStealthProfile(storage.getStore()), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/tokens-util.test.ts b/tests/tokens-util.test.ts new file mode 100644 index 0000000..b75fc8b --- /dev/null +++ b/tests/tokens-util.test.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getAddress } from "viem"; + +import { mapPrivateBalanceRows } from "../src/lib/private-balance-rows.js"; +import { ETH_AS_ERC20 } from "../src/utils/plugins.js"; +import { + isPendingPrivateBalanceRow, + isPrivateBalanceNativeEth, + isSpendablePrivateBalanceTag, + mergeDefaultAndExtraErc20s, + privateBalanceRowMatchesUnshieldToken, + privateBalanceStatusLabel, + wethAddressForChain, +} from "../src/utils/tokens-util.js"; + +const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + +describe("isPrivateBalanceNativeEth", () => { + it("recognizes the EEE… sentinel and zero-address variants", () => { + assert.equal(isPrivateBalanceNativeEth(ETH_AS_ERC20), true); + assert.equal(isPrivateBalanceNativeEth(ETH_AS_ERC20.toUpperCase()), true); + assert.equal( + isPrivateBalanceNativeEth("0x0000000000000000000000000000000000000000"), + true + ); + assert.equal(isPrivateBalanceNativeEth("0x00"), true); + assert.equal(isPrivateBalanceNativeEth(DAI), false); + assert.equal(isPrivateBalanceNativeEth("eth"), false); + }); +}); + +describe("private balance tags", () => { + it("treats missing/Valid as spendable and Missing as pending", () => { + assert.equal(isSpendablePrivateBalanceTag(undefined), true); + assert.equal(isSpendablePrivateBalanceTag("Valid"), true); + assert.equal(isSpendablePrivateBalanceTag("Missing"), false); + assert.equal(isSpendablePrivateBalanceTag("pending"), false); + assert.equal(isPendingPrivateBalanceRow({}), false); + assert.equal(isPendingPrivateBalanceRow({ tag: "Missing" }), true); + assert.equal(privateBalanceStatusLabel("Missing"), "pending"); + assert.equal(privateBalanceStatusLabel("Valid"), "spendable"); + }); +}); + +describe("privateBalanceRowMatchesUnshieldToken", () => { + it("matches Railgun ETH against WETH, including mixed case", () => { + const weth = wethAddressForChain(1n)!; + assert.equal( + privateBalanceRowMatchesUnshieldToken( + weth.toLowerCase(), + { isEth: true, tokenAddress: ETH_AS_ERC20 }, + 1n, + "railgun" + ), + true + ); + assert.equal( + privateBalanceRowMatchesUnshieldToken( + ETH_AS_ERC20, + { isEth: true, tokenAddress: ETH_AS_ERC20 }, + 1n, + "railgun" + ), + false + ); + }); + + it("matches Tornado ETH against the native sentinel, not WETH", () => { + const weth = wethAddressForChain(1n)!; + assert.equal( + privateBalanceRowMatchesUnshieldToken( + ETH_AS_ERC20, + { isEth: true, tokenAddress: ETH_AS_ERC20 }, + 1n, + "tornado" + ), + true + ); + assert.equal( + privateBalanceRowMatchesUnshieldToken( + weth, + { isEth: true, tokenAddress: ETH_AS_ERC20 }, + 1n, + "tornado" + ), + false + ); + }); + + it("matches ERC-20 rows case-insensitively", () => { + assert.equal( + privateBalanceRowMatchesUnshieldToken( + DAI.toLowerCase(), + { isEth: false, tokenAddress: DAI }, + 1n + ), + true + ); + assert.equal( + privateBalanceRowMatchesUnshieldToken( + USDC, + { isEth: false, tokenAddress: DAI }, + 1n + ), + false + ); + }); +}); + +describe("mapPrivateBalanceRows", () => { + it("pads a bigint contract to 40 hex chars and looks up checksummed meta", () => { + const daiBig = BigInt(DAI); + const rows = mapPrivateBalanceRows( + [ + { + asset: { __type: "erc20", contract: daiBig }, + amount: 1_000n, + } as never, + ], + new Map([[DAI.toLowerCase(), { symbol: "DAI", decimals: 18 }]]) + ); + assert.equal(rows[0]!.symbol, "DAI"); + assert.equal(rows[0]!.token_address, getAddress(DAI)); + assert.equal(rows[0]!.raw_token_holdings, "1000"); + assert.equal(rows[0]!.status, "spendable"); + }); + + it("marks a pending native-ETH row", () => { + const rows = mapPrivateBalanceRows( + [ + { + asset: { __type: "erc20", contract: ETH_AS_ERC20 }, + amount: 1n, + tag: "Missing", + } as never, + ], + new Map() + ); + assert.equal(rows[0]!.symbol, "ETH (pending)"); + assert.equal(rows[0]!.token_address, "---"); + assert.equal(rows[0]!.status, "pending"); + }); +}); + +describe("mergeDefaultAndExtraErc20s", () => { + it("dedupes mixed-case extras against chain defaults", () => { + const merged = mergeDefaultAndExtraErc20s("1", [ + USDC.toLowerCase() as `0x${string}`, + "0x1111111111111111111111111111111111111111", + ]); + const usdcCount = merged.erc20Addresses.filter( + (a) => a.toLowerCase() === USDC.toLowerCase() + ).length; + assert.equal(usdcCount, 1); + assert.ok( + merged.erc20Addresses.some( + (a) => a.toLowerCase() === "0x1111111111111111111111111111111111111111" + ) + ); + assert.equal(merged.knownMetaByLower.get(USDC.toLowerCase())?.symbol, "USDC"); + }); +}); diff --git a/tests/tornado-paymaster-gas.test.ts b/tests/tornado-paymaster-gas.test.ts new file mode 100644 index 0000000..1dcec61 --- /dev/null +++ b/tests/tornado-paymaster-gas.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + TAIL_FORWARD_FEE_PAD_DEN, + TAIL_FORWARD_FEE_PAD_NUM, + estimateTornadoPaymasterFee, + padTornadoTailForwardFee, + tornadoWithdrawalCallGasLimit, +} from "../src/utils/tornado-paymaster-gas.js"; +import { withTailCallsGasOverhead } from "../src/utils/tornado-tail-gas.js"; + +describe("tornadoWithdrawalCallGasLimit", () => { + it("defaults extra-note gas to the 300k execution-tail baseline", () => { + assert.equal(tornadoWithdrawalCallGasLimit(0), 300_000n); + assert.equal(tornadoWithdrawalCallGasLimit(1), 700_000n); + assert.equal(tornadoWithdrawalCallGasLimit(2), 1_100_000n); + }); + + it("uses the ERC-20 per-withdraw constant when isERC20", () => { + assert.equal(tornadoWithdrawalCallGasLimit(1, undefined, true), 800_000n); + assert.equal(tornadoWithdrawalCallGasLimit(2, undefined, true), 1_300_000n); + }); + + it("adds a measured execution tail instead of the 300k default", () => { + assert.equal(tornadoWithdrawalCallGasLimit(0, 50_000n), 50_000n); + assert.equal(tornadoWithdrawalCallGasLimit(1, 50_000n, true), 550_000n); + }); + + it("treats a negative extra-withdrawals count as zero extra notes", () => { + assert.equal(tornadoWithdrawalCallGasLimit(-3), 300_000n); + }); +}); + +describe("estimateTornadoPaymasterFee", () => { + it("applies the SDK 1.2× safety margin to ETH withdrawal gas", () => { + // 50k + 300k + 350k + 80k + 50k = 830k + assert.equal(estimateTornadoPaymasterFee(1n), (830_000n * 12n) / 10n); + }); + + it("adds ERC-20 transfer gas onto paymaster verification", () => { + // 50k + 300k + 450k + 80k + 50k = 930k + assert.equal( + estimateTornadoPaymasterFee(1n, { isERC20: true }), + (930_000n * 12n) / 10n + ); + }); + + it("uses an explicit callGasLimit when provided", () => { + assert.equal( + estimateTornadoPaymasterFee(1n, { callGasLimit: 1_000_000n }), + ((50_000n + 1_000_000n + 350_000n + 80_000n + 50_000n) * 12n) / 10n + ); + }); +}); + +describe("padTornadoTailForwardFee", () => { + it("pads 23/20 (15%), not the 1.2× gas margin", () => { + assert.equal(TAIL_FORWARD_FEE_PAD_NUM, 23n); + assert.equal(TAIL_FORWARD_FEE_PAD_DEN, 20n); + assert.equal(padTornadoTailForwardFee(1000n), 1150n); + assert.notEqual(padTornadoTailForwardFee(1000n), (1000n * 12n) / 10n); + }); +}); + +describe("withTailCallsGasOverhead", () => { + it("adds 10% headroom on measured execution-tail gas", () => { + assert.equal(withTailCallsGasOverhead(1000n), 1100n); + assert.equal(withTailCallsGasOverhead(0n), 0n); + }); +}); diff --git a/tests/tornado-pools.test.ts b/tests/tornado-pools.test.ts new file mode 100644 index 0000000..977c686 --- /dev/null +++ b/tests/tornado-pools.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + assertTornadoDepositAmount, + assertTornadoExactPoolDenomination, + assertTornadoTokenSupported, + assertTornadoUnshieldAmountForToken, + tornadoPaymasterPoolsForAsset, + tornadoPoolsForAsset, + tornadoPoolsForChain, +} from "../src/utils/tornado-pools.js"; + +const ETH = { + isEth: true, + tokenAddress: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + symbol: "ETH", + decimals: 18, +}; +const DAI = { + isEth: false, + tokenAddress: "0x6B175474E89094C44Da98b954EedeAC495271d0F", + symbol: "DAI", + decimals: 18, +}; +const USDC = { + isEth: false, + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + symbol: "USDC", + decimals: 6, +}; +const UNKNOWN = { + isEth: false, + tokenAddress: "0x0000000000000000000000000000000000000001", + symbol: "FAKE", + decimals: 18, +}; + +const ETH_01 = 100_000_000_000_000_000n; // 0.1 +const ETH_1 = 1_000_000_000_000_000_000n; +const ETH_02 = 200_000_000_000_000_000n; // 0.2 — multiple of 0.1, not an exact pool +const USDC_100 = 100_000_000n; +const USDC_200 = 200_000_000n; + +describe("tornadoPoolsForChain", () => { + it("returns mainnet and Sepolia catalogs and nothing else", () => { + assert.ok(tornadoPoolsForChain(1n).length > 0); + assert.ok(tornadoPoolsForChain(11155111n).length > 0); + assert.deepEqual(tornadoPoolsForChain(10n), []); + }); +}); + +describe("tornadoPoolsForAsset", () => { + it("matches ETH vs ERC-20, including mixed-case token addresses", () => { + const eth = tornadoPoolsForAsset(1n, { isEth: true }); + assert.ok(eth.every((p) => !p.isERC20)); + assert.ok(eth.some((p) => p.denomination === ETH_01)); + + const dai = tornadoPoolsForAsset(1n, { + isEth: false, + tokenAddress: DAI.tokenAddress.toLowerCase(), + }); + assert.ok(dai.length > 0); + assert.ok(dai.every((p) => p.asset === "DAI")); + + const usdc = tornadoPoolsForAsset(1n, { + isEth: false, + tokenAddress: USDC.tokenAddress, + }); + assert.ok(usdc.every((p) => p.decimals === 6)); + }); +}); + +describe("assertTornadoTokenSupported", () => { + it("throws for an unknown token or chain with no pools", () => { + assert.throws( + () => assertTornadoTokenSupported(1n, UNKNOWN), + /not in the Tornado Cash pool catalog/ + ); + assert.throws( + () => assertTornadoTokenSupported(10n, ETH), + /no ETH pools/ + ); + }); +}); + +describe("assertTornadoDepositAmount", () => { + it("accepts a positive multiple of the smallest ETH denomination", () => { + assert.doesNotThrow(() => assertTornadoDepositAmount(1n, ETH_01, ETH)); + assert.doesNotThrow(() => assertTornadoDepositAmount(1n, ETH_1, ETH)); + assert.doesNotThrow(() => assertTornadoDepositAmount(1n, ETH_02, ETH)); + }); + + it("rejects amounts that are not a multiple of the smallest pool", () => { + assert.throws( + () => assertTornadoDepositAmount(1n, ETH_01 / 2n, ETH), + /exact multiple of 0.1 ETH/ + ); + }); + + it("uses 6-decimal USDC denominations", () => { + assert.doesNotThrow(() => assertTornadoDepositAmount(1n, USDC_100, USDC)); + assert.doesNotThrow(() => assertTornadoDepositAmount(1n, USDC_200, USDC)); + assert.throws( + () => assertTornadoDepositAmount(1n, 50_000_000n, USDC), + /exact multiple of 100 USDC/ + ); + }); + + it("rejects zero", () => { + assert.throws( + () => assertTornadoDepositAmount(1n, 0n, ETH), + /greater than zero/ + ); + }); +}); + +describe("assertTornadoExactPoolDenomination", () => { + it("accepts an exact pool size and rejects a mere multiple", () => { + assert.equal( + assertTornadoExactPoolDenomination(1n, ETH_1, ETH).denomination, + ETH_1 + ); + assert.throws( + () => assertTornadoExactPoolDenomination(1n, ETH_02, ETH), + /not an exact Tornado pool denomination/ + ); + }); +}); + +describe("assertTornadoUnshieldAmountForToken", () => { + it("accepts a multiple of the smallest paymaster-backed denomination", () => { + assert.doesNotThrow(() => + assertTornadoUnshieldAmountForToken(1n, ETH_02, ETH) + ); + assert.doesNotThrow(() => + assertTornadoUnshieldAmountForToken(1n, USDC_100, USDC) + ); + }); + + it("rejects a non-multiple of the paymaster-backed minimum", () => { + assert.throws( + () => assertTornadoUnshieldAmountForToken(1n, ETH_01 / 2n, ETH), + /smallest paymaster-backed pool denomination/ + ); + }); +}); + +describe("tornadoPaymasterPoolsForAsset", () => { + it("is a subset of the deposit catalog", () => { + const deposit = tornadoPoolsForAsset(1n, { isEth: true }); + const paymaster = tornadoPaymasterPoolsForAsset(1n, { isEth: true }); + assert.ok(paymaster.length > 0); + const depositAddrs = new Set(deposit.map((p) => p.poolAddress.toLowerCase())); + for (const p of paymaster) { + assert.ok(depositAddrs.has(p.poolAddress.toLowerCase())); + } + }); +}); diff --git a/tests/transfer-max.test.ts b/tests/transfer-max.test.ts new file mode 100644 index 0000000..0cb64e1 --- /dev/null +++ b/tests/transfer-max.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { transferMaxAmountFromBalance } from "../src/utils/transfer-max.js"; + +describe("transferMaxAmountFromBalance", () => { + it("returns the full ERC-20 balance", () => { + assert.equal( + transferMaxAmountFromBalance(1_000n, { isEth: false }), + 1_000n + ); + }); + + it("floors a non-positive ERC-20 balance at 0", () => { + assert.equal(transferMaxAmountFromBalance(0n, { isEth: false }), 0n); + }); + + it("subtracts the ETH gas reserve from native balance", () => { + assert.equal( + transferMaxAmountFromBalance(1_000n, { + isEth: true, + ethGasReserveWei: 210n, + }), + 790n + ); + }); + + it("returns 0 when ETH cannot cover the gas reserve", () => { + assert.equal( + transferMaxAmountFromBalance(100n, { + isEth: true, + ethGasReserveWei: 100n, + }), + 0n + ); + assert.equal( + transferMaxAmountFromBalance(50n, { + isEth: true, + ethGasReserveWei: 100n, + }), + 0n + ); + }); + + it("treats a missing ETH reserve as 0", () => { + assert.equal(transferMaxAmountFromBalance(500n, { isEth: true }), 500n); + }); +}); diff --git a/tests/unshield-amount.test.ts b/tests/unshield-amount.test.ts new file mode 100644 index 0000000..b0213a3 --- /dev/null +++ b/tests/unshield-amount.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + parseUnshieldAmount, + privacyPoolsRelayerFeeWei, +} from "../src/lib/unshield-flow.js"; + +describe("parseUnshieldAmount", () => { + it("accepts max regardless of case", () => { + assert.equal(parseUnshieldAmount("max", 18, 123n), 123n); + assert.equal(parseUnshieldAmount("MAX", 18, 123n), 123n); + assert.equal(parseUnshieldAmount(" Max ", 18, 123n), 123n); + }); + + it("throws when max is requested but the hint is missing", () => { + assert.throws( + () => parseUnshieldAmount("max", 18, 0n), + /Could not determine max unshield amount/ + ); + }); + + it("parses a human amount at 18 decimals", () => { + assert.equal(parseUnshieldAmount("1.5", 18, 0n), 1_500_000_000_000_000_000n); + }); + + it("parses a human amount at 6 decimals (USDC)", () => { + assert.equal(parseUnshieldAmount("1.5", 6, 0n), 1_500_000n); + }); + + it("rejects zero and negative-looking zero", () => { + assert.throws(() => parseUnshieldAmount("0", 18, 0n), /greater than zero/); + assert.throws(() => parseUnshieldAmount("0.0", 6, 0n), /greater than zero/); + }); + + it("rejects an amount above the max hint when a hint is present", () => { + assert.throws( + () => parseUnshieldAmount("2", 18, 1_000_000_000_000_000_000n), + /exceeds maximum/ + ); + }); + + it("allows an exact max-hint amount", () => { + const cap = 1_000_000n; + assert.equal(parseUnshieldAmount("1", 6, cap), cap); + }); +}); + +describe("privacyPoolsRelayerFeeWei", () => { + it("returns null when the prepared op has no relay fee", () => { + assert.equal(privacyPoolsRelayerFeeWei({}, 10_000n), null); + assert.equal(privacyPoolsRelayerFeeWei(null, 10_000n), null); + }); + + it("computes amount * bps / 10000", () => { + const prepared = { rawData: { relayData: { relayFeeBps: 25 } } }; + assert.deepEqual(privacyPoolsRelayerFeeWei(prepared, 10_000n), { + relayFeeBps: 25n, + feeWei: 25n, + }); + }); + + it("accepts a string relayFeeBps from JSON-like payloads", () => { + const prepared = { rawData: { relayData: { relayFeeBps: "100" } } }; + assert.deepEqual(privacyPoolsRelayerFeeWei(prepared, 10_000n), { + relayFeeBps: 100n, + feeWei: 100n, + }); + }); +}); diff --git a/tests/unshield-tail-calls.test.ts b/tests/unshield-tail-calls.test.ts new file mode 100644 index 0000000..ac407d5 --- /dev/null +++ b/tests/unshield-tail-calls.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getAddress } from "viem"; + +import { parseTailCalls } from "../src/utils/unshield-tail-calls.js"; + +const TARGET = "0x9dAEf1EA5CC90C0F9DA9a5F0B49DA10510c34502"; +const TARGET_LOWER = TARGET.toLowerCase(); + +describe("parseTailCalls", () => { + it("parses TARGET:CALLDATA with a zero value", () => { + assert.deepEqual(parseTailCalls(`${TARGET}:0x`), [ + { to: TARGET, data: "0x", value: 0n }, + ]); + }); + + it("checksums a lowercase target", () => { + const [call] = parseTailCalls(`${TARGET_LOWER}:0xaabb`); + assert.equal(call!.to, getAddress(TARGET_LOWER)); + assert.equal(call!.data, "0xaabb"); + assert.equal(call!.value, 0n); + }); + + it("parses decimal wei and 0x-hex values", () => { + assert.equal( + parseTailCalls(`${TARGET}:0x:1000`)[0]!.value, + 1000n + ); + assert.equal( + parseTailCalls(`${TARGET}:0x:0x64`)[0]!.value, + 100n + ); + }); + + it("parses comma-separated calls", () => { + const calls = parseTailCalls(`${TARGET}:0x,${TARGET}:0xdead:1`); + assert.equal(calls.length, 2); + assert.equal(calls[1]!.data, "0xdead"); + assert.equal(calls[1]!.value, 1n); + }); + + it("rejects empty entries", () => { + assert.throws(() => parseTailCalls(""), /comma-separated/); + assert.throws(() => parseTailCalls(`${TARGET}:0x,`), /comma-separated/); + }); + + it("rejects a non-address target", () => { + assert.throws( + () => parseTailCalls("not-an-address:0x"), + /Invalid tail call target at index 0/ + ); + }); + + it("rejects odd-length or non-0x calldata", () => { + assert.throws( + () => parseTailCalls(`${TARGET}:0xabc`), + /byte-aligned hex/ + ); + assert.throws( + () => parseTailCalls(`${TARGET}:deadbeef`), + /byte-aligned hex/ + ); + }); + + it("rejects extra colons and missing slots", () => { + assert.throws( + () => parseTailCalls(`${TARGET}:0x:1:2`), + /TARGET:CALLDATA or TARGET:CALLDATA:VALUE/ + ); + assert.throws( + () => parseTailCalls(`${TARGET}:`), + /TARGET:CALLDATA or TARGET:CALLDATA:VALUE/ + ); + }); + + it("rejects a garbage value", () => { + assert.throws( + () => parseTailCalls(`${TARGET}:0x:-1`), + /expected 0x-hex or decimal wei/ + ); + assert.throws( + () => parseTailCalls(`${TARGET}:0x:1.5`), + /expected 0x-hex or decimal wei/ + ); + }); +}); diff --git a/tests/wallets-util-path.test.ts b/tests/wallets-util-path.test.ts new file mode 100644 index 0000000..f3ba8b6 --- /dev/null +++ b/tests/wallets-util-path.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + parseRequiredWalletName, + resolveWalletDir, + walletNameToDirSegment, +} from "../src/utils/wallets-util.js"; + +describe("parseRequiredWalletName", () => { + it("trims and treats blank as missing", () => { + assert.equal(parseRequiredWalletName(" alice "), "alice"); + assert.equal(parseRequiredWalletName(undefined), null); + assert.equal(parseRequiredWalletName(" "), null); + }); +}); + +describe("walletNameToDirSegment", () => { + it("replaces unsafe characters without allowing path traversal", () => { + assert.equal(walletNameToDirSegment("alice"), "alice"); + assert.equal(walletNameToDirSegment("My Wallet"), "My_Wallet"); + assert.equal(walletNameToDirSegment("../etc/passwd"), ".._etc_passwd"); + assert.equal(walletNameToDirSegment("/tmp/foo"), "tmp_foo"); + }); + + it("rejects empty or punctuation-only names", () => { + assert.throws(() => walletNameToDirSegment(""), /cannot be empty/); + assert.throws( + () => walletNameToDirSegment(" "), + /cannot be empty/ + ); + assert.throws( + () => walletNameToDirSegment("///"), + /must contain at least one letter/ + ); + }); +}); + +describe("resolveWalletDir", () => { + it("joins the sanitized segment onto the data dir", () => { + const dir = resolveWalletDir("/data", "My Wallet"); + assert.ok(dir.endsWith("My_Wallet")); + assert.equal(dir.includes("My Wallet"), false); + }); +}); From 841bc7c19d870c5788bc4a6ca91f5f1a2694ff41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=BA=CE=B1=CF=83=CF=83=CE=AC=CE=BD=CE=B4=CF=81=CE=B1=2Ee?= =?UTF-8?q?th?= <0xDADA@protonmail.com> Date: Sat, 22 Aug 2026 13:15:36 -0400 Subject: [PATCH 2/2] fix: typecheck missing rpcUrl --- .github/workflows/ci.yml | 4 +- src/commands/shield.ts | 7 ++ src/commands/transact-raw.ts | 8 ++ src/commands/transfer.ts | 177 ++++++++++++++++++----------------- src/lib/shield-flow.ts | 3 + 5 files changed, 110 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c0d7b6..37be81e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 with: node-version: "22" cache: npm diff --git a/src/commands/shield.ts b/src/commands/shield.ts index 61e4be0..039e9a8 100644 --- a/src/commands/shield.ts +++ b/src/commands/shield.ts @@ -539,6 +539,11 @@ export function registerShieldCommand(program: Command): void { quietNonInteractive(opts.nonInteractive) ); const quiet = quietNonInteractive(opts.nonInteractive); + const eip7702Tor = { + rpcUrl, + walletDir, + withoutTor: !!opts.withoutTor, + }; const broadcastTransactions: BroadcastTxResultJson[] = []; try { await withTor( @@ -653,6 +658,7 @@ export function registerShieldCommand(program: Command): void { senderAddress, calls, privateKey: senderPrivateKey, + ...eip7702Tor, }); } else { const call = calls[0]!; @@ -759,6 +765,7 @@ export function registerShieldCommand(program: Command): void { privateKey: senderPrivateKey, chainId, calls, + ...eip7702Tor, }), (t) => `UserOp mined: ${t.txHash}${ diff --git a/src/commands/transact-raw.ts b/src/commands/transact-raw.ts index c8fb258..89e9899 100644 --- a/src/commands/transact-raw.ts +++ b/src/commands/transact-raw.ts @@ -389,6 +389,11 @@ export function registerTransactRawCommand(program: Command): void { value: tx.value.toString(), })); const batchAsUserOp = rawTxs.length > 1; + const eip7702Tor = { + rpcUrl, + walletDir, + withoutTor: !!opts.withoutTor, + }; // Single EOA: eth_call each tx. Multi-call UserOp: skip isolated eth_calls — // later payloads often depend on earlier ones (e.g. approve then spend). @@ -423,6 +428,7 @@ export function registerTransactRawCommand(program: Command): void { senderAddress, calls: rawTxs, privateKey: senderPrivateKey, + ...eip7702Tor, }); } else { const parts: FeePreview[] = []; @@ -490,6 +496,7 @@ export function registerTransactRawCommand(program: Command): void { senderAddress, calls: rawTxs, privateKey: senderPrivateKey, + ...eip7702Tor, }); await maybeConfirm( !!opts.nonInteractive, @@ -509,6 +516,7 @@ export function registerTransactRawCommand(program: Command): void { privateKey: senderPrivateKey, chainId, calls: rawTxs, + ...eip7702Tor, }), (t) => `UserOp mined: ${t.txHash}${ diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index 5bfd809..a2c41ea 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -680,9 +680,8 @@ export function registerTransferCommand(program: Command): void { return; } - let fees: FeePreview; if (!batchAsUserOp) { - fees = await estimateEoaTxFeePreview( + const fees = await estimateEoaTxFeePreview( client, { to: txs[0]!.to, @@ -696,10 +695,9 @@ export function registerTransferCommand(program: Command): void { if (dryRun) { if (opts.nonInteractive) { logCliJson({ - stealth: !!stealthPlan, + stealth: false, stealthMetaAddressURI: stealthMetaURI ?? undefined, recipient: recipientAddress, - ephemeralPublicKey: stealthPlan?.ephemeralPublicKey, amount: amount.toString(), token: tokenMeta.isEth ? "eth" : tokenMeta.tokenAddress, fees, @@ -719,119 +717,124 @@ export function registerTransferCommand(program: Command): void { } return; } - } - if (!senderPrivateKey) { - cliError( - "Cannot sign: no private key for this --from (use a saved public/stealth account or --from-priv with --broadcast)." - ); - return; - } + if (!senderPrivateKey) { + cliError( + "Cannot sign: no private key for this --from (use a saved public/stealth account or --from-priv with --broadcast)." + ); + return; + } - if (batchAsUserOp) { - let fees: FeePreview; - const sent = await withTor( - !opts.withoutTor, - { rpcUrl, walletDir }, - async () => { - fees = await estimateEip7702BatchUserOpFee({ - client, - chainId, - senderAddress, - calls: txs, - privateKey: senderPrivateKey, - ...eip7702Tor, - }); - await maybeConfirm( - !!opts.nonInteractive, - `Submit stealth transfer of ${amountPreview} as one EIP-7702 UserOp (transfer + announce) from ${senderAddress}?\n ${feeConfirmLine(fees)}` - ); + await maybeConfirm( + !!opts.nonInteractive, + `Send transfer: ${amountPreview} from ${senderAddress} to ${recipientAddress}?\n ${feeConfirmLine(fees)}` + ); - return runQuietSpinner( - quiet, - txSpinner, - { - start: - "Submitting EIP-7702 stealth UserOp (transfer + announce)...", - failure: "EIP-7702 stealth UserOp failed.", - }, - async () => - sendEip7702BatchUserOperation({ - client, - privateKey: senderPrivateKey, - chainId, - calls: txs, - ...eip7702Tor, - }), - (t) => - `UserOp mined: ${t.txHash}${ - t.delegatedInUserOp ? " (delegation included)" : "" - }` - ); - } + const walletClient = makeWalletClient(senderPrivateKey, client, rpcUrl); + const hash = await runQuietSpinner( + quiet, + txSpinner, + { start: "Sending transfer...", failure: "Transfer failed." }, + async () => + sendTransactionAndWait(walletClient, client, { + to: txs[0]!.to, + data: txs[0]!.data, + value: txs[0]!.value, + }), + (h) => `Mined: ${h}` ); if (opts.nonInteractive) { logCliJson({ - stealth: true, - mode: "eip7702-userop", - implementation: sent.implementation, - delegation: sent.delegatedInUserOp - ? "included-in-userop" - : "already-set", - userOpHash: sent.userOpHash, - txHash: sent.txHash, - explorer: etherscanTxUrl(chainId, sent.txHash), + stealth: false, + hashes: [hash], + explorers: [etherscanTxUrl(chainId, hash)], from: senderAddress, to: recipientAddress, - stealthMetaAddressURI: stealthMetaURI ?? undefined, - ephemeralPublicKey: stealthPlan?.ephemeralPublicKey, amount: amount.toString(), token: tokenMeta.isEth ? "eth" : tokenMeta.tokenAddress, - calls: payloads, - fees: fees!, }); } else { console.log(); - console.log(chalk.green("✔ Stealth transfer complete (batched UserOp).")); - console.log(chalk.dim(etherscanTxUrl(chainId, sent.txHash))); + console.log(chalk.green("✔ Transfer complete.")); + console.log(chalk.dim(etherscanTxUrl(chainId, hash))); } return; } - await maybeConfirm( - !!opts.nonInteractive, - `Send transfer: ${amountPreview} from ${senderAddress} to ${recipientAddress}?\n ${feeConfirmLine(fees)}` - ); + if (!senderPrivateKey) { + cliError( + "Cannot sign: no private key for this --from (use a saved public/stealth account or --from-priv with --broadcast)." + ); + return; + } - const walletClient = makeWalletClient(senderPrivateKey, client, rpcUrl); - const hash = await runQuietSpinner( - quiet, - txSpinner, - { start: "Sending transfer...", failure: "Transfer failed." }, - async () => - sendTransactionAndWait(walletClient, client, { - to: txs[0]!.to, - data: txs[0]!.data, - value: txs[0]!.value, - }), - (h) => `Mined: ${h}` + let fees: FeePreview; + const sent = await withTor( + !opts.withoutTor, + { rpcUrl, walletDir }, + async () => { + fees = await estimateEip7702BatchUserOpFee({ + client, + chainId, + senderAddress, + calls: txs, + privateKey: senderPrivateKey, + ...eip7702Tor, + }); + await maybeConfirm( + !!opts.nonInteractive, + `Submit stealth transfer of ${amountPreview} as one EIP-7702 UserOp (transfer + announce) from ${senderAddress}?\n ${feeConfirmLine(fees)}` + ); + + return runQuietSpinner( + quiet, + txSpinner, + { + start: + "Submitting EIP-7702 stealth UserOp (transfer + announce)...", + failure: "EIP-7702 stealth UserOp failed.", + }, + async () => + sendEip7702BatchUserOperation({ + client, + privateKey: senderPrivateKey, + chainId, + calls: txs, + ...eip7702Tor, + }), + (t) => + `UserOp mined: ${t.txHash}${ + t.delegatedInUserOp ? " (delegation included)" : "" + }` + ); + } ); if (opts.nonInteractive) { logCliJson({ - stealth: false, - hashes: [hash], - explorers: [etherscanTxUrl(chainId, hash)], + stealth: true, + mode: "eip7702-userop", + implementation: sent.implementation, + delegation: sent.delegatedInUserOp + ? "included-in-userop" + : "already-set", + userOpHash: sent.userOpHash, + txHash: sent.txHash, + explorer: etherscanTxUrl(chainId, sent.txHash), from: senderAddress, to: recipientAddress, + stealthMetaAddressURI: stealthMetaURI ?? undefined, + ephemeralPublicKey: stealthPlan.ephemeralPublicKey, amount: amount.toString(), token: tokenMeta.isEth ? "eth" : tokenMeta.tokenAddress, + calls: payloads, + fees: fees!, }); } else { console.log(); - console.log(chalk.green("✔ Transfer complete.")); - console.log(chalk.dim(etherscanTxUrl(chainId, hash))); + console.log(chalk.green("✔ Stealth transfer complete (batched UserOp).")); + console.log(chalk.dim(etherscanTxUrl(chainId, sent.txHash))); } } catch (e) { cliErrorFromCaught(e); diff --git a/src/lib/shield-flow.ts b/src/lib/shield-flow.ts index 685eed5..ff247a5 100644 --- a/src/lib/shield-flow.ts +++ b/src/lib/shield-flow.ts @@ -664,6 +664,9 @@ export async function broadcastShield(opts: { privateKey: sender.senderPrivateKey, chainId: opts.chainId, calls, + rpcUrl: opts.rpcUrl, + walletDir: opts.walletDir, + withoutTor: opts.withoutTor, }); return [ {