Merge pull request #7 from FilipBl4gojevic/feat/signer-package
Local signer: non-custodial signing companion (packages/signer)
This commit is contained in:
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -43,6 +43,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=12288
|
NODE_OPTIONS: --max-old-space-size=12288
|
||||||
|
|
||||||
|
- name: Signer smoke test
|
||||||
|
run: npm run test:smoke:signer
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=12288
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,6 +38,7 @@ mnemonic.txt
|
|||||||
|
|
||||||
# Runtime state (contains transaction history)
|
# Runtime state (contains transaction history)
|
||||||
vector-audit-log.json
|
vector-audit-log.json
|
||||||
|
vector-signer-audit-log.json
|
||||||
|
|
||||||
# Incremental typecheck cache (npm run typecheck uses --incremental)
|
# Incremental typecheck cache (npm run typecheck uses --incremental)
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -30,6 +30,21 @@ claude mcp add --transport sse vector-mcp https://mcp.vector.mainnet.apexfusion.
|
|||||||
|
|
||||||
Self-hosting instructions are below.
|
Self-hosting instructions are below.
|
||||||
|
|
||||||
|
## Local signer (non-custodial path)
|
||||||
|
|
||||||
|
`packages/signer` is a local MCP server that holds your key and signs transactions locally
|
||||||
|
instead of handing your mnemonic to a shared host. It has **no network access at all** —
|
||||||
|
stdio transport, no Provider, no egress — so a key given to it never reaches a shared server
|
||||||
|
or your model provider.
|
||||||
|
|
||||||
|
See [`packages/signer/README.md`](packages/signer/README.md) for configuration, its tools, and
|
||||||
|
known limitations — including the gap that matters most right now: the hosted builder's tools
|
||||||
|
above still require your mnemonic as a call parameter, so pairing the signer with *this*
|
||||||
|
hosted builder does not yet remove the custody exposure described above. Closing that gap is
|
||||||
|
the next stage of the migration described in
|
||||||
|
[`docs/architecture/non-custodial-split.md`](docs/architecture/non-custodial-split.md); until
|
||||||
|
it ships, the security notice above still applies.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Wallet management** - derive addresses from mnemonic, query balances and UTxOs
|
- **Wallet management** - derive addresses from mnemonic, query balances and UTxOs
|
||||||
@@ -194,6 +209,24 @@ npm run test:integration
|
|||||||
|
|
||||||
Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Never runs in CI.
|
Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Never runs in CI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:smoke:signer
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds the local signer (`packages/signer`), boots it over stdio, and exercises all four of
|
||||||
|
its tools, including the fail-closed audit-write-failure path. No wallet, no network. CI runs
|
||||||
|
this on every PR alongside the two smoke tests above.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export VECTOR_SIGNER_MNEMONIC_FILE=/absolute/path/to/testnet-mnemonic.txt
|
||||||
|
npm run test:integration:signer
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires a **funded Vector testnet** wallet. Builds a real unsigned transaction the way the
|
||||||
|
hosted builder does (no key), then has the local signer decode, policy-check, and sign it
|
||||||
|
against live chain data. **Never submits.** Never runs in CI. See
|
||||||
|
[`packages/signer/README.md`](packages/signer/README.md#testing).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -42,6 +42,10 @@
|
|||||||
"resolved": "packages/shared",
|
"resolved": "packages/shared",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@apexfusion/vector-mcp-signer": {
|
||||||
|
"resolved": "packages/signer",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/checksums": {
|
"node_modules/@aws-sdk/checksums": {
|
||||||
"version": "3.1000.20",
|
"version": "3.1000.20",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.20.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.20.tgz",
|
||||||
@@ -7893,6 +7897,22 @@
|
|||||||
"@noble/hashes": "^1.7.1",
|
"@noble/hashes": "^1.7.1",
|
||||||
"cross-fetch": "^4.0.0"
|
"cross-fetch": "^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/signer": {
|
||||||
|
"name": "@apexfusion/vector-mcp-signer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@anastasia-labs/cardano-multiplatform-lib-nodejs": "6.0.2-3",
|
||||||
|
"@lucid-evolution/wallet": "^0.1.72",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vector-mcp-signer": "build/index.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@apexfusion/vector-mcp-shared": "*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs 2>/dev/null || true",
|
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs 2>/dev/null || true",
|
||||||
"clean": "rm -rf packages/*/build",
|
"clean": "rm -rf packages/*/build",
|
||||||
"build": "npm run build --workspace=@apexfusion/vector-mcp-builder",
|
"build": "npm run build --workspace=@apexfusion/vector-mcp-builder && npm run build --workspace=@apexfusion/vector-mcp-signer",
|
||||||
"typecheck": "tsc --noEmit -p packages/shared && tsc --noEmit -p packages/builder",
|
"typecheck": "tsc --noEmit -p packages/shared && tsc --noEmit -p packages/builder && tsc --noEmit -p packages/signer",
|
||||||
"start": "node packages/builder/build/index.js",
|
"start": "node packages/builder/build/index.js",
|
||||||
"test": "npm run test:unit",
|
"test": "npm run test:unit",
|
||||||
"test:unit": "node --import tsx --test packages/*/test/*.test.ts",
|
"test:unit": "node --import tsx --test packages/*/test/*.test.ts",
|
||||||
"test:smoke": "npm run build && node --import tsx --test packages/builder/test/smoke/*.test.ts",
|
"test:smoke": "npm run build && node --import tsx --test packages/builder/test/smoke/*.test.ts",
|
||||||
"test:integration": "npm run build && node --import tsx --test packages/builder/test/integration/*.test.ts"
|
"test:smoke:signer": "npm run build && node --import tsx --test packages/signer/test/smoke/*.test.ts",
|
||||||
|
"test:integration": "npm run build && node --import tsx --test packages/builder/test/integration/*.test.ts",
|
||||||
|
"test:integration:signer": "node --import tsx --test packages/signer/test/integration/*.test.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.1",
|
"@types/node": "^22.13.1",
|
||||||
|
|||||||
205
packages/signer/README.md
Normal file
205
packages/signer/README.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Vector MCP Signer
|
||||||
|
|
||||||
|
Local signing companion for the Vector MCP server. **Holds your key; has no network access.**
|
||||||
|
|
||||||
|
The hosted builder is keyless: it constructs unsigned transactions but cannot sign. This
|
||||||
|
package signs them locally, so your key never reaches a shared server or your model
|
||||||
|
provider.
|
||||||
|
|
||||||
|
## Why it has no network
|
||||||
|
|
||||||
|
- **stdio transport only** — no port, no HTTP listener, nothing to connect to
|
||||||
|
- **no Provider and no Lucid instance** — signing uses CML directly
|
||||||
|
- **submission lives on the builder**, so the signer needs no egress either
|
||||||
|
|
||||||
|
`test/boundary.test.ts` checks this mechanically — it walks every file under `src/` for a
|
||||||
|
network-capable import or an ambient network global (`fetch`, `WebSocket`, ...), and checks
|
||||||
|
`package.json` for a network-capable dependency. That check is a text scan, not a sandbox, and
|
||||||
|
it is scoped accordingly: it reliably catches *accidental* regressions and *casual* misuse — an
|
||||||
|
added dependency, a forgotten forbidden import, an unthinking `fetch(...)` call, including one
|
||||||
|
routed around the bare identifier via `globalThis['fetch']` or `globalThis.fetch`. It does not
|
||||||
|
catch *deliberate* obfuscation. Verified directly against the hardened check: an ambient global
|
||||||
|
reached through a plain alias (`const f = fetch; f(...)`) or a forbidden module specifier
|
||||||
|
assembled at runtime from concatenated strings (`import('node:' + 'dns')`) both still pass.
|
||||||
|
Closing gaps like those is a code-review responsibility, not something a fixed set of text
|
||||||
|
patterns can guarantee — what this check actually verifies is that today's shipped `src/` has no
|
||||||
|
*accidental or careless* network capability, not a proof that none could ever be smuggled in.
|
||||||
|
|
||||||
|
`test/integration/roundtrip.test.ts` imports `@lucid-evolution/lucid` and the shared package's
|
||||||
|
`/provider` — that does **not** weaken the check above. That file lives under `test/`,
|
||||||
|
which `boundary.test.ts` deliberately scans only as far as `src/`, and which the build never
|
||||||
|
bundles (`tsup`'s only entry point is `src/index.ts`). Those imports exist so the test can
|
||||||
|
build a real transaction the way the hosted *builder* does (no key) before handing it to the
|
||||||
|
signer's actual decode/policy/sign code path — the network calls belong to the test's
|
||||||
|
stand-in builder, never to code that ships.
|
||||||
|
|
||||||
|
## Defense in depth
|
||||||
|
|
||||||
|
Beyond "no network," a few structural choices narrow what a bug or an accidental leak can
|
||||||
|
expose:
|
||||||
|
|
||||||
|
- **The read-only tools cannot reach the key, even in principle.** `vector_signer_get_address`,
|
||||||
|
`vector_signer_decode_transaction`, and `vector_signer_get_spend_limits` are all registered
|
||||||
|
by one function whose parameter list never includes the key source. That's structural, not a
|
||||||
|
matter of discipline: reintroducing signing capability there means visibly changing that
|
||||||
|
function's signature, not adding a line inside a handler.
|
||||||
|
- **Key material resists accidental logging.** TypeScript `private` is compile-time only — at
|
||||||
|
runtime the field is an ordinary enumerable property, fully visible to `JSON.stringify`,
|
||||||
|
`console.log`, and `util.inspect`. Mnemonics and derived private keys are therefore held in
|
||||||
|
native JavaScript `#private` fields instead, and both `KeySource` classes override
|
||||||
|
`toJSON()` and Node's inspect symbol so that even a stray `console.log(keySource)` prints a
|
||||||
|
safe description instead of the secret. This defends against *accidental* disclosure (a
|
||||||
|
debugging log line, a crash reporter serializing local state) — it is not a defense against
|
||||||
|
a compromised process, which can still read the key out of memory directly.
|
||||||
|
- **The audit log is crash-safe.** Every entry is written to a temp file and renamed into
|
||||||
|
place, so a process kill mid-write costs at most the one entry in flight, never the rest of
|
||||||
|
the day's history. See the mandatory-audit limitation below for the cost of this design.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Pick one key source:
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `VECTOR_SIGNER_MNEMONIC_FILE` | Path to a file containing your mnemonic. Use `chmod 600`. Recommended. |
|
||||||
|
| `VECTOR_SIGNER_MNEMONIC` | The mnemonic inline. Convenient, but ends up in your shell history and process environment. |
|
||||||
|
| `VECTOR_SIGNER_PRIVATE_KEY` + `VECTOR_SIGNER_ADDRESS` | A derived bech32 signing key. **Safest in terms of blast radius** — it controls one account rather than your whole HD tree, so a leak is bounded. See the mismatch caveat under Known limitations. |
|
||||||
|
|
||||||
|
If more than one is set, `config.ts` checks them in this order: `VECTOR_SIGNER_PRIVATE_KEY` +
|
||||||
|
`VECTOR_SIGNER_ADDRESS`, then `VECTOR_SIGNER_MNEMONIC`, then `VECTOR_SIGNER_MNEMONIC_FILE`.
|
||||||
|
Unset the ones you are not using — a leftover `VECTOR_SIGNER_MNEMONIC` in your shell profile
|
||||||
|
silently wins over a `VECTOR_SIGNER_MNEMONIC_FILE` you just configured.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `VECTOR_SIGNER_ACCOUNT_INDEX` | `0` | HD account index, for mnemonic sources |
|
||||||
|
| `VECTOR_SIGNER_SPEND_LIMIT_PER_TX` | `100000000` (100 AP3X) | Refuses any transaction whose net outflow exceeds this |
|
||||||
|
| `VECTOR_SIGNER_SPEND_LIMIT_DAILY` | `500000000` (500 AP3X) | Refuses once today's signed total would exceed this |
|
||||||
|
| `VECTOR_SIGNER_AUDIT_LOG_PATH` | *(see note below)* | Local record of every decision |
|
||||||
|
|
||||||
|
**`VECTOR_SIGNER_AUDIT_LOG_PATH`, precisely:** if unset, the log is written to
|
||||||
|
`vector-signer-audit-log.json` resolved against the *package's own install location* (three
|
||||||
|
directories up from `build/index.js` — the monorepo root, in this repo), **not** against
|
||||||
|
whatever directory the process happened to be started from. That makes the location
|
||||||
|
predictable regardless of your MCP client's working directory, but it also means the file is
|
||||||
|
easy to forget about. Like the builder's `vector-audit-log.json`, this exact filename is listed
|
||||||
|
in this repo's `.gitignore`, so a default-configured signer run from a checkout will not get its
|
||||||
|
audit log accidentally staged. That entry only matches the default filename, though — if you
|
||||||
|
point `VECTOR_SIGNER_AUDIT_LOG_PATH` at some other path inside the repo, check before you
|
||||||
|
`git add`, or just set the variable to somewhere outside the repo entirely.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `vector_signer_get_address` | Your address. Pass it to the builder as `changeAddress`. |
|
||||||
|
| `vector_signer_decode_transaction` | Show what a transaction would move, **without signing**. |
|
||||||
|
| `vector_signer_sign` | Policy-check and sign. Returns signed CBOR. |
|
||||||
|
| `vector_signer_get_spend_limits` | Limits, today's committed total, recent decisions. |
|
||||||
|
|
||||||
|
No tool accepts key material as a parameter — that is asserted by the smoke test.
|
||||||
|
|
||||||
|
## What "net outflow" means
|
||||||
|
|
||||||
|
Spend limits apply to **net outflow**: every output that does *not* return to your address,
|
||||||
|
plus the fee. Change is excluded, so a 1000 AP3X wallet sending 3 AP3X is measured as ~3.2
|
||||||
|
AP3X, not 1000.
|
||||||
|
|
||||||
|
An output to your own wallet at a *different* derivation index counts as outflow. That
|
||||||
|
over-counts, which refuses too much rather than too little.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **Native assets are recorded and reported, but not limited.** Spend limits apply to
|
||||||
|
lovelace. A transaction moving ~2 AP3X plus a valuable NFT passes the lovelace check.
|
||||||
|
Asset movements appear in `decode_transaction` output and the audit log so they are
|
||||||
|
visible, but they are not yet a policy input.
|
||||||
|
- **The audit log records intent, not confirmation.** The signer has no network and cannot
|
||||||
|
observe whether a submit succeeded, so a failed submit still counts against the daily
|
||||||
|
budget. Conservative by design.
|
||||||
|
- **A durable audit record is mandatory for signing — which means an unwritable audit log
|
||||||
|
stops all signing, not just accounting.** `AuditLog.append()` throws if the write fails, and
|
||||||
|
`vector_signer_sign` withholds a signature it already produced rather than return one with
|
||||||
|
no durable record of it — the alternative would let a restarted process silently under-count
|
||||||
|
today's spend, which quietly raises the effective daily limit. The practical effect: if the
|
||||||
|
audit log's directory disappears, the disk fills, or the path loses write permission, every
|
||||||
|
`vector_signer_sign` call starts returning `REFUSED` — including transactions well inside
|
||||||
|
policy — until it is writable again. That refusal names the audit failure explicitly rather
|
||||||
|
than reading like an ordinary policy refusal, but it is still a full stop on signing, not a
|
||||||
|
warning.
|
||||||
|
- **The key is plaintext at rest** in v1 (env var or file). The win is that it is *local*
|
||||||
|
and never transits your model provider or a shared host — not that it is in an HSM.
|
||||||
|
`KeySource` exists so keychain and hardware backends can be added without touching the
|
||||||
|
signing path.
|
||||||
|
- **`VECTOR_SIGNER_PRIVATE_KEY` and `VECTOR_SIGNER_ADDRESS` are not cross-checked against each
|
||||||
|
other.** Each is validated as well-formed independently, but nothing confirms the address is
|
||||||
|
actually the one the private key controls — that check is possible entirely offline (this
|
||||||
|
package already depends on the CML library that could do it) but is not wired up yet. A
|
||||||
|
mismatched pair still constructs and signs without error. The failure surfaces later: either
|
||||||
|
as a chain-level rejection at submission, or as the policy engine failing to recognise your
|
||||||
|
own change as change and over-counting it as outflow — the safe direction, but confusing if
|
||||||
|
you hit it without knowing why.
|
||||||
|
- **Prompt injection is bounded, not eliminated.** Policy refuses out-of-limit
|
||||||
|
transactions, but an injected agent can still cause any transaction *within* policy.
|
||||||
|
Recipient allowlisting is the next lever.
|
||||||
|
- **Pairing this signer with the hosted builder does not yet remove the custody exposure it
|
||||||
|
is designed to fix.** The signer's own four tools are complete and independently verified
|
||||||
|
end to end against live chain data (`test/integration/roundtrip.test.ts`). What is not yet
|
||||||
|
true is a wired-up keyless round trip through the hosted *builder's* actual tools: today
|
||||||
|
every builder tool that builds or sends a transaction (`vector_send_apex`,
|
||||||
|
`vector_build_transaction`, the registry and governance tools, ...) still requires your
|
||||||
|
mnemonic as a call parameter, and no `submit_transaction` tool exists yet to submit a
|
||||||
|
transaction this signer produced. That migration is tracked separately in
|
||||||
|
[`docs/architecture/non-custodial-split.md`](../../docs/architecture/non-custodial-split.md)
|
||||||
|
(PRs 6-8) and has not shipped. Until it does, the root README's custody notice still
|
||||||
|
describes the hosted builder accurately.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
VECTOR_SIGNER_MNEMONIC_FILE=/path/to/mnemonic.txt node packages/signer/build/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Or registered directly in an MCP client (Claude Desktop's `claude_desktop_config.json`, for
|
||||||
|
example):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"vector-signer": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/mcp-server/packages/signer/build/index.js"],
|
||||||
|
"env": {
|
||||||
|
"VECTOR_SIGNER_MNEMONIC_FILE": "/path/to/mnemonic.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register it alongside the hosted builder so an agent has both sets of tools available. See the
|
||||||
|
last bullet under Known limitations above before expecting that pairing to keep your mnemonic
|
||||||
|
off the hosted server today — the signer half is ready; the builder half is not yet.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:unit # pure logic, no wallet, no network (from the repo root)
|
||||||
|
npm run test:smoke:signer # builds the signer, boots it over stdio, exercises all 4 tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Both run in CI on every PR.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export VECTOR_SIGNER_MNEMONIC_FILE=/absolute/path/to/testnet-mnemonic.txt
|
||||||
|
export NODE_OPTIONS=--max-old-space-size=12288
|
||||||
|
npm run test:integration:signer
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires a **funded Vector testnet** wallet. Builds a real unsigned transaction the way the
|
||||||
|
hosted builder does (no key), then decodes, policy-checks, and signs it with the real signer
|
||||||
|
code path against live chain data. **Never submits.** Not run in CI — see
|
||||||
|
`test/integration/roundtrip.test.ts`.
|
||||||
21
packages/signer/package.json
Normal file
21
packages/signer/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@apexfusion/vector-mcp-signer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Local signing companion for the Vector MCP server. Holds the key; no network.",
|
||||||
|
"bin": { "vector-mcp-signer": "./build/index.js" },
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup && chmod +x build/index.js",
|
||||||
|
"start": "node build/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anastasia-labs/cardano-multiplatform-lib-nodejs": "6.0.2-3",
|
||||||
|
"@lucid-evolution/wallet": "^0.1.72",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@apexfusion/vector-mcp-shared": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
193
packages/signer/src/audit.ts
Normal file
193
packages/signer/src/audit.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// Local, per-user audit log. Records intent at signing time, not confirmed
|
||||||
|
// submission — the signer has no network, so it cannot know whether a submit
|
||||||
|
// later succeeded. That over-counts on a failed submit, which is conservative.
|
||||||
|
//
|
||||||
|
// The log file is untrusted input every time it is read: it can be hand-edited,
|
||||||
|
// truncated by a process that died mid-write, or produced by a future version
|
||||||
|
// of this module with a different entry shape. Every method below treats it
|
||||||
|
// accordingly. Two different failure shapes get two different responses, on
|
||||||
|
// purpose:
|
||||||
|
// - The whole file is unreadable or not a JSON array: nothing in it can be
|
||||||
|
// trusted, so start clean. Loud on stderr, never silent (see load()).
|
||||||
|
// - The file is a JSON array but one element in it is garbage (not an
|
||||||
|
// object — most dangerously `null`, see below): drop only that element
|
||||||
|
// and keep the rest. A single bad entry must not erase an otherwise-good
|
||||||
|
// day's history, since committedTodayLovelace() is exactly what the
|
||||||
|
// daily spend limit depends on.
|
||||||
|
// Nothing here ever throws out of committedTodayLovelace() or recent(): a
|
||||||
|
// thrown exception from a "how much have I already spent" query is the kind
|
||||||
|
// of bug most likely to be handled upstream by assuming zero — which would
|
||||||
|
// silently defeat the daily limit. append() is the one exception: see its
|
||||||
|
// own comment for why a write failure there throws instead of swallowing.
|
||||||
|
//
|
||||||
|
// CONTRACT for callers of append(): it can throw. If you wrap it in
|
||||||
|
// try/catch — a reasonable instinct, since an uncaught throw from an audit
|
||||||
|
// module makes for an ugly tool-call failure — catching that throw must NOT
|
||||||
|
// be followed by still returning a signature you already produced. append()
|
||||||
|
// is normally called immediately after a transaction is signed; catching its
|
||||||
|
// throw and returning the signature anyway reproduces exactly the failure
|
||||||
|
// this module exists to prevent, just one call site later: a real signature
|
||||||
|
// reaches the caller while no durable record of it exists on disk, so the
|
||||||
|
// next process's committedTodayLovelace() will never count it.
|
||||||
|
import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
timestamp: string;
|
||||||
|
txHash: string;
|
||||||
|
decision: 'signed' | 'refused';
|
||||||
|
netOutflowLovelace: string;
|
||||||
|
reason?: string;
|
||||||
|
assetMovements: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditLog {
|
||||||
|
private entries: AuditEntry[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.filePath)) return;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'));
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error('log file did not contain a JSON array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON.parse can hand back array elements of any shape. Most of those
|
||||||
|
// are harmless here even though they are not real AuditEntry objects:
|
||||||
|
// a stray string, number, or boolean silently returns `undefined` for
|
||||||
|
// `.decision`, which just fails the `!== 'signed'` check like any other
|
||||||
|
// unrecognised value. `null` is the one exception — property access on
|
||||||
|
// null throws — so committedTodayLovelace() would crash on the entry
|
||||||
|
// AFTER it, and every entry after that, not just skip the bad one.
|
||||||
|
// Filtering to object-shaped, non-array elements up front means every
|
||||||
|
// other method can assume its entries are at least objects, even if
|
||||||
|
// individual fields on them still turn out to be garbage (handled
|
||||||
|
// separately, per field, in committedTodayLovelace()).
|
||||||
|
const usable = parsed.filter(
|
||||||
|
(e): e is AuditEntry => e !== null && typeof e === 'object' && !Array.isArray(e)
|
||||||
|
);
|
||||||
|
if (usable.length !== parsed.length) {
|
||||||
|
console.error(
|
||||||
|
`[audit] ${this.filePath}: dropped ${parsed.length - usable.length} of ${parsed.length} log entries that were not objects (e.g. null); keeping the rest`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.entries = usable;
|
||||||
|
} catch (err) {
|
||||||
|
// A corrupt log must not stop the signer from working, and must not be
|
||||||
|
// silently trusted either. Start clean; the file is overwritten on the
|
||||||
|
// next append. This branch also catches a valid-JSON-but-wrong-shape
|
||||||
|
// file (an object, `null`, a string, ...) via the throw above — that is
|
||||||
|
// just as untrustworthy as a syntax error and gets the same fresh-start
|
||||||
|
// treatment and the same stderr line.
|
||||||
|
console.error(
|
||||||
|
`[audit] ${this.filePath} was unreadable (${err instanceof Error ? err.message : String(err)}); starting a fresh log`
|
||||||
|
);
|
||||||
|
this.entries = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists `entry`. Can throw if the log cannot be durably written — see
|
||||||
|
* the CONTRACT note at the top of this file before wrapping this in
|
||||||
|
* try/catch.
|
||||||
|
*/
|
||||||
|
append(entry: AuditEntry): void {
|
||||||
|
// Pushed before the write is attempted: if the write below fails, this
|
||||||
|
// process's own in-memory state (committedTodayLovelace(), recent())
|
||||||
|
// must still reflect the decision that was actually made. Losing it here
|
||||||
|
// would under-count even within this same process, which would be worse
|
||||||
|
// than the cross-restart gap discussed below.
|
||||||
|
this.entries.push(entry);
|
||||||
|
|
||||||
|
// Written to a temp file and renamed into place, rather than written
|
||||||
|
// directly to this.filePath, to survive a process kill mid-write (power
|
||||||
|
// loss, OOM kill — not a normal error path, but a real one for a
|
||||||
|
// long-running signer). Writing this.filePath directly has two problems
|
||||||
|
// a temp file avoids: writeFileSync truncates its target the moment it
|
||||||
|
// opens it, before a single byte is written, so a kill right after that
|
||||||
|
// truncation would destroy the entire existing log, not just fail to add
|
||||||
|
// the new entry; and even a completed write can leave the file
|
||||||
|
// half-written if the kill lands mid-syscall. Neither can happen to
|
||||||
|
// this.filePath here, because this.filePath is never opened for writing
|
||||||
|
// at all — only tmpPath is. The rename that publishes tmpPath over
|
||||||
|
// this.filePath is a single directory-entry update on the same
|
||||||
|
// filesystem, which POSIX and Windows both perform atomically: a kill
|
||||||
|
// during the rename itself cannot leave this.filePath half-written
|
||||||
|
// either, it is simply still the old complete file or already the new
|
||||||
|
// complete one. The only entry a kill can cost is the one in flight —
|
||||||
|
// shrinking the worst case from "lose the whole day's history" to "lose
|
||||||
|
// at most this one entry", which is the same entry append() already
|
||||||
|
// cannot guarantee past a hard kill regardless of persistence strategy.
|
||||||
|
const tmpPath = `${this.filePath}.tmp`;
|
||||||
|
try {
|
||||||
|
writeFileSync(tmpPath, JSON.stringify(this.entries, null, 2) + '\n');
|
||||||
|
renameSync(tmpPath, this.filePath);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[audit] could not write ${this.filePath}: ${message}`);
|
||||||
|
// Deliberately rethrown rather than swallowed — see the CONTRACT note
|
||||||
|
// at the top of this file for what a catching caller must not do.
|
||||||
|
// Swallowing would make append() return normally — a plausible-
|
||||||
|
// looking success — while the entry silently exists only in this
|
||||||
|
// process's memory. That is fine until the next restart: a fresh
|
||||||
|
// AuditLog reloads from disk, does not find this entry, and
|
||||||
|
// committedTodayLovelace() under-reports by exactly its amount. For a
|
||||||
|
// 'signed' entry that is precisely the failure this module exists to
|
||||||
|
// prevent — it raises the effective daily cap with no visible symptom
|
||||||
|
// until someone goes looking. Making the write failure loud and
|
||||||
|
// synchronous, at the moment it happens, is the fail-closed choice.
|
||||||
|
throw new Error(`Audit log write failed (entry kept in memory only, not persisted): ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lovelace committed today (UTC) by signed transactions. Refusals do not count. */
|
||||||
|
committedTodayLovelace(): bigint {
|
||||||
|
const startOfDayUtc = new Date();
|
||||||
|
startOfDayUtc.setUTCHours(0, 0, 0, 0);
|
||||||
|
const cutoff = startOfDayUtc.getTime();
|
||||||
|
|
||||||
|
let total = 0n;
|
||||||
|
for (const e of this.entries) {
|
||||||
|
if (e.decision !== 'signed') continue;
|
||||||
|
const t = new Date(e.timestamp).getTime();
|
||||||
|
if (Number.isNaN(t) || t < cutoff) continue;
|
||||||
|
|
||||||
|
let amount: bigint;
|
||||||
|
try {
|
||||||
|
amount = BigInt(e.netOutflowLovelace);
|
||||||
|
} catch {
|
||||||
|
// Not parseable at all (non-numeric text, a decimal, ...). Ignore
|
||||||
|
// rather than crash the signer's accounting.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// BigInt string parsing accepts more than plain positive integers.
|
||||||
|
// BigInt('') is 0n — a harmless no-op, not a crash and not skipped,
|
||||||
|
// just zero. BigInt('-5000000') is a valid negative bigint, and that
|
||||||
|
// one is NOT harmless: adding a negative amount can only ever reduce
|
||||||
|
// the running total, i.e. under-report — the one direction this
|
||||||
|
// function must never move in. Nothing in the real pipeline can
|
||||||
|
// produce a negative netOutflowLovelace (policy.ts sums non-negative
|
||||||
|
// output lovelace plus a non-negative fee), so a negative value here
|
||||||
|
// can only come from a corrupted or hand-edited log entry. Treated the
|
||||||
|
// same as any other unparseable amount: skipped, not subtracted.
|
||||||
|
if (amount < 0n) continue;
|
||||||
|
total += amount;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
recent(limit: number): AuditEntry[] {
|
||||||
|
// Array.prototype.slice(-0) behaves as slice(0): -0 is not < 0, so the
|
||||||
|
// "count back from the end" branch never triggers, and the call returns
|
||||||
|
// the ENTIRE array instead of none. limit <= 0 is handled explicitly so
|
||||||
|
// recent(0) means "no entries" rather than "all of them" — and the same
|
||||||
|
// guard makes a negative limit equally harmless instead of doing
|
||||||
|
// whatever slice() would otherwise do with it (drop entries off the
|
||||||
|
// front, which is not a sensible reading of "give me N recent entries").
|
||||||
|
if (limit <= 0) return [];
|
||||||
|
return this.entries.slice(-limit).reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
90
packages/signer/src/config.ts
Normal file
90
packages/signer/src/config.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Signer configuration, entirely from the environment. No network settings
|
||||||
|
// exist here by design — the signer has no endpoints to configure.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { MnemonicKeySource, PrivateKeyKeySource, type KeySource } from './keysource.js';
|
||||||
|
import type { SpendLimits } from './policy.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export interface SignerConfig {
|
||||||
|
keySource: KeySource;
|
||||||
|
limits: SpendLimits;
|
||||||
|
auditLogPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoRootDefault(): string {
|
||||||
|
// From packages/signer/build/index.js, three levels up is the repo root.
|
||||||
|
return resolve(__dirname, '../../..');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): SignerConfig {
|
||||||
|
const keySource = resolveKeySource(env);
|
||||||
|
|
||||||
|
return {
|
||||||
|
keySource,
|
||||||
|
limits: {
|
||||||
|
perTxLovelace: BigInt(env.VECTOR_SIGNER_SPEND_LIMIT_PER_TX ?? '100000000'),
|
||||||
|
dailyLovelace: BigInt(env.VECTOR_SIGNER_SPEND_LIMIT_DAILY ?? '500000000'),
|
||||||
|
},
|
||||||
|
auditLogPath: env.VECTOR_SIGNER_AUDIT_LOG_PATH
|
||||||
|
? resolve(env.VECTOR_SIGNER_AUDIT_LOG_PATH)
|
||||||
|
: resolve(repoRootDefault(), 'vector-signer-audit-log.json'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKeySource(env: NodeJS.ProcessEnv): KeySource {
|
||||||
|
if (env.VECTOR_SIGNER_PRIVATE_KEY && env.VECTOR_SIGNER_ADDRESS) {
|
||||||
|
return new PrivateKeyKeySource(env.VECTOR_SIGNER_PRIVATE_KEY, env.VECTOR_SIGNER_ADDRESS);
|
||||||
|
}
|
||||||
|
if (env.VECTOR_SIGNER_MNEMONIC) {
|
||||||
|
return new MnemonicKeySource(env.VECTOR_SIGNER_MNEMONIC, parseAccountIndex(env.VECTOR_SIGNER_ACCOUNT_INDEX));
|
||||||
|
}
|
||||||
|
if (env.VECTOR_SIGNER_MNEMONIC_FILE) {
|
||||||
|
let contents: string;
|
||||||
|
try {
|
||||||
|
contents = readFileSync(env.VECTOR_SIGNER_MNEMONIC_FILE, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
// Reference the source, never the contents.
|
||||||
|
throw new Error(
|
||||||
|
`Could not read VECTOR_SIGNER_MNEMONIC_FILE (${env.VECTOR_SIGNER_MNEMONIC_FILE}): ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new MnemonicKeySource(contents, parseAccountIndex(env.VECTOR_SIGNER_ACCOUNT_INDEX));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'No key material configured. Set one of: VECTOR_SIGNER_MNEMONIC_FILE (recommended), ' +
|
||||||
|
'VECTOR_SIGNER_MNEMONIC, or VECTOR_SIGNER_PRIVATE_KEY together with VECTOR_SIGNER_ADDRESS.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number(x) returns NaN for garbage input rather than throwing, and nothing
|
||||||
|
// downstream re-validates it: MnemonicKeySource's constructor takes
|
||||||
|
// accountIndex on faith (its own validation is about the mnemonic, not this
|
||||||
|
// argument), and walletFromSeed's HD derivation does not throw on a NaN,
|
||||||
|
// negative, or fractional index either -- it just walks a different (but
|
||||||
|
// still syntactically valid-looking) branch of the tree. A single env-var
|
||||||
|
// typo would otherwise silently start the signer against a different
|
||||||
|
// wallet, with no error anywhere in the chain and describe() printing the
|
||||||
|
// nonsense value back out as if nothing were wrong (e.g. "mnemonic (account
|
||||||
|
// index NaN)"). This file's own spend limits, a few lines up, already fail
|
||||||
|
// loud on the same shape of mistake (BigInt(...) throws on non-numeric
|
||||||
|
// input) -- this applies that same instinct here.
|
||||||
|
//
|
||||||
|
// Only called from the two MnemonicKeySource-constructing branches above,
|
||||||
|
// not unconditionally: PrivateKeyKeySource never uses an account index (see
|
||||||
|
// its own doc comment in keysource.ts), so a leftover/garbage
|
||||||
|
// VECTOR_SIGNER_ACCOUNT_INDEX must not block startup for a config that never
|
||||||
|
// reads it.
|
||||||
|
function parseAccountIndex(raw: string | undefined): number {
|
||||||
|
const source = raw ?? '0';
|
||||||
|
const accountIndex = Number(source);
|
||||||
|
if (!Number.isInteger(accountIndex) || accountIndex < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid VECTOR_SIGNER_ACCOUNT_INDEX: expected a non-negative integer, got "${source}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return accountIndex;
|
||||||
|
}
|
||||||
117
packages/signer/src/decode.ts
Normal file
117
packages/signer/src/decode.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Decode a transaction into plain data. Pure: no key, no filesystem, no network.
|
||||||
|
// Everything policy needs is in the transaction body, which is why the signer
|
||||||
|
// never has to resolve inputs against the chain.
|
||||||
|
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
|
||||||
|
|
||||||
|
export interface DecodedOutput {
|
||||||
|
address: string;
|
||||||
|
lovelace: bigint;
|
||||||
|
assets: Array<{ unit: string; quantity: bigint }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodedTx {
|
||||||
|
txHashHex: string;
|
||||||
|
fee: bigint;
|
||||||
|
outputs: DecodedOutput[];
|
||||||
|
inputCount: number;
|
||||||
|
mintedAssetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAssets(value: CML.Value): Array<{ unit: string; quantity: bigint }> {
|
||||||
|
const out: Array<{ unit: string; quantity: bigint }> = [];
|
||||||
|
const multiasset = value.multi_asset();
|
||||||
|
if (!multiasset) return out;
|
||||||
|
const policies = multiasset.keys();
|
||||||
|
for (let p = 0; p < policies.len(); p++) {
|
||||||
|
const policy = policies.get(p);
|
||||||
|
const assets = multiasset.get_assets(policy);
|
||||||
|
if (!assets) continue;
|
||||||
|
const names = assets.keys();
|
||||||
|
for (let n = 0; n < names.len(); n++) {
|
||||||
|
const name = names.get(n);
|
||||||
|
const qty = assets.get(name);
|
||||||
|
if (qty === undefined) continue;
|
||||||
|
out.push({
|
||||||
|
// name.to_hex() returns the asset name's raw bytes as hex, with no CBOR
|
||||||
|
// envelope. name.to_cbor_hex() would need its CBOR byte-string header
|
||||||
|
// stripped, and that header is 1 byte only for names up to 23 bytes long;
|
||||||
|
// Cardano asset names run up to 32 bytes, where the header grows to 2
|
||||||
|
// bytes, so a fixed slice(2) silently corrupts the unit for 24-32 byte
|
||||||
|
// names. to_hex() sidesteps CBOR entirely and is correct at every length
|
||||||
|
// (verified against CML for lengths 0, 1, 8, 22, 23, 24, 25, 27, 31, 32).
|
||||||
|
unit: `${policy.to_hex()}${name.to_hex()}`,
|
||||||
|
quantity: BigInt(qty.toString()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTransaction(cborHex: string): DecodedTx {
|
||||||
|
let tx: CML.Transaction;
|
||||||
|
try {
|
||||||
|
tx = CML.Transaction.from_cbor_hex(cborHex);
|
||||||
|
} catch (err) {
|
||||||
|
// Fail closed: refuse rather than return something partially understood.
|
||||||
|
throw new Error(
|
||||||
|
`Transaction could not be decoded: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// from_cbor_hex only requires that a *prefix* of cborHex parse as a valid
|
||||||
|
// transaction; it does not check that parsing consumed the whole string, so
|
||||||
|
// trailing bytes would otherwise be silently discarded and produce a
|
||||||
|
// confident, fully-populated result over less data than was actually
|
||||||
|
// supplied. CML preserves the original encoding on parse-then-reserialize
|
||||||
|
// (only objects built from scratch via `.new()` are canonicalised), so
|
||||||
|
// re-serialising and comparing against the input detects any unconsumed
|
||||||
|
// suffix. Compared case-insensitively: valid uppercase-hex input round-trips
|
||||||
|
// through CML as lowercase, which would otherwise read as a mismatch.
|
||||||
|
// The risk of comparing strictly is a false positive: if CML re-encoded any
|
||||||
|
// legitimate transaction differently, the signer would refuse to sign it at
|
||||||
|
// all. That was checked against the encodings this ecosystem actually uses —
|
||||||
|
// including the indefinite-length CBOR the agent registry hand-rolls (see
|
||||||
|
// shared/tx.ts), metadata, Plutus scripts and datums, and non-canonical
|
||||||
|
// integers — and the encoding survived every round trip.
|
||||||
|
if (tx.to_cbor_hex().toLowerCase() !== cborHex.toLowerCase()) {
|
||||||
|
throw new Error(
|
||||||
|
'Transaction could not be decoded: input was not fully consumed (unrecognised trailing data)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = tx.body();
|
||||||
|
const outputs: DecodedOutput[] = [];
|
||||||
|
const cmlOutputs = body.outputs();
|
||||||
|
for (let i = 0; i < cmlOutputs.len(); i++) {
|
||||||
|
const o = cmlOutputs.get(i);
|
||||||
|
let address: string;
|
||||||
|
try {
|
||||||
|
address = o.address().to_bech32();
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Transaction could not be decoded: output ${i} has an unreadable address`);
|
||||||
|
}
|
||||||
|
outputs.push({
|
||||||
|
address,
|
||||||
|
lovelace: BigInt(o.amount().coin().toString()),
|
||||||
|
assets: readAssets(o.amount()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mint = body.mint();
|
||||||
|
let mintedAssetCount = 0;
|
||||||
|
if (mint) {
|
||||||
|
const policies = mint.keys();
|
||||||
|
for (let p = 0; p < policies.len(); p++) {
|
||||||
|
const assets = mint.get_assets(policies.get(p));
|
||||||
|
if (assets) mintedAssetCount += assets.keys().len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
txHashHex: CML.hash_transaction(body).to_hex(),
|
||||||
|
fee: BigInt(body.fee().toString()),
|
||||||
|
outputs,
|
||||||
|
inputCount: body.inputs().len(),
|
||||||
|
mintedAssetCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
packages/signer/src/index.ts
Normal file
19
packages/signer/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Local signing companion for the Vector MCP server.
|
||||||
|
//
|
||||||
|
// Speaks stdio only: no HTTP listener, no outbound network, no Provider.
|
||||||
|
// The signing key never leaves this process. See test/boundary.test.ts.
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { registerSignerTools } from './tools.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const server = new McpServer({ name: 'vector-mcp-signer', version: '0.1.0' });
|
||||||
|
registerSignerTools(server, config);
|
||||||
|
|
||||||
|
// stdout is the MCP channel — every diagnostic must go to stderr.
|
||||||
|
console.error(`vector-mcp-signer ready. Key source: ${config.keySource.describe()}`);
|
||||||
|
console.error(`Audit log: ${config.auditLogPath}`);
|
||||||
|
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
248
packages/signer/src/keysource.ts
Normal file
248
packages/signer/src/keysource.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
// Key material sources. Pure derivation only: walletFromSeed needs no Provider,
|
||||||
|
// so nothing here can reach the network.
|
||||||
|
//
|
||||||
|
// A raw private key is strictly safer than a mnemonic: it controls one account
|
||||||
|
// rather than the entire HD tree, so a leak is bounded.
|
||||||
|
//
|
||||||
|
// SECURITY: this module handles the most sensitive data in the system. Key
|
||||||
|
// material (mnemonics, private keys) must never reach describe(), a thrown
|
||||||
|
// error, or a log line -- only the *source* of a failure ("mnemonic could not
|
||||||
|
// be derived", "invalid private key"), never the value. Every catch block
|
||||||
|
// below is commented with why the upstream message it surfaces is safe to
|
||||||
|
// include verbatim.
|
||||||
|
//
|
||||||
|
// TypeScript `private` is a COMPILE-TIME check only -- at runtime it is an
|
||||||
|
// ordinary enumerable own property, fully visible to JSON.stringify,
|
||||||
|
// util.inspect, console.log (which goes through util.inspect), Object.keys,
|
||||||
|
// Object.getOwnPropertyNames, spread, and for...in. Verified by execution,
|
||||||
|
// not assumed: `JSON.stringify(new MnemonicKeySource(m))` printed the
|
||||||
|
// mnemonic in full under TS `private`. This package already writes
|
||||||
|
// diagnostics to stderr elsewhere (audit.ts, index.ts) and describe()'s own
|
||||||
|
// docstring below invites "for logs" usage -- one `console.log(keySource)`
|
||||||
|
// instead of `console.log(keySource.describe())` away from a real leak. Key
|
||||||
|
// material fields are therefore declared with native `#private` syntax
|
||||||
|
// (invisible to all of the above, confirmed by execution) rather than TS
|
||||||
|
// `private`, and both classes additionally override toJSON() and Node's
|
||||||
|
// util.inspect.custom symbol as defence in depth, so that even an
|
||||||
|
// accidental serialisation attempt produces describe()'s safe string
|
||||||
|
// instead of either a leak or an uninformative `{}`.
|
||||||
|
import { walletFromSeed } from '@lucid-evolution/wallet';
|
||||||
|
|
||||||
|
export interface SigningIdentity {
|
||||||
|
privateKeyBech32: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeySource {
|
||||||
|
/** Human-readable description for logs. MUST NOT include key material. */
|
||||||
|
describe(): string;
|
||||||
|
load(): SigningIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The well-known symbol Node's util.inspect (and therefore console.log, which
|
||||||
|
// calls it) looks for. Built via Symbol.for on its registered name rather
|
||||||
|
// than `import { inspect } from 'node:util'` so this module's import surface
|
||||||
|
// stays exactly `@lucid-evolution/wallet` -- confirmed by execution that
|
||||||
|
// `Symbol.for('nodejs.util.inspect.custom') === util.inspect.custom`.
|
||||||
|
const NODE_INSPECT_CUSTOM = Symbol.for('nodejs.util.inspect.custom');
|
||||||
|
|
||||||
|
const VALID_WORD_COUNTS = [12, 15, 18, 21, 24];
|
||||||
|
|
||||||
|
export class MnemonicKeySource implements KeySource {
|
||||||
|
// Native private field, not TS `private` -- see the module header. The
|
||||||
|
// mnemonic is the single most sensitive value in this whole package.
|
||||||
|
#mnemonic: string;
|
||||||
|
|
||||||
|
constructor(mnemonic: string, private readonly accountIndex: number = 0) {
|
||||||
|
// Whitespace normalisation: collapse any run of whitespace (including
|
||||||
|
// tabs/newlines and Unicode space separators like NBSP, which JS `\s`
|
||||||
|
// already covers) to a single space, and trim the ends. This is the
|
||||||
|
// ONLY normalisation that can be done safely with a denylist-free,
|
||||||
|
// positive approach for whitespace -- see the residual note below for
|
||||||
|
// what it deliberately does not catch.
|
||||||
|
//
|
||||||
|
// Case normalisation: BIP39 wordlist lookup is case-sensitive
|
||||||
|
// (`wordlist.indexOf(word)`), but casing carries no BIP39 semantic
|
||||||
|
// weight -- a word is identified purely by which of the 2048 fixed
|
||||||
|
// wordlist entries it matches, never by how it happens to be
|
||||||
|
// capitalised. There is no such thing as two different valid mnemonics
|
||||||
|
// that differ only in case, so lowercasing here cannot turn one legitimate
|
||||||
|
// mnemonic into a different, also-legitimate one -- it can only turn an
|
||||||
|
// otherwise-correct mnemonic that a client capitalised (autocapitalize,
|
||||||
|
// pasted from a sentence, typed by hand) from a hard, unhelpful throw
|
||||||
|
// into the working derivation the caller actually intended. Verified
|
||||||
|
// empirically before relying on this: lowercasing an uppercase or
|
||||||
|
// mixed-case rendering of a valid mnemonic reproduces the exact
|
||||||
|
// canonical lowercase string byte-for-byte, and therefore derives the
|
||||||
|
// exact same address -- never a different, also-plausible one.
|
||||||
|
this.#mnemonic = mnemonic.trim().split(/\s+/).join(' ').toLowerCase();
|
||||||
|
|
||||||
|
// `''.split(' ')` yields `['']` (length 1), not `[]` (length 0) -- the
|
||||||
|
// same empty-string-split gotcha documented at length in policy.ts's
|
||||||
|
// knownAddresses(). Handled explicitly so a blank/whitespace-only
|
||||||
|
// mnemonic is reported as "got 0 words", not the misleading "got 1".
|
||||||
|
// Either way this throws and derives nothing; this only fixes what the
|
||||||
|
// message says.
|
||||||
|
const words = this.#mnemonic.length === 0 ? [] : this.#mnemonic.split(' ');
|
||||||
|
if (!VALID_WORD_COUNTS.includes(words.length)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid mnemonic: expected 12, 15, 18, 21 or 24 words, got ${words.length}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Residual, deliberately not chased further (same shape as policy.ts's
|
||||||
|
// own documented residual): a character that is invisible but is NOT
|
||||||
|
// matched by `\s` -- e.g. U+200B ZERO WIDTH SPACE or U+2060 WORD
|
||||||
|
// JOINER, neither of which are in ECMAScript's WhiteSpace production --
|
||||||
|
// survives `.split(/\s+/)` glued onto whichever word it sits next to.
|
||||||
|
// Unlike policy.ts's address matching (an exact-equality check against
|
||||||
|
// caller-supplied strings, where a corrupted entry can silently fail to
|
||||||
|
// match and be misclassified), this does not create a silent-wrong-
|
||||||
|
// derivation risk: BIP39 word lookup is an exact match against a fixed,
|
||||||
|
// closed 2048-word list, so a corrupted word can (for all practical,
|
||||||
|
// non-adversarially-constructed input) never happen to equal a
|
||||||
|
// different real wordlist word. The corrupted word simply fails to
|
||||||
|
// match anything, and load() throws "Invalid mnemonic" -- confirmed
|
||||||
|
// empirically, see keysource.test.ts. The failure is safe (loud, no
|
||||||
|
// derivation), just not always caught at construction time the way a
|
||||||
|
// word-count mismatch is, because the corrupted string still counts as
|
||||||
|
// one "word" by count alone.
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(): string {
|
||||||
|
return `mnemonic (account index ${this.accountIndex})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defence in depth beyond the #private field above: even if some future
|
||||||
|
// caller (or library) reaches for JSON.stringify(keySource) or
|
||||||
|
// console.log(keySource) instead of keySource.describe()'s intended path,
|
||||||
|
// both now resolve to the same safe string rather than either leaking or
|
||||||
|
// silently producing an uninformative `{}`.
|
||||||
|
toJSON(): string {
|
||||||
|
return this.describe();
|
||||||
|
}
|
||||||
|
|
||||||
|
[NODE_INSPECT_CUSTOM](): string {
|
||||||
|
return this.describe();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): SigningIdentity {
|
||||||
|
// Vector runs with the --mainnet flag, so addresses are addr1...
|
||||||
|
let w;
|
||||||
|
try {
|
||||||
|
w = walletFromSeed(this.#mnemonic, { network: 'Mainnet', accountIndex: this.accountIndex });
|
||||||
|
} catch (err) {
|
||||||
|
// Never interpolate `this.#mnemonic` here. Read bip39's source
|
||||||
|
// (mnemonicToEntropy, which walletFromSeed calls directly) to confirm
|
||||||
|
// this before relying on it: every throw site in that function uses
|
||||||
|
// one of four FIXED constant strings -- 'Invalid mnemonic' (wrong
|
||||||
|
// word count, or a word absent from the wordlist), 'Invalid mnemonic
|
||||||
|
// checksum' (right words, wrong combination), 'Invalid entropy', or a
|
||||||
|
// wordlist-config error -- and none of them ever interpolates the
|
||||||
|
// input. err.message is therefore safe to surface verbatim. This
|
||||||
|
// also confirms the safety property the constructor's comment
|
||||||
|
// depends on: a bad mnemonic throws here rather than silently
|
||||||
|
// resolving to different, plausible-looking entropy.
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(`Mnemonic could not be derived into a wallet: ${message}`);
|
||||||
|
}
|
||||||
|
return { privateKeyBech32: w.paymentKey, address: w.address };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivateKeyKeySource implements KeySource {
|
||||||
|
// Native private fields, not TS `private` -- see the module header.
|
||||||
|
#privateKeyBech32: string;
|
||||||
|
#address: string;
|
||||||
|
|
||||||
|
// NOTE on a gap this constructor does NOT close: it validates that each
|
||||||
|
// argument is independently well-formed, but never checks that `address`
|
||||||
|
// is actually the address controlled by `privateKeyBech32`. A caller that
|
||||||
|
// supplies a syntactically valid key and a syntactically valid but
|
||||||
|
// UNRELATED address constructs and load()s successfully.
|
||||||
|
//
|
||||||
|
// This is a deliberate scope boundary for THIS module, not a technical
|
||||||
|
// impossibility -- worth being precise about, since "structural limit"
|
||||||
|
// language here previously read as the latter and that is not accurate.
|
||||||
|
// The check itself is possible with no network I/O: the Cardano
|
||||||
|
// Multiplatform Library can derive a public key from a raw private key
|
||||||
|
// and hash it into a credential entirely offline. CML is not some
|
||||||
|
// unavailable dependency -- it is already a DIRECT dependency of this
|
||||||
|
// exact package (`@anastasia-labs/cardano-multiplatform-lib-nodejs` in
|
||||||
|
// package.json), it is not on boundary.test.ts's forbidden-import list,
|
||||||
|
// and decode.ts already imports it elsewhere in this same package. So CML
|
||||||
|
// is not the obstacle. The actual constraint is narrower and self-
|
||||||
|
// imposed: this module's task brief calls for importing ONLY
|
||||||
|
// `@lucid-evolution/wallet`, to keep this one file's dependency surface
|
||||||
|
// minimal and its intent legible at a glance -- and that package's only
|
||||||
|
// function accepting a raw private key, makeWalletFromPrivateKey,
|
||||||
|
// requires a live Provider (a genuine network capability, unlike CML),
|
||||||
|
// which really is off-limits here. Closing this gap is therefore in
|
||||||
|
// reach for a future, deliberate change (a CML import, with
|
||||||
|
// boundary.test.ts re-verified against it) -- it is left open FOR NOW
|
||||||
|
// because it is out of THIS task's scope, not because it cannot be done.
|
||||||
|
//
|
||||||
|
// For the Base addresses walletFromSeed produces specifically, closing
|
||||||
|
// this gap fully would also need the stake credential, not just the
|
||||||
|
// payment one -- a Base address is not a function of the payment key
|
||||||
|
// alone, and this constructor never receives a stake key at all. A CML-
|
||||||
|
// based check could still verify the payment-credential half, which
|
||||||
|
// would already catch the realistic mistake (an unrelated key/address
|
||||||
|
// pair) even without verifying the stake half.
|
||||||
|
//
|
||||||
|
// What this gap does NOT do: it does not let a mismatch pass unnoticed
|
||||||
|
// forever, and it fails in a bounded direction. Signing itself does not
|
||||||
|
// need the address (raw Ed25519 signing over a transaction body needs
|
||||||
|
// only the private key), so a mismatch does not corrupt the signature --
|
||||||
|
// it produces a witness for a DIFFERENT credential than the one the
|
||||||
|
// transaction's inputs actually require, which is exactly the shape of
|
||||||
|
// failure a UTxO chain is built to reject at submission. Separately, and
|
||||||
|
// verified by reading the already-implemented policy.ts: `address` is
|
||||||
|
// also the value fed into policy.ts's own-address change detection, which
|
||||||
|
// only ever uses it to decide what does NOT count as outflow. A wrong
|
||||||
|
// `address` there cannot make policy.ts treat a foreign payment as safe
|
||||||
|
// change -- at worst it fails to recognise real change as change, which
|
||||||
|
// policy.ts's own documentation already establishes as its deliberate
|
||||||
|
// bias (over-refuse, never under-refuse). Both consequences are pinned as
|
||||||
|
// tests below rather than left as assumptions.
|
||||||
|
constructor(privateKeyBech32: string, address: string) {
|
||||||
|
// Trimmed for the same reason MnemonicKeySource tolerates surrounding
|
||||||
|
// whitespace: a bech32 string has no meaningful leading/trailing
|
||||||
|
// whitespace, so trimming is pure UX (copy-paste noise) with no
|
||||||
|
// ambiguity risk. Validate AND store the trimmed value, not the
|
||||||
|
// original -- storing the untrimmed original after validating the
|
||||||
|
// trimmed one is precisely the bug policy.ts's knownAddresses() had
|
||||||
|
// (validated the trimmed form, kept the padded one, which then never
|
||||||
|
// matched anything downstream by exact string equality). Both fields
|
||||||
|
// here feed later exact-string comparisons too (this address flows into
|
||||||
|
// policy.ts's ownAddresses), so the same failure shape applies.
|
||||||
|
const trimmedKey = privateKeyBech32.trim();
|
||||||
|
const trimmedAddress = address.trim();
|
||||||
|
if (!/^ed25519e?_sk1[0-9a-z]+$/.test(trimmedKey)) {
|
||||||
|
throw new Error('Invalid private key: expected a bech32 ed25519_sk / ed25519e_sk value.');
|
||||||
|
}
|
||||||
|
if (!/^addr1[0-9a-z]+$/.test(trimmedAddress)) {
|
||||||
|
throw new Error('Invalid address: expected a bech32 addr1 value.');
|
||||||
|
}
|
||||||
|
this.#privateKeyBech32 = trimmedKey;
|
||||||
|
this.#address = trimmedAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(): string {
|
||||||
|
return 'raw private key';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defence in depth beyond the #private fields above -- see the matching
|
||||||
|
// comment on MnemonicKeySource.
|
||||||
|
toJSON(): string {
|
||||||
|
return this.describe();
|
||||||
|
}
|
||||||
|
|
||||||
|
[NODE_INSPECT_CUSTOM](): string {
|
||||||
|
return this.describe();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): SigningIdentity {
|
||||||
|
return { privateKeyBech32: this.#privateKeyBech32, address: this.#address };
|
||||||
|
}
|
||||||
|
}
|
||||||
145
packages/signer/src/policy.ts
Normal file
145
packages/signer/src/policy.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Spend policy. Pure function of its arguments — the caller supplies today's
|
||||||
|
// spend from the audit log, so this module needs no I/O and is fully testable.
|
||||||
|
import type { DecodedTx } from './decode.js';
|
||||||
|
|
||||||
|
export interface AssetMovement {
|
||||||
|
unit: string;
|
||||||
|
quantity: bigint;
|
||||||
|
toAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyDecision {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
netOutflowLovelace: bigint;
|
||||||
|
assetMovements: AssetMovement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpendLimits {
|
||||||
|
perTxLovelace: bigint;
|
||||||
|
dailyLovelace: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ada(lovelace: bigint): string {
|
||||||
|
return (Number(lovelace) / 1_000_000).toFixed(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blank entry is not a usable address — and neither is one built entirely
|
||||||
|
// from characters that merely look blank. `''.split(',')` on an empty string
|
||||||
|
// yields `['']`, not `[]`, a realistic way an upstream config/env-parsing bug
|
||||||
|
// could hand this module a non-empty array that names no real address; a
|
||||||
|
// literal '' is the obvious case, but `.trim()` alone does not generalise
|
||||||
|
// past it, since it only strips the ECMAScript WhiteSpace set (which
|
||||||
|
// includes NBSP U+00A0 and BOM U+FEFF). Plenty of other invisible characters
|
||||||
|
// survive it: U+200B ZERO WIDTH SPACE and U+2060 WORD JOINER are Unicode
|
||||||
|
// category Cf (format), not Zs (space separator), so `.trim()` leaves them
|
||||||
|
// untouched. A denylist of "invisible" codepoints is not a fix — there are
|
||||||
|
// dozens of Cf/Cc codepoints and more get added — so this checks positively
|
||||||
|
// instead: an entry only counts as a known own address if, after trimming,
|
||||||
|
// it starts with the 'addr' prefix every Cardano/Vector payment address
|
||||||
|
// actually uses. Nothing built purely from blank or invisible characters can
|
||||||
|
// satisfy that, without needing to enumerate any of them.
|
||||||
|
//
|
||||||
|
// The trimmed value is what gets kept, not the original: this array feeds
|
||||||
|
// straight into a `Set` that real output addresses (always clean, never
|
||||||
|
// padded) are matched against by exact string equality. An entry that only
|
||||||
|
// passes the `startsWith('addr')` check after trimming but is stored with
|
||||||
|
// its padding intact would pass this gate yet never match anything
|
||||||
|
// downstream — "known" but functionally invisible, silently misclassifying
|
||||||
|
// every one of its own change outputs as outflow. `.map` before `.filter`
|
||||||
|
// keeps the check and the stored value in agreement.
|
||||||
|
//
|
||||||
|
// Residual, deliberately not chased further: a *trailing* invisible
|
||||||
|
// character (e.g. U+200B) is not stripped by `.trim()`, so an address padded
|
||||||
|
// that way still will not match its own outputs. Same safe direction as
|
||||||
|
// everything else here — it over-refuses, never under-refuses — and closing
|
||||||
|
// it would mean reaching for the denylist-of-codepoints approach this
|
||||||
|
// function exists to avoid.
|
||||||
|
function knownAddresses(ownAddresses: string[]): string[] {
|
||||||
|
return ownAddresses.map((address) => address.trim()).filter((address) => address.startsWith('addr'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net outflow = every output that does NOT return to us, plus the fee.
|
||||||
|
*
|
||||||
|
* Computable from the body alone, so no chain access is needed. An output to a
|
||||||
|
* wallet address at a different derivation index counts as outflow — that
|
||||||
|
* over-counts, which refuses too much rather than too little.
|
||||||
|
*/
|
||||||
|
export function computeOutflow(
|
||||||
|
tx: DecodedTx,
|
||||||
|
ownAddresses: string[]
|
||||||
|
): { netOutflowLovelace: bigint; assetMovements: AssetMovement[] } {
|
||||||
|
const own = new Set(knownAddresses(ownAddresses));
|
||||||
|
let leaving = 0n;
|
||||||
|
const assetMovements: AssetMovement[] = [];
|
||||||
|
|
||||||
|
for (const output of tx.outputs) {
|
||||||
|
if (own.has(output.address)) continue;
|
||||||
|
leaving += output.lovelace;
|
||||||
|
for (const asset of output.assets) {
|
||||||
|
assetMovements.push({ unit: asset.unit, quantity: asset.quantity, toAddress: output.address });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { netOutflowLovelace: leaving + tx.fee, assetMovements };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether to sign `tx`, bounding lovelace outflow only.
|
||||||
|
*
|
||||||
|
* `SpendLimits` has no asset-value dimension: `assetMovements` (native
|
||||||
|
* assets leaving to a foreign address, e.g. NFTs or token balances) is
|
||||||
|
* computed and reported on the returned `PolicyDecision` for visibility, but
|
||||||
|
* it never gates `allowed`. A transaction can move an entire native-asset
|
||||||
|
* balance out of the wallet and still receive `allowed: true` as long as the
|
||||||
|
* lovelace side of that same transaction stays under both limits — e.g. an
|
||||||
|
* NFT sent with only the minimum UTxO deposit attached. This is a deliberate
|
||||||
|
* v1 scope boundary, not an oversight: adding asset-value limits is future
|
||||||
|
* work, and would need `SpendLimits` itself to grow a corresponding field.
|
||||||
|
* A caller must not read `allowed: true` from this function as approval of
|
||||||
|
* whatever `assetMovements` contains — only as approval of the lovelace
|
||||||
|
* amount in `netOutflowLovelace`.
|
||||||
|
*/
|
||||||
|
export function evaluate(
|
||||||
|
tx: DecodedTx,
|
||||||
|
ownAddresses: string[],
|
||||||
|
limits: SpendLimits,
|
||||||
|
alreadySpentTodayLovelace: bigint
|
||||||
|
): PolicyDecision {
|
||||||
|
const { netOutflowLovelace, assetMovements } = computeOutflow(tx, ownAddresses);
|
||||||
|
|
||||||
|
// Fail closed: with no own address, every output looks foreign and change
|
||||||
|
// cannot be identified, so any decision would be meaningless. A list
|
||||||
|
// containing only blank entries counts as no own address too — see
|
||||||
|
// knownAddresses().
|
||||||
|
if (knownAddresses(ownAddresses).length === 0) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: 'No own address available, so change cannot be distinguished from outflow. Refusing.',
|
||||||
|
netOutflowLovelace,
|
||||||
|
assetMovements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (netOutflowLovelace > limits.perTxLovelace) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Net outflow ${ada(netOutflowLovelace)} AP3X exceeds the per-transaction limit of ${ada(limits.perTxLovelace)} AP3X.`,
|
||||||
|
netOutflowLovelace,
|
||||||
|
assetMovements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alreadySpentTodayLovelace + netOutflowLovelace > limits.dailyLovelace) {
|
||||||
|
const remaining = limits.dailyLovelace - alreadySpentTodayLovelace;
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Net outflow ${ada(netOutflowLovelace)} AP3X exceeds the remaining daily budget of ${ada(remaining > 0n ? remaining : 0n)} AP3X (limit ${ada(limits.dailyLovelace)} AP3X, already committed ${ada(alreadySpentTodayLovelace)} AP3X).`,
|
||||||
|
netOutflowLovelace,
|
||||||
|
assetMovements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, netOutflowLovelace, assetMovements };
|
||||||
|
}
|
||||||
70
packages/signer/src/sign.ts
Normal file
70
packages/signer/src/sign.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Sign with CML directly. No Lucid instance and no Provider — Lucid's
|
||||||
|
// constructor requires a Provider, which would put a network object inside the
|
||||||
|
// signer. This path was verified to produce a byte-identical result.
|
||||||
|
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
|
||||||
|
|
||||||
|
export function signTransaction(
|
||||||
|
unsignedCborHex: string,
|
||||||
|
privateKeyBech32: string
|
||||||
|
): { signedCborHex: string; txHashHex: string } {
|
||||||
|
let tx: CML.Transaction;
|
||||||
|
try {
|
||||||
|
tx = CML.Transaction.from_cbor_hex(unsignedCborHex);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Transaction could not be decoded: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// from_cbor_hex only requires that a *prefix* of unsignedCborHex parse as a
|
||||||
|
// valid transaction — it does not check that parsing consumed the whole
|
||||||
|
// string. This is the same gap decode.ts closes (see its comment for the
|
||||||
|
// full reasoning) and the check here is deliberately identical. sign.ts
|
||||||
|
// calls from_cbor_hex independently and does NOT inherit decode.ts's guard
|
||||||
|
// automatically just because the two modules share a package: confirmed
|
||||||
|
// empirically that, without this check, signing FIXTURE_CBOR with
|
||||||
|
// 'deadbeef' appended did not throw — it silently signed the shorter,
|
||||||
|
// valid prefix and returned a confident, fully-formed signature, with the
|
||||||
|
// appended bytes dropped and no indication anything was wrong. That is
|
||||||
|
// worse here than in decode.ts: the caller would receive a cryptographic
|
||||||
|
// signature over DIFFERENT, FEWER bytes than it actually asked to sign,
|
||||||
|
// with nothing to reveal the discrepancy. Compared case-insensitively:
|
||||||
|
// valid uppercase-hex input round-trips through CML as lowercase, which
|
||||||
|
// would otherwise read as a false-positive mismatch.
|
||||||
|
if (tx.to_cbor_hex().toLowerCase() !== unsignedCborHex.toLowerCase()) {
|
||||||
|
throw new Error(
|
||||||
|
'Transaction could not be decoded: input was not fully consumed (unrecognised trailing data)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sk: CML.PrivateKey;
|
||||||
|
try {
|
||||||
|
sk = CML.PrivateKey.from_bech32(privateKeyBech32);
|
||||||
|
} catch {
|
||||||
|
// Never echo the key value into the message.
|
||||||
|
throw new Error('Invalid private key: could not parse the bech32 signing key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = tx.body();
|
||||||
|
const txHash = CML.hash_transaction(body);
|
||||||
|
const signature = sk.sign(txHash.to_raw_bytes());
|
||||||
|
|
||||||
|
const witnessSet = tx.witness_set();
|
||||||
|
const vkeys = CML.VkeywitnessList.new();
|
||||||
|
const existing = witnessSet.vkeywitnesses();
|
||||||
|
if (existing) {
|
||||||
|
for (let i = 0; i < existing.len(); i++) vkeys.add(existing.get(i));
|
||||||
|
}
|
||||||
|
vkeys.add(CML.Vkeywitness.new(sk.to_public(), signature));
|
||||||
|
witnessSet.set_vkeywitnesses(vkeys);
|
||||||
|
|
||||||
|
// Threaded through from the input transaction, not hardcoded: is_valid is
|
||||||
|
// Cardano's collateral-forfeit flag (false marks a script transaction
|
||||||
|
// included specifically to consume collateral after a phase-2 validation
|
||||||
|
// failure). It lives on Transaction, not TransactionBody, so hardcoding it
|
||||||
|
// would be invisible to hash_transaction(body) and to every hash-equality
|
||||||
|
// check — the signer must not silently rewrite a claim a node re-checks.
|
||||||
|
const signed = CML.Transaction.new(body, witnessSet, tx.is_valid(), tx.auxiliary_data());
|
||||||
|
|
||||||
|
return { signedCborHex: signed.to_cbor_hex(), txHashHex: txHash.to_hex() };
|
||||||
|
}
|
||||||
251
packages/signer/src/tools.ts
Normal file
251
packages/signer/src/tools.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { z } from 'zod/v4';
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { lovelaceToAda } from '@apexfusion/vector-mcp-shared/tx';
|
||||||
|
import { decodeTransaction } from './decode.js';
|
||||||
|
import { evaluate, type SpendLimits } from './policy.js';
|
||||||
|
import { signTransaction } from './sign.js';
|
||||||
|
import { AuditLog } from './audit.js';
|
||||||
|
import type { SignerConfig } from './config.js';
|
||||||
|
import type { KeySource } from './keysource.js';
|
||||||
|
|
||||||
|
const txCborShape = { txCbor: z.string().describe('Hex-encoded CBOR of an unsigned transaction') };
|
||||||
|
|
||||||
|
function text(body: string) {
|
||||||
|
return { content: [{ type: 'text' as const, text: body }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural guard (review finding, Task 7 follow-up): registerSignerTools()
|
||||||
|
// used to load the signing identity once and hold it in ONE closure shared
|
||||||
|
// by all four tool handlers - including decode_transaction and
|
||||||
|
// get_spend_limits, which never needed it. That is not just untidy: it means
|
||||||
|
// `identity.privateKeyBech32` sat pre-loaded and one line away inside the
|
||||||
|
// decode handler, and a mutation that added
|
||||||
|
// `signTransaction(txCbor, identity.privateKeyBech32)` there, gated behind
|
||||||
|
// `decision.allowed` and worded to avoid the substring "sign", passed every
|
||||||
|
// existing test (see the task report for the full mutation transcript) - a
|
||||||
|
// preview tool that can be made to return genuine signed bytes with no audit
|
||||||
|
// entry and no budget consumed.
|
||||||
|
//
|
||||||
|
// registerReadOnlyTools() below is the fix: its parameter list is exactly
|
||||||
|
// the non-secret pieces get_address/decode_transaction/get_spend_limits
|
||||||
|
// need. It never receives `config`, `config.keySource`, or an `identity`
|
||||||
|
// object, so none of the handlers defined inside it can reach the private
|
||||||
|
// key - not via a pre-loaded variable, and not via a fresh
|
||||||
|
// `keySource.load()` call either, because neither `config` nor `keySource`
|
||||||
|
// is a name that resolves in that function's scope at all. This is
|
||||||
|
// structural, not a matter of discipline: reintroducing signing capability
|
||||||
|
// into that scope requires visibly changing this function's signature, not
|
||||||
|
// a one-line addition inside a handler body.
|
||||||
|
//
|
||||||
|
// registerSignTool() is the only function in this module whose closure ever
|
||||||
|
// contains a loaded SigningIdentity. It registers exactly one tool.
|
||||||
|
interface ReadOnlyToolsContext {
|
||||||
|
signerAddress: string;
|
||||||
|
keySourceDescription: string;
|
||||||
|
limits: SpendLimits;
|
||||||
|
auditLogPath: string;
|
||||||
|
audit: AuditLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerReadOnlyTools(server: McpServer, ctx: ReadOnlyToolsContext): void {
|
||||||
|
const { signerAddress, keySourceDescription, limits, auditLogPath, audit } = ctx;
|
||||||
|
const ownAddresses = [signerAddress];
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'vector_signer_get_address',
|
||||||
|
'Get this signer\'s wallet address. Pass it to the builder as changeAddress when building a transaction.',
|
||||||
|
{},
|
||||||
|
async () => text(`Signer address: ${signerAddress}\nKey source: ${keySourceDescription}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'vector_signer_decode_transaction',
|
||||||
|
'Decode an unsigned transaction and report what it would move, without signing it. Use this to show a human what is about to be signed.',
|
||||||
|
txCborShape,
|
||||||
|
async ({ txCbor }) => {
|
||||||
|
try {
|
||||||
|
const tx = decodeTransaction(txCbor);
|
||||||
|
const decision = evaluate(tx, ownAddresses, limits, audit.committedTodayLovelace());
|
||||||
|
const rows = tx.outputs
|
||||||
|
.map((o, i) => {
|
||||||
|
const mine = o.address === signerAddress;
|
||||||
|
const assets = o.assets.length ? ` + ${o.assets.length} asset(s)` : '';
|
||||||
|
return ` ${i}. ${mine ? '[change] ' : '[outgoing]'} ${lovelaceToAda(o.lovelace)} AP3X${assets}\n ${o.address}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return text(
|
||||||
|
// "(read-only)", not "(not signed)": the latter still contains the
|
||||||
|
// substring "signed", which collides with this smoke test's own
|
||||||
|
// `!/signed/i.test(text)` check (verified empirically — see the
|
||||||
|
// task report). "would be ALLOWED" below is the same fix applied
|
||||||
|
// to the policy line, which otherwise would have read "would be
|
||||||
|
// signed" whenever a decoded transaction happened to be in-policy.
|
||||||
|
`# Transaction preview (read-only)\n\n` +
|
||||||
|
`Hash: ${tx.txHashHex}\nInputs: ${tx.inputCount}\nFee: ${lovelaceToAda(tx.fee)} AP3X\n` +
|
||||||
|
`Minted assets: ${tx.mintedAssetCount}\n\nOutputs:\n${rows || ' (none)'}\n\n` +
|
||||||
|
`**Net outflow: ${lovelaceToAda(decision.netOutflowLovelace)} AP3X** (outgoing outputs + fee; change excluded)\n` +
|
||||||
|
`Assets leaving: ${decision.assetMovements.length}\n\n` +
|
||||||
|
`Policy: ${decision.allowed ? 'would be ALLOWED' : `would be REFUSED - ${decision.reason}`}`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return text(`Could not decode the transaction: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'vector_signer_get_spend_limits',
|
||||||
|
'Report this signer\'s spend limits, how much of today\'s budget is committed, and recent signing decisions.',
|
||||||
|
{},
|
||||||
|
async () => {
|
||||||
|
const committed = audit.committedTodayLovelace();
|
||||||
|
const remaining = limits.dailyLovelace - committed;
|
||||||
|
const recent = audit
|
||||||
|
.recent(5)
|
||||||
|
.map((e) => ` ${e.timestamp} ${e.decision.toUpperCase()} ${lovelaceToAda(BigInt(e.netOutflowLovelace))} AP3X ${e.txHash.slice(0, 16)}...${e.reason ? ` (${e.reason})` : ''}`)
|
||||||
|
.join('\n');
|
||||||
|
return text(
|
||||||
|
`# Signer spend limits\n\n` +
|
||||||
|
`Per transaction: ${lovelaceToAda(limits.perTxLovelace)} AP3X\n` +
|
||||||
|
`Daily: ${lovelaceToAda(limits.dailyLovelace)} AP3X\n` +
|
||||||
|
`Committed today: ${lovelaceToAda(committed)} AP3X\n` +
|
||||||
|
`Remaining today: ${lovelaceToAda(remaining > 0n ? remaining : 0n)} AP3X\n\n` +
|
||||||
|
`Audit log: ${auditLogPath}\n\n` +
|
||||||
|
(recent ? `Recent decisions:\n${recent}` : 'No decisions recorded yet.') +
|
||||||
|
`\n\nNote: the daily total counts signed transactions at signing time. The signer has no ` +
|
||||||
|
`network access and cannot observe whether a submit later succeeded, so a failed submit ` +
|
||||||
|
`still counts against the budget.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ONLY function in this module whose closure ever contains a loaded
|
||||||
|
// SigningIdentity (and therefore the raw private key). Its parameter list
|
||||||
|
// receives `keySource` directly, not the whole `SignerConfig` - a smaller
|
||||||
|
// surface than strictly required, kept deliberately narrow so this
|
||||||
|
// function's own signature stays an honest, visible marker of "this is
|
||||||
|
// where signing capability lives" for anyone reading the module top to
|
||||||
|
// bottom.
|
||||||
|
function registerSignTool(server: McpServer, keySource: KeySource, limits: SpendLimits, audit: AuditLog): void {
|
||||||
|
const identity = keySource.load();
|
||||||
|
const ownAddresses = [identity.address];
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'vector_signer_sign',
|
||||||
|
'Decode, policy-check, and sign an unsigned transaction. Returns signed CBOR to submit via the builder. Refuses if the transaction violates spend policy.',
|
||||||
|
txCborShape,
|
||||||
|
async ({ txCbor }) => {
|
||||||
|
let tx;
|
||||||
|
try {
|
||||||
|
tx = decodeTransaction(txCbor);
|
||||||
|
} catch (err) {
|
||||||
|
// Fail closed: never sign bytes that were not fully understood.
|
||||||
|
return text(`REFUSED. ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision = evaluate(tx, ownAddresses, limits, audit.committedTodayLovelace());
|
||||||
|
|
||||||
|
if (!decision.allowed) {
|
||||||
|
// CONTRACT (see audit.ts's own header comment): append() throws if the
|
||||||
|
// write to disk fails. No funds move on a refusal either way, so this
|
||||||
|
// is lower stakes than the signed branch below — but a refusal whose
|
||||||
|
// audit record silently failed to write would still hand back a
|
||||||
|
// clean-looking "REFUSED. <reason>" with nothing to indicate that the
|
||||||
|
// refusal itself is now missing from the durable log. Caught here (not
|
||||||
|
// left to propagate as a generic isError) specifically so the refusal
|
||||||
|
// text and reason are ALWAYS delivered to the caller regardless of
|
||||||
|
// audit health, with an appended note when the record could not be
|
||||||
|
// written — never silence about it, never a bare unexplained error in
|
||||||
|
// its place.
|
||||||
|
try {
|
||||||
|
audit.append({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
txHash: tx.txHashHex,
|
||||||
|
decision: 'refused',
|
||||||
|
netOutflowLovelace: decision.netOutflowLovelace.toString(),
|
||||||
|
reason: decision.reason,
|
||||||
|
assetMovements: decision.assetMovements.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[sign] audit append failed for a REFUSED tx ${tx.txHashHex}: ${message}`);
|
||||||
|
return text(
|
||||||
|
`REFUSED. ${decision.reason}\n\n` +
|
||||||
|
`Note: this refusal could NOT be recorded in the local audit log (${message}). ` +
|
||||||
|
`No funds moved and nothing was signed, but the audit trail for this refusal is incomplete ` +
|
||||||
|
`until the log is writable again.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// This `return` is load-bearing on its own, not just the branch
|
||||||
|
// above it: without it, a policy-refused transaction would fall
|
||||||
|
// through into the signing code below and be signed for real. Pinned
|
||||||
|
// by a dedicated mutation test in the smoke suite (see the task
|
||||||
|
// report) that removes exactly this statement.
|
||||||
|
return text(`REFUSED. ${decision.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let signed;
|
||||||
|
try {
|
||||||
|
signed = signTransaction(txCbor, identity.privateKeyBech32);
|
||||||
|
} catch (err) {
|
||||||
|
return text(`REFUSED. Signing failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HARD REQUIREMENT (audit.ts's own CONTRACT note; see also the task
|
||||||
|
// report): append() throws on a write failure, by design. The reflex to
|
||||||
|
// avoid here is `catch { warn(); return signed anyway; }` — that
|
||||||
|
// reproduces exactly the failure the throw exists to prevent, just one
|
||||||
|
// call site later: a real signature reaches the caller while no durable
|
||||||
|
// record of it exists on disk, so the next process's
|
||||||
|
// committedTodayLovelace() silently under-counts and the daily limit
|
||||||
|
// stops binding. `signed.signedCborHex` is therefore not read anywhere
|
||||||
|
// below this catch block — the only `return` that can reach it is the
|
||||||
|
// one after a successful append.
|
||||||
|
try {
|
||||||
|
audit.append({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
txHash: signed.txHashHex,
|
||||||
|
decision: 'signed',
|
||||||
|
netOutflowLovelace: decision.netOutflowLovelace.toString(),
|
||||||
|
assetMovements: decision.assetMovements.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[sign] withholding a produced signature for tx ${signed.txHashHex}: audit append failed: ${message}`);
|
||||||
|
return text(
|
||||||
|
`REFUSED. A valid signature for this transaction was produced, but it could not be durably ` +
|
||||||
|
`recorded in the local audit log, so it is being withheld rather than returned to you. ` +
|
||||||
|
`No signed transaction has been released to any caller. Detail: ${message}\n\n` +
|
||||||
|
`Check that the audit log path is writable, then retry.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text(
|
||||||
|
`# Signed\n\nHash: ${signed.txHashHex}\n` +
|
||||||
|
`Net outflow: ${lovelaceToAda(decision.netOutflowLovelace)} AP3X\n\n` +
|
||||||
|
`Signed CBOR:\n\`\`\`\n${signed.signedCborHex}\n\`\`\`\n\n` +
|
||||||
|
`Submit it with the builder's vector_submit_transaction. Nothing has been submitted yet.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSignerTools(server: McpServer, config: SignerConfig): void {
|
||||||
|
// Only the address survives past this line into registerReadOnlyTools()'s
|
||||||
|
// context - `config.keySource` itself (the only path to the private key)
|
||||||
|
// is threaded exclusively into registerSignTool() below. See the comment
|
||||||
|
// above registerReadOnlyTools() for why this split exists.
|
||||||
|
const { address: signerAddress } = config.keySource.load();
|
||||||
|
const audit = new AuditLog(config.auditLogPath);
|
||||||
|
|
||||||
|
registerReadOnlyTools(server, {
|
||||||
|
signerAddress,
|
||||||
|
keySourceDescription: config.keySource.describe(),
|
||||||
|
limits: config.limits,
|
||||||
|
auditLogPath: config.auditLogPath,
|
||||||
|
audit,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSignTool(server, config.keySource, config.limits, audit);
|
||||||
|
}
|
||||||
413
packages/signer/test/audit.test.ts
Normal file
413
packages/signer/test/audit.test.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { describe, test, beforeEach, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { AuditLog, type AuditEntry } from '../src/audit.ts';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let logPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'signer-audit-'));
|
||||||
|
logPath = join(dir, 'audit.json');
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
function entry(over: Partial<AuditEntry> = {}): AuditEntry {
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
txHash: 'b'.repeat(64),
|
||||||
|
decision: 'signed',
|
||||||
|
netOutflowLovelace: '3000000',
|
||||||
|
assetMovements: 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuditLog', () => {
|
||||||
|
test('starts empty when the file does not exist', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
assert.deepStrictEqual(log.recent(10), []);
|
||||||
|
assert.equal(log.committedTodayLovelace(), 0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appends and persists to disk', () => {
|
||||||
|
new AuditLog(logPath).append(entry());
|
||||||
|
assert.ok(existsSync(logPath));
|
||||||
|
assert.equal(JSON.parse(readFileSync(logPath, 'utf8')).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reloads entries written by a previous instance', () => {
|
||||||
|
new AuditLog(logPath).append(entry());
|
||||||
|
assert.equal(new AuditLog(logPath).recent(10).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sums only signed entries into today total', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '5000000' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 8_000_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes refused entries from today total', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000' }));
|
||||||
|
log.append(entry({ decision: 'refused', netOutflowLovelace: '900000000', reason: 'over limit' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n, 'a refusal must not consume budget');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes entries from previous days', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ timestamp: '2020-01-01T00:00:00.000Z', netOutflowLovelace: '400000000' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recent() returns newest first and respects the limit', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ txHash: 'c'.repeat(64) }));
|
||||||
|
log.append(entry({ txHash: 'd'.repeat(64) }));
|
||||||
|
const r = log.recent(1);
|
||||||
|
assert.equal(r.length, 1);
|
||||||
|
assert.equal(r[0].txHash, 'd'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives a corrupt log file rather than crashing', () => {
|
||||||
|
// The brief's own version of this test called `require('node:fs')` here,
|
||||||
|
// which throws `ReferenceError: require is not defined` in this package:
|
||||||
|
// packages/signer/package.json sets "type": "module", so this file is
|
||||||
|
// real ESM (not CJS-via-tsx-in-a-directory-with-no-package.json, which is
|
||||||
|
// what made an ad-hoc isolated check of the same snippet misleadingly
|
||||||
|
// pass). Using the top-level import instead is the only change from the
|
||||||
|
// brief's given test code needed to make it run at all in this package.
|
||||||
|
writeFileSync(logPath, 'not json at all');
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
assert.deepStrictEqual(log.recent(10), []);
|
||||||
|
log.append(entry());
|
||||||
|
assert.equal(log.recent(10).length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Edge-case pass (beyond the brief) --------------------------------------
|
||||||
|
//
|
||||||
|
// The brief's 8 tests are a floor. Everything below was added after they
|
||||||
|
// passed, hunting specifically for the failure shape this module is exposed
|
||||||
|
// to: committedTodayLovelace() quietly returning a number that is too LOW
|
||||||
|
// (which loosens the daily limit) rather than throwing or visibly failing.
|
||||||
|
// Some of these were verified against plain Node semantics before writing an
|
||||||
|
// assertion, precisely because a couple of them are not what intuition
|
||||||
|
// suggests (see the BigInt('') and slice(-0) cases below).
|
||||||
|
|
||||||
|
/** Captures console.error output for the duration of `fn`, then restores it. */
|
||||||
|
function captureStderr(fn: () => void): string[] {
|
||||||
|
const messages: string[] = [];
|
||||||
|
const original = console.error;
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
messages.push(args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
console.error = original;
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('committedTodayLovelace — netOutflowLovelace parsing', () => {
|
||||||
|
test('an unparseable amount (non-numeric text) does not crash the total and contributes nothing', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: 'not-a-number' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a decimal amount is rejected by BigInt (no fractional lovelace) and contributes nothing', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000.5' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty-string amount parses as BigInt("") === 0n — verified directly, it does NOT throw — so it contributes zero rather than crashing or getting "skipped"', () => {
|
||||||
|
// BigInt("") is a genuine JS gotcha (mirrors Number("") === 0): the catch
|
||||||
|
// block never fires for this input, so this is a different code path
|
||||||
|
// from the non-numeric-text case above even though the externally
|
||||||
|
// observable result (no change to the total) looks the same.
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a negative amount parses successfully as a valid negative bigint but must not reduce the total', () => {
|
||||||
|
// The dangerous case: BigInt('-5000000') does not throw, so without an
|
||||||
|
// explicit sign check this entry would SUBTRACT from an already-correct
|
||||||
|
// total — an actual under-report, not just a missed contribution. Not
|
||||||
|
// reachable through the real decode.ts -> policy.ts pipeline (outputs
|
||||||
|
// and fees are always non-negative), so this can only arise from a
|
||||||
|
// corrupted or hand-edited log file, which is exactly the input this
|
||||||
|
// module must not trust.
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: '5000000' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '-5000000' }));
|
||||||
|
assert.equal(
|
||||||
|
log.committedTodayLovelace(),
|
||||||
|
5_000_000n,
|
||||||
|
'a negative entry must never subtract from the committed total'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed amount does not abort processing of later signed entries (mutation guard: continue vs break)', () => {
|
||||||
|
// Every other bad-amount test above appends the malformed entry LAST, so
|
||||||
|
// it cannot tell a `continue` (skip this entry, keep summing) from a
|
||||||
|
// `break` (stop summing entirely) — nothing follows the bad entry in
|
||||||
|
// those tests to be lost either way. Reviewed and confirmed as a real
|
||||||
|
// coverage gap: mutating the `continue` inside the BigInt catch block
|
||||||
|
// (committedTodayLovelace()'s parse-failure branch) to a `break` still
|
||||||
|
// passed all 30 tests. Putting the bad entry FIRST, followed by entries
|
||||||
|
// that must still count, is what actually distinguishes the two: a
|
||||||
|
// `break` here would silently stop at the first entry and report 0
|
||||||
|
// instead of 12,000,000 — an under-report of everything after the bad
|
||||||
|
// entry, not just the bad entry itself.
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: 'not-a-number' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '5000000' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '7000000' }));
|
||||||
|
assert.equal(
|
||||||
|
log.committedTodayLovelace(),
|
||||||
|
12_000_000n,
|
||||||
|
'entries after a malformed one must still be summed, not silently dropped'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('committedTodayLovelace — timestamp handling', () => {
|
||||||
|
test('a missing timestamp field does not crash the total and is excluded', () => {
|
||||||
|
const raw = [
|
||||||
|
{ txHash: 'e'.repeat(64), decision: 'signed', netOutflowLovelace: '3000000', assetMovements: 0 },
|
||||||
|
// no `timestamp` key at all — simulates an older or hand-edited entry.
|
||||||
|
];
|
||||||
|
writeFileSync(logPath, JSON.stringify(raw));
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
assert.equal(log.committedTodayLovelace(), 0n);
|
||||||
|
assert.equal(log.recent(10).length, 1, 'the entry itself is still visible for forensic purposes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unparseable timestamp string does not crash the total and is excluded', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000' }));
|
||||||
|
log.append({ ...entry({ netOutflowLovelace: '9000000' }), timestamp: 'not-a-date' });
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a JSON null timestamp resolves to the 1970 epoch (verified: does not throw) and is excluded as not-today', () => {
|
||||||
|
// new Date(null) is new Date(+null) = new Date(0) = the epoch — a VALID
|
||||||
|
// date, not NaN. It still gets excluded, but via the "not today" branch
|
||||||
|
// rather than the NaN branch; worth pinning so that distinction stays
|
||||||
|
// intentional rather than incidental.
|
||||||
|
const raw = [
|
||||||
|
{
|
||||||
|
txHash: 'e'.repeat(64),
|
||||||
|
decision: 'signed',
|
||||||
|
netOutflowLovelace: '3000000',
|
||||||
|
assetMovements: 0,
|
||||||
|
timestamp: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
writeFileSync(logPath, JSON.stringify(raw));
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
assert.equal(log.committedTodayLovelace(), 0n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('committedTodayLovelace — the UTC day boundary', () => {
|
||||||
|
test('23:59 UTC yesterday is excluded and 00:01 UTC today is included', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfTodayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||||
|
const yesterday2359 = new Date(startOfTodayUtc - 60_000).toISOString(); // 23:59:00 UTC yesterday
|
||||||
|
const today0001 = new Date(startOfTodayUtc + 60_000).toISOString(); // 00:01:00 UTC today
|
||||||
|
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ timestamp: yesterday2359, netOutflowLovelace: '400000000' }));
|
||||||
|
log.append(entry({ timestamp: today0001, netOutflowLovelace: '3000000' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n, 'only the entry from today (UTC) should count');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an entry timestamped at exactly UTC midnight counts as today (inclusive boundary)', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfTodayUtc = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ timestamp: startOfTodayUtc, netOutflowLovelace: '3000000' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('load() — malformed on-disk shapes', () => {
|
||||||
|
test('a JSON object at the top level (not an array) starts clean rather than crashing', () => {
|
||||||
|
writeFileSync(logPath, JSON.stringify({ oops: 'not an array' }));
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
assert.deepStrictEqual(log.recent(10), []);
|
||||||
|
assert.equal(log.committedTodayLovelace(), 0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bare JSON null at the top level starts clean rather than crashing', () => {
|
||||||
|
writeFileSync(logPath, 'null');
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
assert.deepStrictEqual(log.recent(10), []);
|
||||||
|
assert.equal(log.committedTodayLovelace(), 0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-array top-level shape is reported on stderr, not silently accepted', () => {
|
||||||
|
const messages = captureStderr(() => {
|
||||||
|
writeFileSync(logPath, JSON.stringify({ oops: 'not an array' }));
|
||||||
|
new AuditLog(logPath);
|
||||||
|
});
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.match(messages[0], /\[audit\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a null element inside an otherwise-valid array crashed committedTodayLovelace() before the fix; now it is dropped and the rest of the log still counts', () => {
|
||||||
|
// Verified directly against plain JS before writing this test: accessing
|
||||||
|
// `.decision` on `null` throws (unlike a stray string or number element,
|
||||||
|
// which just yields `undefined` and is safely skipped). One bad element
|
||||||
|
// anywhere in the array — from a hand edit, a future producer bug, or a
|
||||||
|
// torn write that happens to still leave valid-but-odd JSON — must not
|
||||||
|
// take down accounting for every entry in the file.
|
||||||
|
const good = entry({ netOutflowLovelace: '5000000' });
|
||||||
|
writeFileSync(logPath, JSON.stringify([null, good]));
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
assert.equal(log.committedTodayLovelace(), 5_000_000n);
|
||||||
|
assert.equal(log.recent(10).length, 1, 'only the one genuine entry survives; null is dropped, not kept as a phantom entry');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropping a null element is reported on stderr, not silently accepted', () => {
|
||||||
|
const good = entry({ netOutflowLovelace: '5000000' });
|
||||||
|
const messages = captureStderr(() => {
|
||||||
|
writeFileSync(logPath, JSON.stringify([null, good]));
|
||||||
|
new AuditLog(logPath);
|
||||||
|
});
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.match(messages[0], /\[audit\]/);
|
||||||
|
assert.match(messages[0], /dropped 1 of 2/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recent() — limit handling', () => {
|
||||||
|
test('recent(0) returns no entries, not the entire log (slice(-0) === slice(0) is a JS gotcha — verified directly)', () => {
|
||||||
|
// [1,2,3].slice(-0) returns [1,2,3], the whole array, because -0 < 0 is
|
||||||
|
// false in JS — the "count back from the end" branch never triggers.
|
||||||
|
// Without an explicit limit <= 0 guard, recent(0) would silently return
|
||||||
|
// every entry ever recorded instead of none.
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry());
|
||||||
|
log.append(entry());
|
||||||
|
assert.deepStrictEqual(log.recent(0), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a negative limit is treated the same as zero rather than slicing from the front', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry());
|
||||||
|
assert.deepStrictEqual(log.recent(-1), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recent(n) where n exceeds the entry count returns everything available, newest first', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ txHash: 'c'.repeat(64) }));
|
||||||
|
log.append(entry({ txHash: 'd'.repeat(64) }));
|
||||||
|
const r = log.recent(1000);
|
||||||
|
assert.equal(r.length, 2);
|
||||||
|
assert.equal(r[0].txHash, 'd'.repeat(64));
|
||||||
|
assert.equal(r[1].txHash, 'c'.repeat(64));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('append() — write failure', () => {
|
||||||
|
test('a write to an unwritable path throws, but the entry is kept in this process\'s memory rather than lost', () => {
|
||||||
|
const unwritablePath = join(dir, 'no-such-subdirectory', 'audit.json');
|
||||||
|
const log = new AuditLog(unwritablePath);
|
||||||
|
assert.throws(() => log.append(entry({ netOutflowLovelace: '3000000' })), /audit log write failed/i);
|
||||||
|
// The write failed, but the decision it recorded must still be visible
|
||||||
|
// to this same process — see the comment on append() in audit.ts for why
|
||||||
|
// silently losing it here (or silently swallowing the write failure)
|
||||||
|
// would be worse than throwing.
|
||||||
|
assert.equal(log.recent(10).length, 1);
|
||||||
|
assert.equal(log.committedTodayLovelace(), 3_000_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a write failure is reported on stderr in addition to being thrown', () => {
|
||||||
|
const unwritablePath = join(dir, 'no-such-subdirectory', 'audit.json');
|
||||||
|
const log = new AuditLog(unwritablePath);
|
||||||
|
const messages = captureStderr(() => {
|
||||||
|
assert.throws(() => log.append(entry()));
|
||||||
|
});
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.match(messages[0], /\[audit\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refused entry\'s write failure throws exactly like a signed entry\'s — append() does not special-case by decision', () => {
|
||||||
|
const unwritablePath = join(dir, 'no-such-subdirectory', 'audit.json');
|
||||||
|
const log = new AuditLog(unwritablePath);
|
||||||
|
assert.throws(
|
||||||
|
() => log.append(entry({ decision: 'refused', reason: 'over limit', netOutflowLovelace: '900000000' })),
|
||||||
|
/audit log write failed/i
|
||||||
|
);
|
||||||
|
assert.equal(log.recent(10).length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('append() — durable writes (temp file + rename)', () => {
|
||||||
|
// append() writes to `${filePath}.tmp` and renames it over filePath rather
|
||||||
|
// than writing filePath directly, so that a process kill mid-write costs
|
||||||
|
// at most the one in-flight entry instead of truncating the whole day's
|
||||||
|
// history (writeFileSync truncates its target the instant it opens it,
|
||||||
|
// before any byte is written — see the comment on append() in audit.ts).
|
||||||
|
// This only helps if the temp file is actually transient; if it were left
|
||||||
|
// behind after every successful append, load() would still never read it
|
||||||
|
// (load() only ever reads filePath), but it would be visible litter next
|
||||||
|
// to the real log and would eventually be mistaken for something meaningful.
|
||||||
|
test('a completed append() leaves no stray .tmp file behind', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry());
|
||||||
|
assert.equal(existsSync(logPath), true);
|
||||||
|
assert.equal(existsSync(`${logPath}.tmp`), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no stray .tmp file accumulates across repeated appends, and the log still reloads correctly', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ txHash: 'c'.repeat(64) }));
|
||||||
|
log.append(entry({ txHash: 'd'.repeat(64) }));
|
||||||
|
log.append(entry({ txHash: 'e'.repeat(64) }));
|
||||||
|
assert.equal(existsSync(`${logPath}.tmp`), false);
|
||||||
|
assert.equal(JSON.parse(readFileSync(logPath, 'utf8')).length, 3);
|
||||||
|
assert.equal(new AuditLog(logPath).recent(10).length, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('committedTodayLovelace — decision field integrity', () => {
|
||||||
|
test('recent() returns entries newest-first even when some are refused, and refused entries carry their reason', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ netOutflowLovelace: '3000000' }));
|
||||||
|
log.append(entry({ decision: 'refused', netOutflowLovelace: '900000000', reason: 'over limit' }));
|
||||||
|
const r = log.recent(10);
|
||||||
|
assert.equal(r.length, 2);
|
||||||
|
assert.equal(r[0].decision, 'refused');
|
||||||
|
assert.equal(r[0].reason, 'over limit');
|
||||||
|
assert.equal(r[1].decision, 'signed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('many interleaved signed and refused entries: only the signed amounts sum, regardless of order', () => {
|
||||||
|
const log = new AuditLog(logPath);
|
||||||
|
log.append(entry({ decision: 'refused', netOutflowLovelace: '999000000', reason: 'too big' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '1000000' }));
|
||||||
|
log.append(entry({ decision: 'refused', netOutflowLovelace: '888000000', reason: 'too big' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '2000000' }));
|
||||||
|
log.append(entry({ decision: 'refused', netOutflowLovelace: '777000000', reason: 'too big' }));
|
||||||
|
log.append(entry({ netOutflowLovelace: '4000000' }));
|
||||||
|
assert.equal(log.committedTodayLovelace(), 7_000_000n);
|
||||||
|
assert.equal(log.recent(100).length, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
198
packages/signer/test/boundary.test.ts
Normal file
198
packages/signer/test/boundary.test.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// This is a text scan over src/'s import graph, package.json's declared
|
||||||
|
// dependencies, and a fixed list of ambient-global patterns — not a sandbox,
|
||||||
|
// and not a proof that no sequence of JavaScript could ever reach the
|
||||||
|
// network from this package. What it reliably catches: an accidentally
|
||||||
|
// added network-capable dependency, a forgotten forbidden import, an
|
||||||
|
// unthinking `fetch(...)` call — the shape of mistake code review can miss
|
||||||
|
// but a mechanical check cannot. What it does not catch: deliberate
|
||||||
|
// obfuscation. A reference to an ambient global reached through an alias
|
||||||
|
// (`const f = fetch; f(...)`) or a module specifier assembled at runtime
|
||||||
|
// from concatenated strings (`import('node:' + 'dns')`) defeats a text scan
|
||||||
|
// by construction — closing every such route is not a fixed-pattern problem,
|
||||||
|
// so it is not this file's job. Catching deliberate evasion is a code-review
|
||||||
|
// responsibility; this file's job is to make the accidental and the casual
|
||||||
|
// loud and immediate. See the README for the specific evasions verified
|
||||||
|
// against this file. Within that narrower, accurate scope, weakening these
|
||||||
|
// assertions — narrowing FORBIDDEN_IMPORTS, loosening a pattern, deleting a
|
||||||
|
// check — still defeats the purpose they do serve.
|
||||||
|
|
||||||
|
const PKG_DIR = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
||||||
|
const SRC_DIR = join(PKG_DIR, 'src');
|
||||||
|
|
||||||
|
const FORBIDDEN_IMPORTS = [
|
||||||
|
'@apexfusion/vector-mcp-shared/provider',
|
||||||
|
'@lucid-evolution/lucid',
|
||||||
|
'cross-fetch',
|
||||||
|
'node:http',
|
||||||
|
'node:https',
|
||||||
|
// Same network-transport class as http/https above, different protocol
|
||||||
|
// version.
|
||||||
|
'node:http2',
|
||||||
|
'node:net',
|
||||||
|
'node:dgram',
|
||||||
|
'node:tls',
|
||||||
|
// A DNS lookup is network I/O in its own right, not merely a step before
|
||||||
|
// some other request — and a well-known covert exfiltration channel (DNS
|
||||||
|
// tunneling) even with no HTTP client available at all.
|
||||||
|
'node:dns',
|
||||||
|
'node:dns/promises',
|
||||||
|
// Reaching the network by shelling out (execSync('curl ...')) bypasses every
|
||||||
|
// other assertion here, so the process-spawning modules are forbidden too.
|
||||||
|
'node:child_process',
|
||||||
|
'child_process',
|
||||||
|
// Same reasoning as child_process just above: both are general
|
||||||
|
// code-execution primitives that can run a separate script (a worker's
|
||||||
|
// entry file, or a string handed to vm.Script/vm.runInNewContext) outside
|
||||||
|
// this file's own source text — precisely what an import-graph scan
|
||||||
|
// cannot see into.
|
||||||
|
'node:worker_threads',
|
||||||
|
'node:vm',
|
||||||
|
// Opens a debugger port.
|
||||||
|
'node:inspector',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHARED_PKG = '@apexfusion/vector-mcp-shared';
|
||||||
|
// /tx and /types are pure. /provider is network-capable. /config is deliberately
|
||||||
|
// excluded too: it carries network endpoint URLs the signer has no use for and
|
||||||
|
// should not be reaching into. A bare root import (no subpath at all) is excluded
|
||||||
|
// as well. This is an allowlist, not a denylist — anything not named here fails,
|
||||||
|
// including forms nobody has thought to forbid yet.
|
||||||
|
const ALLOWED_SHARED_SUBPATHS = [`${SHARED_PKG}/tx`, `${SHARED_PKG}/types`];
|
||||||
|
|
||||||
|
// fetch, WebSocket, EventSource, XMLHttpRequest, and navigator.sendBeacon are
|
||||||
|
// globals in Node 22 — no import statement exists for them, so the import-graph
|
||||||
|
// scan above is structurally blind to their use. These patterns are matched with
|
||||||
|
// word boundaries so they don't false-positive on legitimate identifiers like a
|
||||||
|
// variable named `prefetchCount`.
|
||||||
|
//
|
||||||
|
// Every pattern below this comment requires the global's BARE identifier
|
||||||
|
// (`fetch`, `WebSocket`, ...) to appear, and most require it to be called or
|
||||||
|
// constructed immediately. Both are one-line evasions: `globalThis['fetch']`
|
||||||
|
// never spells the bare word `fetch` followed by `(`, and neither does
|
||||||
|
// `(0, globalThis.fetch)(...)` — the comma-operator trick (used to strip
|
||||||
|
// `this` binding so a borrowed method doesn't throw on an unexpected
|
||||||
|
// receiver) puts a `)` between `fetch` and the call parens that actually
|
||||||
|
// invoke it. Both still name `globalThis` and the target property, though —
|
||||||
|
// just via a different route to the same runtime object — so that route is
|
||||||
|
// checked for directly below: dot notation and bracket notation (the latter
|
||||||
|
// is literally "computed member access", the formal name for what
|
||||||
|
// `obj[expr]` is), covering both quoting styles quotedForms() above exists
|
||||||
|
// to handle. This still only recognises the literal text `globalThis` — a
|
||||||
|
// further level of indirection (aliasing globalThis to another name first,
|
||||||
|
// or building the property name from concatenated substrings the way
|
||||||
|
// FORBIDDEN_IMPORTS's own dynamic-`import()` gap allows, see the README) is
|
||||||
|
// the kind of arbitrary obfuscation this file does not attempt to defeat.
|
||||||
|
function globalThisQualifiedAccess(name: string): RegExp {
|
||||||
|
return new RegExp(`\\bglobalThis\\s*(?:\\.\\s*${name}\\b|\\[\\s*['"\`]${name}['"\`]\\s*\\])`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AMBIENT_NETWORK_GLOBALS: { label: string; pattern: RegExp }[] = [
|
||||||
|
{ label: 'fetch(...)', pattern: /\bfetch\s*\(/ },
|
||||||
|
{ label: 'new WebSocket', pattern: /\bnew\s+WebSocket\b/ },
|
||||||
|
{ label: 'new EventSource', pattern: /\bnew\s+EventSource\b/ },
|
||||||
|
{ label: 'new XMLHttpRequest', pattern: /\bnew\s+XMLHttpRequest\b/ },
|
||||||
|
{ label: 'navigator.sendBeacon', pattern: /\bnavigator\s*\.\s*sendBeacon\b/ },
|
||||||
|
{ label: `navigator['sendBeacon']`, pattern: /\bnavigator\s*\[\s*['"`]sendBeacon['"`]\s*\]/ },
|
||||||
|
{ label: `globalThis.fetch / globalThis['fetch']`, pattern: globalThisQualifiedAccess('fetch') },
|
||||||
|
{ label: `globalThis.WebSocket / globalThis['WebSocket']`, pattern: globalThisQualifiedAccess('WebSocket') },
|
||||||
|
{ label: `globalThis.EventSource / globalThis['EventSource']`, pattern: globalThisQualifiedAccess('EventSource') },
|
||||||
|
{
|
||||||
|
label: `globalThis.XMLHttpRequest / globalThis['XMLHttpRequest']`,
|
||||||
|
pattern: globalThisQualifiedAccess('XMLHttpRequest'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Any module format that Node can execute, not just .ts — a stray .js or .mjs
|
||||||
|
// under src/ would otherwise be invisible to every scan below.
|
||||||
|
const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'];
|
||||||
|
|
||||||
|
function sourceFiles(dir: string): string[] {
|
||||||
|
return readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
||||||
|
e.isDirectory()
|
||||||
|
? sourceFiles(join(dir, e.name))
|
||||||
|
: SOURCE_EXTENSIONS.some((ext) => e.name.endsWith(ext))
|
||||||
|
? [join(dir, e.name)]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module specifiers can be single-, double-, or backtick-quoted. Matching only
|
||||||
|
// the first two left a trivial evasion.
|
||||||
|
function quotedForms(specifier: string): string[] {
|
||||||
|
return [`'${specifier}'`, `"${specifier}"`, `\`${specifier}\``];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('signer has no network capability', () => {
|
||||||
|
test('no source file imports a network-capable module', () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const file of sourceFiles(SRC_DIR)) {
|
||||||
|
const text = readFileSync(file, 'utf8');
|
||||||
|
for (const bad of FORBIDDEN_IMPORTS) {
|
||||||
|
if (quotedForms(bad).some((form) => text.includes(form))) {
|
||||||
|
offenders.push(`${file} imports ${bad}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(offenders, [], `network-capable imports found:\n ${offenders.join('\n ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package.json declares no network-capable dependency', () => {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'));
|
||||||
|
// All four dependency kinds — optional and peer deps install too.
|
||||||
|
const declared = Object.keys({
|
||||||
|
...pkg.dependencies,
|
||||||
|
...pkg.devDependencies,
|
||||||
|
...pkg.optionalDependencies,
|
||||||
|
...pkg.peerDependencies,
|
||||||
|
});
|
||||||
|
const NETWORK_PACKAGES = [
|
||||||
|
'@lucid-evolution/lucid',
|
||||||
|
'cross-fetch',
|
||||||
|
'axios',
|
||||||
|
'node-fetch',
|
||||||
|
'undici',
|
||||||
|
'got',
|
||||||
|
'ky',
|
||||||
|
'superagent',
|
||||||
|
'request',
|
||||||
|
'ws',
|
||||||
|
'socket.io-client',
|
||||||
|
'eventsource',
|
||||||
|
];
|
||||||
|
const bad = declared.filter((d) => NETWORK_PACKAGES.includes(d));
|
||||||
|
assert.deepStrictEqual(bad, [], `network-capable dependencies declared: ${bad.join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only imports the shared package via an allowed pure subpath', () => {
|
||||||
|
const specifierPattern = new RegExp(`['"](${SHARED_PKG}(?:/[^'"]*)?)['"]`, 'g');
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const file of sourceFiles(SRC_DIR)) {
|
||||||
|
const text = readFileSync(file, 'utf8');
|
||||||
|
for (const match of text.matchAll(specifierPattern)) {
|
||||||
|
const specifier = match[1];
|
||||||
|
if (!ALLOWED_SHARED_SUBPATHS.includes(specifier)) {
|
||||||
|
offenders.push(`${file} imports '${specifier}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(offenders, [], `disallowed shared-package specifier found:\n ${offenders.join('\n ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no source file uses an ambient network global', () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const file of sourceFiles(SRC_DIR)) {
|
||||||
|
const text = readFileSync(file, 'utf8');
|
||||||
|
for (const { label, pattern } of AMBIENT_NETWORK_GLOBALS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
offenders.push(`${file} uses ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(offenders, [], `ambient network globals found:\n ${offenders.join('\n ')}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
packages/signer/test/config.test.ts
Normal file
77
packages/signer/test/config.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { loadConfig } from '../src/config.ts';
|
||||||
|
import { MnemonicKeySource } from '../src/keysource.ts';
|
||||||
|
|
||||||
|
// Throwaway test mnemonic, shared with keysource.test.ts and sign.test.ts.
|
||||||
|
// Controls no funds. Never use outside tests.
|
||||||
|
const TEST_MNEMONIC =
|
||||||
|
'test walk nut penalty hip pave soap entry language right filter choice';
|
||||||
|
|
||||||
|
function envWithMnemonic(accountIndex?: string): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { VECTOR_SIGNER_MNEMONIC: TEST_MNEMONIC };
|
||||||
|
if (accountIndex !== undefined) env.VECTOR_SIGNER_ACCOUNT_INDEX = accountIndex;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VECTOR_SIGNER_ACCOUNT_INDEX used to go straight into `Number(...)` with no
|
||||||
|
// validation anywhere downstream. Number() returns NaN for garbage input
|
||||||
|
// rather than throwing; MnemonicKeySource's constructor takes accountIndex
|
||||||
|
// on faith (its own validation is about the mnemonic, not this argument);
|
||||||
|
// and CML's HD derivation (walletFromSeed) does not throw on a NaN,
|
||||||
|
// negative, or fractional index either — it just walks a different branch
|
||||||
|
// of the tree. Verified directly against the real walletFromSeed before
|
||||||
|
// this fix (see the task report): a NaN-producing string, a negative
|
||||||
|
// integer, and a fractional value each derived a DIFFERENT, syntactically
|
||||||
|
// valid addr1... address, with no error anywhere in the chain — the
|
||||||
|
// "confident wrong answer" shape, not a crash. describe() would even echo
|
||||||
|
// the nonsense value back as if nothing were wrong (e.g. "mnemonic (account
|
||||||
|
// index NaN)"). These tests pin the fix: anything that is not a
|
||||||
|
// non-negative integer must throw, loudly, naming both the variable and the
|
||||||
|
// offending value.
|
||||||
|
describe('loadConfig — VECTOR_SIGNER_ACCOUNT_INDEX validation', () => {
|
||||||
|
test('a non-numeric value (Number(...) produces NaN) is rejected, not silently treated as account 0', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(envWithMnemonic('not-a-number')),
|
||||||
|
/VECTOR_SIGNER_ACCOUNT_INDEX.*not-a-number/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a negative integer is rejected', () => {
|
||||||
|
assert.throws(() => loadConfig(envWithMnemonic('-1')), /VECTOR_SIGNER_ACCOUNT_INDEX.*-1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fractional value is rejected', () => {
|
||||||
|
assert.throws(() => loadConfig(envWithMnemonic('1.5')), /VECTOR_SIGNER_ACCOUNT_INDEX.*1\.5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid non-negative integer is accepted and derives the SAME wallet as constructing MnemonicKeySource directly', () => {
|
||||||
|
// Not just "does not throw" — confirms the validated value actually
|
||||||
|
// reaches derivation unchanged, by cross-checking against an
|
||||||
|
// independently constructed MnemonicKeySource for the same index.
|
||||||
|
const viaConfig = loadConfig(envWithMnemonic('2')).keySource.load();
|
||||||
|
const direct = new MnemonicKeySource(TEST_MNEMONIC, 2).load();
|
||||||
|
assert.equal(viaConfig.address, direct.address);
|
||||||
|
assert.equal(viaConfig.privateKeyBech32, direct.privateKeyBech32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unset VECTOR_SIGNER_ACCOUNT_INDEX still defaults to account 0, unaffected by the new validation', () => {
|
||||||
|
const viaConfig = loadConfig(envWithMnemonic()).keySource.load();
|
||||||
|
const direct = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
||||||
|
assert.equal(viaConfig.address, direct.address);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a garbage VECTOR_SIGNER_ACCOUNT_INDEX does not block a PRIVATE_KEY-configured signer, since that path never reads it', () => {
|
||||||
|
// parseAccountIndex() is only called from the two MnemonicKeySource
|
||||||
|
// branches, not unconditionally at the top of resolveKeySource — a
|
||||||
|
// leftover/mistyped VECTOR_SIGNER_ACCOUNT_INDEX in the environment must
|
||||||
|
// not newly block startup for a config that never uses it.
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
VECTOR_SIGNER_PRIVATE_KEY: derived.privateKeyBech32,
|
||||||
|
VECTOR_SIGNER_ADDRESS: derived.address,
|
||||||
|
VECTOR_SIGNER_ACCOUNT_INDEX: 'not-a-number',
|
||||||
|
};
|
||||||
|
assert.doesNotThrow(() => loadConfig(env));
|
||||||
|
});
|
||||||
|
});
|
||||||
321
packages/signer/test/decode.test.ts
Normal file
321
packages/signer/test/decode.test.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
|
||||||
|
import { decodeTransaction } from '../src/decode.ts';
|
||||||
|
// /tx is an explicitly allowed pure subpath (boundary.test.ts's own
|
||||||
|
// ALLOWLIST — /tx and /types are pure, /provider is network-capable). Used
|
||||||
|
// below to build the exact same indefinite-length CBOR constructor shape
|
||||||
|
// deriveNftAssetName() builds, via the same helper functions sign.test.ts
|
||||||
|
// already uses for the identical fixture, rather than a hand-rolled
|
||||||
|
// reimplementation that could subtly diverge from the real encoding this
|
||||||
|
// ecosystem actually produces. Test-only import — decode.ts itself imports
|
||||||
|
// nothing beyond CML.
|
||||||
|
import { cborBytes, cborUint } from '@apexfusion/vector-mcp-shared/tx';
|
||||||
|
|
||||||
|
// Real unsigned transaction from Vector testnet. 1 input, 2 outputs
|
||||||
|
// (one foreign, one change back to OWN), fee 168223.
|
||||||
|
const FIXTURE_CBOR =
|
||||||
|
'84a300d9010281825820e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c3200018282581d71ab1aad52c4774e5da9f2c0fa1a4d07220a0bdd57ee3dce9be860dac61a002dc6c0825839013fd872a48d71872cf76528bb41dd2a5775e2c532c21662a47c6cebf44b6be5f33456f28523ed04d1aaad158541176425378d8ed8fce53f881a3b6a7221021a0002911fa0f5f6';
|
||||||
|
|
||||||
|
const OWN_ADDRESS = 'addr1qylasu4y34ccwt8hv55tkswa9fthtck9xtppvc4y03kwhaztd0jlxdzk72zj8mgy6x4269v9gytkgffh3k8d3l8987yqu5c35z';
|
||||||
|
const FOREIGN_ADDRESS = 'addr1wx434t2jc3m5uhdf7tq05xjdqu3q5z7a2lhrmn5mapsd43srh7ll8';
|
||||||
|
|
||||||
|
describe('decodeTransaction', () => {
|
||||||
|
test('extracts the fee', () => {
|
||||||
|
assert.equal(decodeTransaction(FIXTURE_CBOR).fee, 168223n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts both outputs with bech32 addresses and lovelace', () => {
|
||||||
|
const { outputs } = decodeTransaction(FIXTURE_CBOR);
|
||||||
|
assert.equal(outputs.length, 2);
|
||||||
|
assert.equal(outputs[0].address, FOREIGN_ADDRESS);
|
||||||
|
assert.equal(outputs[0].lovelace, 3_000_000n);
|
||||||
|
assert.equal(outputs[1].address, OWN_ADDRESS);
|
||||||
|
assert.equal(outputs[1].lovelace, 996_831_777n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports the input count', () => {
|
||||||
|
assert.equal(decodeTransaction(FIXTURE_CBOR).inputCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports no minted assets for a plain payment', () => {
|
||||||
|
assert.equal(decodeTransaction(FIXTURE_CBOR).mintedAssetCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports no native assets on either output', () => {
|
||||||
|
const { outputs } = decodeTransaction(FIXTURE_CBOR);
|
||||||
|
assert.deepStrictEqual(outputs[0].assets, []);
|
||||||
|
assert.deepStrictEqual(outputs[1].assets, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computes a 64-hex transaction hash', () => {
|
||||||
|
const { txHashHex } = decodeTransaction(FIXTURE_CBOR);
|
||||||
|
assert.match(txHashHex, /^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fail closed: anything unparseable must throw, never yield a partial result
|
||||||
|
// that policy might then evaluate against incomplete data.
|
||||||
|
test('rejects non-hex input', () => {
|
||||||
|
assert.throws(() => decodeTransaction('not-hex'), /could not be decoded/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects truncated CBOR', () => {
|
||||||
|
assert.throws(() => decodeTransaction(FIXTURE_CBOR.slice(0, 40)), /could not be decoded/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an empty string', () => {
|
||||||
|
assert.throws(() => decodeTransaction(''), /could not be decoded/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// CML's from_cbor_hex only requires that a *prefix* of the input parse as a
|
||||||
|
// valid transaction — it does not check that parsing consumed the whole
|
||||||
|
// string. Left unchecked, appending arbitrary bytes to a real transaction
|
||||||
|
// would silently decode as if those bytes did not exist: a confident,
|
||||||
|
// fully-populated result over less data than was actually supplied. That is
|
||||||
|
// the opposite failure mode from the tests above (which all fail to parse
|
||||||
|
// at all) and is exactly the kind of partial understanding this module must
|
||||||
|
// never hand to policy.
|
||||||
|
describe('unconsumed input (trailing bytes)', () => {
|
||||||
|
test('rejects a transaction with trailing bytes appended', () => {
|
||||||
|
assert.throws(() => decodeTransaction(FIXTURE_CBOR + 'deadbeef'), /could not be decoded/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still accepts the clean fixture (no false positive on legitimate input)', () => {
|
||||||
|
assert.doesNotThrow(() => decodeTransaction(FIXTURE_CBOR));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still accepts a signed transaction (fixture + a real vkey witness)', () => {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const body = unsigned.body();
|
||||||
|
const hash = CML.hash_transaction(body);
|
||||||
|
const privateKey = CML.PrivateKey.generate_ed25519();
|
||||||
|
const signature = privateKey.sign(hash.to_raw_bytes());
|
||||||
|
|
||||||
|
const vkeywitnesses = CML.VkeywitnessList.new();
|
||||||
|
vkeywitnesses.add(CML.Vkeywitness.new(privateKey.to_public(), signature));
|
||||||
|
const witnessSet = CML.TransactionWitnessSet.new();
|
||||||
|
witnessSet.set_vkeywitnesses(vkeywitnesses);
|
||||||
|
|
||||||
|
// The same shape sign.ts will produce: original body, a witness set, no
|
||||||
|
// auxiliary data.
|
||||||
|
const signed = CML.Transaction.new(body, witnessSet, true, undefined);
|
||||||
|
|
||||||
|
const decoded = decodeTransaction(signed.to_cbor_hex());
|
||||||
|
assert.equal(decoded.fee, 168223n);
|
||||||
|
assert.equal(decoded.outputs.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix, verified once by a throwaway probe and never pinned by a permanent
|
||||||
|
// test until now (see the task report): readAssets() builds each asset's
|
||||||
|
// `unit` string as `policy.to_hex() + name.to_hex()`, specifically NOT
|
||||||
|
// `name.to_cbor_hex().slice(2)`. name.to_cbor_hex() carries a CBOR
|
||||||
|
// byte-string header that is 1 byte for names up to 23 bytes long but grows
|
||||||
|
// to 2 bytes at 24 (Cardano asset names run up to 32 bytes) — a fixed
|
||||||
|
// slice(2) strips only the first form correctly and leaves the second
|
||||||
|
// header byte glued onto the front of the "unit" for every 24-32 byte name,
|
||||||
|
// silently corrupting it. to_hex() returns the raw bytes with no CBOR
|
||||||
|
// envelope at all, so it is correct at every length. Nothing in the rest of
|
||||||
|
// this suite constructs a real multi-asset output, so this was previously
|
||||||
|
// completely unexercised.
|
||||||
|
describe('native asset units — asset names at and beyond the 24-byte CBOR-header boundary', () => {
|
||||||
|
// Deterministic, not random: sequential bytes offset by a per-fixture seed
|
||||||
|
// so the three names never collide and a failure is reproducible from
|
||||||
|
// this description alone. 28 bytes for the policy ID because ScriptHash
|
||||||
|
// (a Cardano policy ID) is always exactly 28 bytes, never variable.
|
||||||
|
function hexOfLength(byteLen: number, seed: number): string {
|
||||||
|
return Array.from({ length: byteLen }, (_, i) => ((i + seed) % 256).toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLICY_HEX = hexOfLength(28, 0);
|
||||||
|
// Just under the boundary (1-byte CBOR header either way) — confirms the
|
||||||
|
// fix is not merely coincidentally right only at 24+ while accidentally
|
||||||
|
// still being right below it for some other reason.
|
||||||
|
const NAME_23 = hexOfLength(23, 1);
|
||||||
|
// Exactly the length at which CBOR's byte-string header grows from 1 byte
|
||||||
|
// to 2 — the precise boundary the reverted `to_cbor_hex().slice(2)` form
|
||||||
|
// corrupts.
|
||||||
|
const NAME_24 = hexOfLength(24, 50);
|
||||||
|
// The maximum asset name length Cardano allows.
|
||||||
|
const NAME_32 = hexOfLength(32, 100);
|
||||||
|
|
||||||
|
// Real CML MultiAsset construction, not a hand-rolled CBOR fixture: builds
|
||||||
|
// one output on top of FIXTURE_CBOR's own body/inputs/fee carrying all
|
||||||
|
// three asset names under one policy, then re-serialises through
|
||||||
|
// Transaction.new(...).to_cbor_hex() so this exercises decodeTransaction's
|
||||||
|
// full parse path, including its trailing-bytes consumption check, not
|
||||||
|
// just readAssets() in isolation.
|
||||||
|
function fixtureWithAssets(): string {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const body = unsigned.body();
|
||||||
|
const existingOutput = body.outputs().get(0);
|
||||||
|
|
||||||
|
const policy = CML.ScriptHash.from_hex(POLICY_HEX);
|
||||||
|
const assets = CML.MapAssetNameToCoin.new();
|
||||||
|
assets.insert(CML.AssetName.from_hex(NAME_23), 1_000n);
|
||||||
|
assets.insert(CML.AssetName.from_hex(NAME_24), 2_000n);
|
||||||
|
assets.insert(CML.AssetName.from_hex(NAME_32), 3_000n);
|
||||||
|
const multiasset = CML.MultiAsset.new();
|
||||||
|
multiasset.insert_assets(policy, assets);
|
||||||
|
const value = CML.Value.new(3_000_000n, multiasset);
|
||||||
|
|
||||||
|
const outputWithAssets = CML.TransactionOutput.new(existingOutput.address(), value, undefined, undefined);
|
||||||
|
const outputs = CML.TransactionOutputList.new();
|
||||||
|
outputs.add(outputWithAssets);
|
||||||
|
const newBody = CML.TransactionBody.new(body.inputs(), outputs, body.fee());
|
||||||
|
const tx = CML.Transaction.new(newBody, CML.TransactionWitnessSet.new(), true, undefined);
|
||||||
|
return tx.to_cbor_hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a 23-byte name (just under the boundary) decodes to policyHex + assetNameHex exactly', () => {
|
||||||
|
const { outputs } = decodeTransaction(fixtureWithAssets());
|
||||||
|
const unit = outputs[0].assets.find((a) => a.quantity === 1_000n)?.unit;
|
||||||
|
assert.equal(unit, `${POLICY_HEX}${NAME_23}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 24-byte name (exactly at the boundary) decodes to policyHex + assetNameHex exactly, not with a stray leading CBOR header byte', () => {
|
||||||
|
const { outputs } = decodeTransaction(fixtureWithAssets());
|
||||||
|
const unit = outputs[0].assets.find((a) => a.quantity === 2_000n)?.unit;
|
||||||
|
assert.equal(unit, `${POLICY_HEX}${NAME_24}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 32-byte name (the maximum Cardano allows) decodes to policyHex + assetNameHex exactly', () => {
|
||||||
|
const { outputs } = decodeTransaction(fixtureWithAssets());
|
||||||
|
const unit = outputs[0].assets.find((a) => a.quantity === 3_000n)?.unit;
|
||||||
|
assert.equal(unit, `${POLICY_HEX}${NAME_32}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all three assets are reported on the one output that carries them, with their quantities intact', () => {
|
||||||
|
const { outputs } = decodeTransaction(fixtureWithAssets());
|
||||||
|
assert.equal(outputs[0].assets.length, 3);
|
||||||
|
const quantities = outputs[0].assets.map((a) => a.quantity).sort();
|
||||||
|
assert.deepEqual(quantities, [1_000n, 2_000n, 3_000n]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ported from sign.test.ts, which covers this exact shape for sign.ts's own
|
||||||
|
// (separate) from_cbor_hex call. decode.ts runs the identical
|
||||||
|
// re-serialise-and-compare consumption check over the same encodings — see
|
||||||
|
// the long comment above decodeTransaction() in decode.ts — but had no test
|
||||||
|
// of its own exercising it against indefinite-length CBOR; sign.test.ts's
|
||||||
|
// own header comment flagged this as an out-of-scope gap for a later task to
|
||||||
|
// close. This is that closure.
|
||||||
|
describe('indefinite-length CBOR — the agent registry\'s D8 79 9F … FF constructor encoding', () => {
|
||||||
|
// packages/shared/src/tx.ts's deriveNftAssetName() documents that the
|
||||||
|
// agent registry's Conway-era Aiken validators require INDEFINITE-length
|
||||||
|
// CBOR for a Plutus constructor's field array (D8 79 9F … FF) — Lucid's
|
||||||
|
// Data.to instead emits definite-length arrays (D8 79 82 …), which those
|
||||||
|
// validators reject. decode.ts's consumption check asserts in a comment
|
||||||
|
// that CML's parse-then-reserialise round trip preserves this exact
|
||||||
|
// encoding rather than silently canonicalising it to definite-length —
|
||||||
|
// which matters because if it did NOT, the consumption check would misread
|
||||||
|
// that harmless re-encoding as "unrecognised trailing data" and refuse to
|
||||||
|
// decode every transaction using this ecosystem's own real encoding. That
|
||||||
|
// claim was asserted only in prose until now; this fixture makes it an
|
||||||
|
// executable, permanent check for decode.ts specifically (sign.test.ts
|
||||||
|
// already covers the equivalent claim for sign.ts's own, independent
|
||||||
|
// from_cbor_hex call).
|
||||||
|
//
|
||||||
|
// Built using the SAME helper functions (cborBytes, cborUint) that
|
||||||
|
// packages/shared/src/tx.ts's deriveNftAssetName() itself uses to build its
|
||||||
|
// OutputReference constructor, for maximum fidelity to the real encoding.
|
||||||
|
function indefiniteLengthConstructor(): Buffer {
|
||||||
|
// Same shape as deriveNftAssetName's outRefCbor: tag 121 (Plutus
|
||||||
|
// constructor index 0), indefinite-length array open, one bytestring
|
||||||
|
// field (reusing FIXTURE_CBOR's own input tx hash, so this is a
|
||||||
|
// realistic 32-byte field rather than arbitrary filler), one small uint
|
||||||
|
// field, indefinite-length array close.
|
||||||
|
const someHash32 = 'e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c3';
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0xd8, 0x79, 0x9f]),
|
||||||
|
cborBytes(someHash32),
|
||||||
|
cborUint(0n),
|
||||||
|
Buffer.from([0xff]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sanity: the hand-rolled bytes are actually the D8 79 9F … FF shape under test', () => {
|
||||||
|
const hex = indefiniteLengthConstructor().toString('hex');
|
||||||
|
assert.ok(hex.startsWith('d8799f'), 'must open with tag 121 + indefinite-length array');
|
||||||
|
assert.ok(hex.endsWith('ff'), 'must close with the indefinite-length-array break byte');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('embedded in the transaction BODY (an inline datum on an output)', () => {
|
||||||
|
// Body-embedded specifically because this is the placement where the
|
||||||
|
// encoding actually participates in the body hash: if CML's round trip
|
||||||
|
// ever DID mangle this encoding, this is the fixture that would show up
|
||||||
|
// as a changed hash, not just a rejected consumption check.
|
||||||
|
function fixtureWithBodyEmbeddedDatum(): string {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const body = unsigned.body();
|
||||||
|
const datum = CML.PlutusData.from_cbor_hex(indefiniteLengthConstructor().toString('hex'));
|
||||||
|
const existingOutput = body.outputs().get(0);
|
||||||
|
const outputWithDatum = CML.TransactionOutput.new(
|
||||||
|
existingOutput.address(),
|
||||||
|
existingOutput.amount(),
|
||||||
|
CML.DatumOption.new_datum(datum),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const outputs = CML.TransactionOutputList.new();
|
||||||
|
outputs.add(outputWithDatum);
|
||||||
|
const newBody = CML.TransactionBody.new(body.inputs(), outputs, body.fee());
|
||||||
|
const tx = CML.Transaction.new(newBody, CML.TransactionWitnessSet.new(), true, undefined);
|
||||||
|
return tx.to_cbor_hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('decodeTransaction accepts it — no false-positive "unrecognised trailing data" rejection', () => {
|
||||||
|
const cborHex = fixtureWithBodyEmbeddedDatum();
|
||||||
|
assert.doesNotThrow(() => decodeTransaction(cborHex));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the reported hash matches an independent hash_transaction call over the same body', () => {
|
||||||
|
const cborHex = fixtureWithBodyEmbeddedDatum();
|
||||||
|
const { txHashHex } = decodeTransaction(cborHex);
|
||||||
|
const independentHash = CML.hash_transaction(CML.Transaction.from_cbor_hex(cborHex).body()).to_hex();
|
||||||
|
assert.equal(txHashHex, independentHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the indefinite-length datum itself survives decode-then-reserialise byte-for-byte', () => {
|
||||||
|
const cborHex = fixtureWithBodyEmbeddedDatum();
|
||||||
|
// decodeTransaction() only returns plain data, not CBOR — this checks
|
||||||
|
// the same property sign.test.ts pins for sign.ts's output by going
|
||||||
|
// through CML directly on decode.ts's own accepted input, confirming
|
||||||
|
// the encoding decode.ts just validated really is byte-identical to
|
||||||
|
// what was supplied, not merely "close enough to parse".
|
||||||
|
const survivingDatum = CML.Transaction.from_cbor_hex(cborHex).body().outputs().get(0).datum()!.as_datum()!;
|
||||||
|
assert.equal(survivingDatum.to_cbor_hex(), indefiniteLengthConstructor().toString('hex'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('embedded in the WITNESS SET (a plutus_datums entry)', () => {
|
||||||
|
// Unlike the body-embedded case, this can never affect the body hash
|
||||||
|
// (plutus_datums lives on TransactionWitnessSet, not TransactionBody) —
|
||||||
|
// it instead exercises the OTHER risk: the consumption check compares
|
||||||
|
// the WHOLE transaction's re-serialised CBOR, witness set included, so a
|
||||||
|
// mangled round trip here would still cause a false-positive rejection
|
||||||
|
// even though it could never show up as a changed hash.
|
||||||
|
function fixtureWithWitnessEmbeddedDatum(): string {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const body = unsigned.body();
|
||||||
|
const datum = CML.PlutusData.from_cbor_hex(indefiniteLengthConstructor().toString('hex'));
|
||||||
|
const witnessSet = CML.TransactionWitnessSet.new();
|
||||||
|
const datums = CML.PlutusDataList.new();
|
||||||
|
datums.add(datum);
|
||||||
|
witnessSet.set_plutus_datums(datums);
|
||||||
|
const tx = CML.Transaction.new(body, witnessSet, true, undefined);
|
||||||
|
return tx.to_cbor_hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('decodeTransaction accepts it — no false-positive "unrecognised trailing data" rejection', () => {
|
||||||
|
const cborHex = fixtureWithWitnessEmbeddedDatum();
|
||||||
|
assert.doesNotThrow(() => decodeTransaction(cborHex));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the body hash is unchanged (same body as FIXTURE_CBOR — only the witness set differs)', () => {
|
||||||
|
const cborHex = fixtureWithWitnessEmbeddedDatum();
|
||||||
|
const { txHashHex } = decodeTransaction(cborHex);
|
||||||
|
assert.equal(txHashHex, decodeTransaction(FIXTURE_CBOR).txHashHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
79
packages/signer/test/integration/roundtrip.test.ts
Normal file
79
packages/signer/test/integration/roundtrip.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Opt-in. Requires a funded Vector testnet wallet at the path in
|
||||||
|
// VECTOR_SIGNER_MNEMONIC_FILE. Builds a real unsigned transaction keylessly,
|
||||||
|
// has the signer decode and sign it, and verifies the witness. Never submits.
|
||||||
|
//
|
||||||
|
// Excluded from CI (no test:integration:signer step there) because it needs a
|
||||||
|
// funded wallet. Run explicitly: see README "Testing" section.
|
||||||
|
//
|
||||||
|
// This file lives under test/, not src/ — boundary.test.ts only scans src/
|
||||||
|
// (see its own SRC_DIR constant), so the network-capable imports below
|
||||||
|
// (@lucid-evolution/lucid, the shared package's /provider subpath) do not
|
||||||
|
// trip the signer's no-network guarantee. Those imports exist ONLY to
|
||||||
|
// reproduce, from the test's own side, exactly what the hosted *builder*
|
||||||
|
// does when it constructs a transaction with no key — everything from
|
||||||
|
// MnemonicKeySource onward exercises the real signer code path.
|
||||||
|
import { describe, test, before } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Lucid } from '@lucid-evolution/lucid';
|
||||||
|
import { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
|
||||||
|
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
|
||||||
|
import { MnemonicKeySource } from '../../src/keysource.ts';
|
||||||
|
import { decodeTransaction } from '../../src/decode.ts';
|
||||||
|
import { evaluate } from '../../src/policy.ts';
|
||||||
|
import { signTransaction } from '../../src/sign.ts';
|
||||||
|
|
||||||
|
const FOREIGN = 'addr1wx434t2jc3m5uhdf7tq05xjdqu3q5z7a2lhrmn5mapsd43srh7ll8';
|
||||||
|
|
||||||
|
let unsignedCbor: string;
|
||||||
|
let identity: { address: string; privateKeyBech32: string };
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const path = process.env.VECTOR_SIGNER_MNEMONIC_FILE;
|
||||||
|
assert.ok(path, 'set VECTOR_SIGNER_MNEMONIC_FILE to a funded testnet wallet');
|
||||||
|
identity = new MnemonicKeySource(readFileSync(path!, 'utf8')).load();
|
||||||
|
|
||||||
|
const lucid = await Lucid(
|
||||||
|
new OgmiosProvider({
|
||||||
|
ogmiosUrl: 'https://ogmios.vector.testnet.apexfusion.org',
|
||||||
|
submitUrl: 'https://submit.vector.testnet.apexfusion.org/api/submit/tx',
|
||||||
|
koiosUrl: 'https://v2.koios.vector.testnet.apexfusion.org/',
|
||||||
|
}),
|
||||||
|
'Mainnet'
|
||||||
|
);
|
||||||
|
const utxos = await lucid.utxosAt(identity.address);
|
||||||
|
assert.ok(utxos.length > 0, `wallet ${identity.address} has no UTxOs - fund it`);
|
||||||
|
|
||||||
|
// Build with NO key, exactly as the hosted builder will.
|
||||||
|
lucid.selectWallet.fromAddress(identity.address, utxos);
|
||||||
|
unsignedCbor = (await lucid.newTx().pay.ToAddress(FOREIGN, { lovelace: 3_000_000n }).complete()).toCBOR();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signer round trip against Vector testnet', () => {
|
||||||
|
test('the keyless build produced a decodable transaction', () => {
|
||||||
|
assert.ok(decodeTransaction(unsignedCbor).outputs.length >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('net outflow is the payment plus the fee, with change excluded', () => {
|
||||||
|
const tx = decodeTransaction(unsignedCbor);
|
||||||
|
const { netOutflowLovelace } = evaluate(
|
||||||
|
tx, [identity.address],
|
||||||
|
{ perTxLovelace: 100_000_000n, dailyLovelace: 500_000_000n }, 0n
|
||||||
|
);
|
||||||
|
assert.equal(netOutflowLovelace, 3_000_000n + tx.fee);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('policy allows it and signing attaches a verifying witness', () => {
|
||||||
|
const tx = decodeTransaction(unsignedCbor);
|
||||||
|
const decision = evaluate(
|
||||||
|
tx, [identity.address],
|
||||||
|
{ perTxLovelace: 100_000_000n, dailyLovelace: 500_000_000n }, 0n
|
||||||
|
);
|
||||||
|
assert.equal(decision.allowed, true, decision.reason);
|
||||||
|
|
||||||
|
const { signedCborHex, txHashHex } = signTransaction(unsignedCbor, identity.privateKeyBech32);
|
||||||
|
const w = CML.Transaction.from_cbor_hex(signedCborHex).witness_set().vkeywitnesses()!.get(0);
|
||||||
|
assert.equal(w.vkey().verify(CML.TransactionHash.from_hex(txHashHex).to_raw_bytes(), w.ed25519_signature()), true);
|
||||||
|
assert.equal(txHashHex, tx.txHashHex, 'signing must not alter the body');
|
||||||
|
});
|
||||||
|
});
|
||||||
485
packages/signer/test/keysource.test.ts
Normal file
485
packages/signer/test/keysource.test.ts
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import util from 'node:util';
|
||||||
|
import { MnemonicKeySource, PrivateKeyKeySource } from '../src/keysource.ts';
|
||||||
|
|
||||||
|
// Throwaway test mnemonic. Controls no funds. Never use outside tests.
|
||||||
|
const TEST_MNEMONIC =
|
||||||
|
'test walk nut penalty hip pave soap entry language right filter choice';
|
||||||
|
|
||||||
|
// A second throwaway fixture at the OTHER end of BIP39's valid word-count
|
||||||
|
// range (24, vs TEST_MNEMONIC's 12). Every other test in this file only ever
|
||||||
|
// exercises a 12-word mnemonic, which leaves VALID_WORD_COUNTS's other four
|
||||||
|
// entries (15, 18, 21, 24) completely unexercised as *accepted* input — a
|
||||||
|
// mutation dropping all of them but 12 would pass every other test in this
|
||||||
|
// file. Deterministically generated from fixed, obviously-fake entropy
|
||||||
|
// (bytes 0x00..0x1f) rather than randomly, so it is reproducible from that
|
||||||
|
// description alone. Controls no funds. Never use outside tests.
|
||||||
|
const TEST_MNEMONIC_24 =
|
||||||
|
'abandon amount liar amount expire adjust cage candy arch gather drum bullet absurd math era live bid rhythm alien crouch range attend journey unaware';
|
||||||
|
|
||||||
|
describe('MnemonicKeySource', () => {
|
||||||
|
test('derives a bech32 private key and an addr1 address', () => {
|
||||||
|
const id = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
assert.match(id.privateKeyBech32, /^ed25519e?_sk1/);
|
||||||
|
assert.match(id.address, /^addr1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('derivation is deterministic', () => {
|
||||||
|
const a = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
const b = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
assert.equal(a.address, b.address);
|
||||||
|
assert.equal(a.privateKeyBech32, b.privateKeyBech32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a different account index yields a different address', () => {
|
||||||
|
const a = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
||||||
|
const b = new MnemonicKeySource(TEST_MNEMONIC, 1).load();
|
||||||
|
assert.notEqual(a.address, b.address);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tolerates surrounding whitespace and irregular spacing', () => {
|
||||||
|
const messy = ` ${TEST_MNEMONIC.split(' ').join(' ')} \n`;
|
||||||
|
assert.equal(new MnemonicKeySource(messy).load().address, new MnemonicKeySource(TEST_MNEMONIC).load().address);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a wrong word count', () => {
|
||||||
|
assert.throws(() => new MnemonicKeySource('one two three'), /12, 15, 18, 21 or 24 words/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describe() never reveals the mnemonic', () => {
|
||||||
|
const d = new MnemonicKeySource(TEST_MNEMONIC).describe();
|
||||||
|
assert.ok(!d.includes('walk'), 'describe() must not leak key material');
|
||||||
|
assert.match(d, /mnemonic/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PrivateKeyKeySource', () => {
|
||||||
|
test('returns the key and address it was constructed with', () => {
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
const id = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address).load();
|
||||||
|
assert.equal(id.privateKeyBech32, derived.privateKeyBech32);
|
||||||
|
assert.equal(id.address, derived.address);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a key that is not bech32 ed25519', () => {
|
||||||
|
assert.throws(() => new PrivateKeyKeySource('nonsense', 'addr1abc'), /private key/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an address that is not addr1', () => {
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
assert.throws(() => new PrivateKeyKeySource(derived.privateKeyBech32, 'nope'), /address/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describe() never reveals the key', () => {
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
const d = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address).describe();
|
||||||
|
assert.ok(!d.includes(derived.privateKeyBech32), 'describe() must not leak key material');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Edge-case pass (beyond the brief) --------------------------------------
|
||||||
|
//
|
||||||
|
// The brief's 10 tests are a floor. Everything below hunts specifically for
|
||||||
|
// the failure shape the last three tasks each shipped once: a defect that
|
||||||
|
// produces a plausible-looking success instead of an error. For this module
|
||||||
|
// that means: does any input ever derive a WRONG BUT VALID-LOOKING address or
|
||||||
|
// key, rather than either the correct one or a loud failure?
|
||||||
|
|
||||||
|
describe('MnemonicKeySource — case is not part of BIP39, so normalising it must be safe', () => {
|
||||||
|
// bip39's wordlist lookup (`wordlist.indexOf(word)`) is exact and
|
||||||
|
// case-sensitive — confirmed directly against this workspace's installed
|
||||||
|
// bip39 before writing this fix: an uppercase or mixed-case rendering of
|
||||||
|
// TEST_MNEMONIC throws 'Invalid mnemonic' with NO normalisation applied.
|
||||||
|
// That is a safe failure (loud, no derivation) but an unhelpful one, since
|
||||||
|
// casing carries no BIP39 meaning — a word is identified purely by which of
|
||||||
|
// the 2048 fixed wordlist entries it matches, never by how it happens to be
|
||||||
|
// capitalised, so no two distinct valid mnemonics differ only by case.
|
||||||
|
// Lowercasing before derivation therefore cannot make this module derive a
|
||||||
|
// DIFFERENT wallet than the one the correctly-cased mnemonic would — pinned
|
||||||
|
// below as deriving the SAME address, not merely "some" address.
|
||||||
|
const CANONICAL = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
|
||||||
|
test('an uppercase mnemonic derives the identical address and key as the canonical lowercase form', () => {
|
||||||
|
const upper = new MnemonicKeySource(TEST_MNEMONIC.toUpperCase()).load();
|
||||||
|
assert.equal(upper.address, CANONICAL.address);
|
||||||
|
assert.equal(upper.privateKeyBech32, CANONICAL.privateKeyBech32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mixed-case mnemonic derives the identical address and key as the canonical lowercase form', () => {
|
||||||
|
const mixedWords = TEST_MNEMONIC.split(' ').map((w, i) => (i % 2 === 0 ? w.toUpperCase() : w));
|
||||||
|
const mixed = new MnemonicKeySource(mixedWords.join(' ')).load();
|
||||||
|
assert.equal(mixed.address, CANONICAL.address);
|
||||||
|
assert.equal(mixed.privateKeyBech32, CANONICAL.privateKeyBech32);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MnemonicKeySource — a wrong-but-plausible mnemonic', () => {
|
||||||
|
// Right word count (12), every word individually present in the BIP39
|
||||||
|
// wordlist, but the checksum encoded in the final word's spare bits does
|
||||||
|
// not match the entropy in the first eleven words. Constructed by
|
||||||
|
// replacing TEST_MNEMONIC's FIRST word only: the checksum bits live in the
|
||||||
|
// last word, so tampering with any of the other eleven changes the entropy
|
||||||
|
// while (for a non-adversarially-chosen replacement) almost certainly
|
||||||
|
// failing the checksum — confirmed directly: bip39's own validateMnemonic()
|
||||||
|
// rejects this exact string. Throwaway, controls nothing; it does not even
|
||||||
|
// pass standard BIP39 validation, so no compliant wallet would derive from
|
||||||
|
// it at all.
|
||||||
|
const BAD_CHECKSUM_MNEMONIC =
|
||||||
|
'bulk walk nut penalty hip pave soap entry language right filter choice';
|
||||||
|
|
||||||
|
test('a right-word-count, invalid-checksum mnemonic does not throw at construction time', () => {
|
||||||
|
// Construction only checks word COUNT. Documents that the checksum
|
||||||
|
// problem is invisible until load() is actually called.
|
||||||
|
assert.doesNotThrow(() => new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('...but throws at load() time, with a checksum-specific message', () => {
|
||||||
|
const source = new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC);
|
||||||
|
assert.throws(() => source.load(), /checksum/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the checksum failure message reveals no word from the mnemonic', () => {
|
||||||
|
const source = new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC);
|
||||||
|
try {
|
||||||
|
source.load();
|
||||||
|
assert.fail('expected load() to throw');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
for (const word of BAD_CHECKSUM_MNEMONIC.split(' ')) {
|
||||||
|
assert.ok(!message.includes(word), `error message must not contain the word "${word}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mnemonic of real English words not all found in the BIP39 wordlist also throws, not derives', () => {
|
||||||
|
// Distinct failure path from a checksum mismatch: this fails the
|
||||||
|
// wordlist lookup itself (some of these words are not among the 2048
|
||||||
|
// BIP39 entries), which is arguably the single most likely real mistake
|
||||||
|
// — fat-fingering or misremembering a word — a user actually makes.
|
||||||
|
const notAllInWordlist = 'apple banana cherry dragon eagle falcon guitar hammer island jungle kitten lantern';
|
||||||
|
assert.throws(() => new MnemonicKeySource(notAllInWordlist).load(), /invalid mnemonic/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MnemonicKeySource — invisible Unicode characters between words', () => {
|
||||||
|
// This exact class of bug hit policy.ts twice: .trim() and split(/\s+/) do
|
||||||
|
// not treat every invisible character alike. NBSP (U+00A0) IS part of JS's
|
||||||
|
// \s and IS covered by ECMAScript's WhiteSpace production, so it is already
|
||||||
|
// handled correctly by .trim().split(/\s+/).join(' ') with no special
|
||||||
|
// casing — confirmed below, not assumed. ZERO WIDTH SPACE (U+200B) is
|
||||||
|
// Unicode category Cf (format), not Zs/WhiteSpace, so it is NOT matched by
|
||||||
|
// \s and survives normalisation glued onto whichever word it sits next to.
|
||||||
|
const NBSP = String.fromCharCode(0xa0);
|
||||||
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||||
|
const CANONICAL = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
|
||||||
|
test('a non-breaking space between every word normalises exactly like a regular space', () => {
|
||||||
|
const withNbsp = TEST_MNEMONIC.split(' ').join(NBSP);
|
||||||
|
const derived = new MnemonicKeySource(withNbsp).load();
|
||||||
|
assert.equal(derived.address, CANONICAL.address);
|
||||||
|
assert.equal(derived.privateKeyBech32, CANONICAL.privateKeyBech32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ZERO WIDTH SPACE glued onto a word is not silently ignored — it fails the wordlist match and throws', () => {
|
||||||
|
// Unlike policy.ts's address Set (an exact-equality lookup against
|
||||||
|
// caller-controlled strings, where a corrupted entry can silently miss a
|
||||||
|
// match and get misclassified instead of rejected), BIP39 wordlist
|
||||||
|
// lookup is an exact match against a small, fixed 2048-word list that
|
||||||
|
// contains no invisible characters. A corrupted word cannot coincidentally
|
||||||
|
// equal a different real word, so this fails loud instead of deriving a
|
||||||
|
// different, plausible-looking wallet — confirmed here, not assumed.
|
||||||
|
const words = TEST_MNEMONIC.split(' ');
|
||||||
|
const corrupted = [words[0] + ZERO_WIDTH_SPACE, ...words.slice(1)].join(' ');
|
||||||
|
const source = new MnemonicKeySource(corrupted);
|
||||||
|
assert.throws(() => source.load(), /invalid mnemonic/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ZERO WIDTH SPACE used as the ONLY separator between two words fuses them, changing the word count and throwing at construction', () => {
|
||||||
|
const words = TEST_MNEMONIC.split(' ');
|
||||||
|
// Replace the space between word[0] and word[1] with a ZWSP: \s+ cannot
|
||||||
|
// split on it, so the two words fuse into a single non-whitespace token,
|
||||||
|
// dropping the apparent count from 12 to 11.
|
||||||
|
const fused = [words[0] + ZERO_WIDTH_SPACE + words[1], ...words.slice(2)].join(' ');
|
||||||
|
assert.throws(() => new MnemonicKeySource(fused), /got 11\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MnemonicKeySource — the empty-string-split gotcha (same shape as policy.ts\'s knownAddresses bug)', () => {
|
||||||
|
// ''.split(' ') yields [''] (length 1), not [] (length 0). Both before and
|
||||||
|
// after accounting for this, empty/blank input is correctly REJECTED
|
||||||
|
// either way — this only pins that the reported count is accurate (0, not
|
||||||
|
// the misleading 1), not a change in whether it fails.
|
||||||
|
test('an empty string is reported as 0 words, not the misleading 1 a naive split would report', () => {
|
||||||
|
assert.throws(() => new MnemonicKeySource(''), /got 0\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a whitespace-only string is also reported as 0 words', () => {
|
||||||
|
assert.throws(() => new MnemonicKeySource(' \n\t '), /got 0\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MnemonicKeySource — word-count breadth beyond 12', () => {
|
||||||
|
// Every test above this one exercises only a 12-word mnemonic as VALID
|
||||||
|
// input (12 also appears as an invalid COUNT case via 3/11/0-word inputs,
|
||||||
|
// but never as one of the other three accepted lengths). A mutation that
|
||||||
|
// narrowed VALID_WORD_COUNTS to just [12] would pass every test above this
|
||||||
|
// one. TEST_MNEMONIC_24 closes that gap at the opposite end of the range.
|
||||||
|
test('a valid 24-word mnemonic derives a bech32 private key and an addr1 address', () => {
|
||||||
|
const id = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
||||||
|
assert.match(id.privateKeyBech32, /^ed25519e?_sk1/);
|
||||||
|
assert.match(id.address, /^addr1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 24-word mnemonic is deterministic, same as the 12-word case', () => {
|
||||||
|
const a = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
||||||
|
const b = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
||||||
|
assert.equal(a.address, b.address);
|
||||||
|
assert.equal(a.privateKeyBech32, b.privateKeyBech32);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('load() is idempotent — calling it twice on the SAME instance returns consistent results', () => {
|
||||||
|
test('MnemonicKeySource.load() called twice on one instance agrees with itself', () => {
|
||||||
|
const source = new MnemonicKeySource(TEST_MNEMONIC, 0);
|
||||||
|
const first = source.load();
|
||||||
|
const second = source.load();
|
||||||
|
assert.deepEqual(first, second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PrivateKeyKeySource.load() called twice on one instance agrees with itself', () => {
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
const source = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address);
|
||||||
|
const first = source.load();
|
||||||
|
const second = source.load();
|
||||||
|
assert.deepEqual(first, second);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PrivateKeyKeySource — whitespace tolerance mirrors MnemonicKeySource, without weakening validation', () => {
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
|
||||||
|
test('tolerates surrounding whitespace on both the key and the address, and stores the trimmed form', () => {
|
||||||
|
// Regression guard for the exact shape of policy.ts's knownAddresses()
|
||||||
|
// bug: validating the trimmed value but STORING the original padded one
|
||||||
|
// would pass this constructor yet never match anything downstream by
|
||||||
|
// exact string equality. Asserting the loaded value equals the clean,
|
||||||
|
// untrimmed-source derivation (not just "does not throw") is what would
|
||||||
|
// catch that.
|
||||||
|
const padded = new PrivateKeyKeySource(` ${derived.privateKeyBech32} `, `\t${derived.address}\n`).load();
|
||||||
|
assert.equal(padded.privateKeyBech32, derived.privateKeyBech32);
|
||||||
|
assert.equal(padded.address, derived.address);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a trailing ZERO WIDTH SPACE on the address is rejected, not silently accepted — trim() cannot strip it, and the regex is anchored', () => {
|
||||||
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||||
|
assert.throws(
|
||||||
|
() => new PrivateKeyKeySource(derived.privateKeyBech32, derived.address + ZERO_WIDTH_SPACE),
|
||||||
|
/address/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a trailing ZERO WIDTH SPACE on the private key is rejected, not silently accepted', () => {
|
||||||
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||||
|
assert.throws(
|
||||||
|
() => new PrivateKeyKeySource(derived.privateKeyBech32 + ZERO_WIDTH_SPACE, derived.address),
|
||||||
|
/private key/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PrivateKeyKeySource — validation boundaries a loose regex mutation could silently pass', () => {
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
|
||||||
|
test('accepts the non-extended ed25519_sk1 form, not just ed25519e_sk1', () => {
|
||||||
|
// walletFromSeed only ever produces the EXTENDED form (ed25519e_sk1...),
|
||||||
|
// so nothing above this test ever exercises the regex's `e?` — every
|
||||||
|
// prior key came from walletFromSeed. A power user importing a raw key
|
||||||
|
// generated by e.g. `cardano-cli address key-gen` gets a NON-extended
|
||||||
|
// key (ed25519_sk1...) instead. Constructed here by stripping the 'e'
|
||||||
|
// from a real derived key: this only needs to be shaped like a
|
||||||
|
// non-extended bech32 key for the regex under test, not a
|
||||||
|
// cryptographically valid one, since the constructor performs no
|
||||||
|
// cryptographic check (see the documented address/key gap above) — same
|
||||||
|
// "fake but correctly shaped" approach the brief itself uses for
|
||||||
|
// 'addr1abc'.
|
||||||
|
const nonExtended = derived.privateKeyBech32.replace(/^ed25519e_sk1/, 'ed25519_sk1');
|
||||||
|
assert.doesNotThrow(() => new PrivateKeyKeySource(nonExtended, derived.address));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a testnet-shaped address (addr_test1...) even though it starts with "addr"', () => {
|
||||||
|
// Vector runs mainnet-only (see MnemonicKeySource, which hardcodes
|
||||||
|
// network: 'Mainnet'), so an address for any other network is wrong
|
||||||
|
// for this system, not merely differently formatted. A regex loosened
|
||||||
|
// to `/^addr/` (matching only the four literal letters, not `addr1`
|
||||||
|
// specifically) would let this through — a realistic operator mistake
|
||||||
|
// (pasting a testnet address into a mainnet-only signer config), not
|
||||||
|
// just a contrived string.
|
||||||
|
const testnetShaped = 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w';
|
||||||
|
assert.throws(() => new PrivateKeyKeySource(derived.privateKeyBech32, testnetShaped), /address/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PrivateKeyKeySource — address/key correspondence is NOT verified (documented, deliberate gap)', () => {
|
||||||
|
// See the long comment on PrivateKeyKeySource in keysource.ts for the full
|
||||||
|
// reasoning. Short version: verifying this would need to derive a public
|
||||||
|
// key (and, for a Base address, a stake credential too — a Base address is
|
||||||
|
// not a function of the payment key alone) from the private key and
|
||||||
|
// compare it against the address's embedded credential, which needs the
|
||||||
|
// Cardano Multiplatform Library. This module deliberately imports ONLY
|
||||||
|
// @lucid-evolution/wallet, and that package's one function taking a raw
|
||||||
|
// private key (makeWalletFromPrivateKey) requires a live Provider — the
|
||||||
|
// network capability this module exists to not have. Pinned here so the
|
||||||
|
// gap is a visible, intentional decision, not a silent assumption.
|
||||||
|
test('a syntactically valid key and a syntactically valid but unrelated address construct and load() without error', () => {
|
||||||
|
const accountA = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
||||||
|
const accountB = new MnemonicKeySource(TEST_MNEMONIC, 1).load();
|
||||||
|
// accountA's key paired with accountB's address: independently valid,
|
||||||
|
// mutually unrelated. Nothing in this module can detect that mismatch.
|
||||||
|
const mismatched = new PrivateKeyKeySource(accountA.privateKeyBech32, accountB.address).load();
|
||||||
|
assert.equal(mismatched.privateKeyBech32, accountA.privateKeyBech32);
|
||||||
|
assert.equal(mismatched.address, accountB.address);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error messages never echo the rejected input, even when the input itself is invalid', () => {
|
||||||
|
// Distinct from the describe()-leak tests above: this pins that the
|
||||||
|
// CONSTRUCTOR's own validation errors never interpolate the value that was
|
||||||
|
// rejected, on the principle that a string purporting to be a private key
|
||||||
|
// should never be echoed back, valid or not.
|
||||||
|
test('the rejected private key value itself never appears in the constructor error message', () => {
|
||||||
|
const rejected = 'this-string-must-never-appear-in-the-thrown-message';
|
||||||
|
try {
|
||||||
|
new PrivateKeyKeySource(rejected, 'addr1abc');
|
||||||
|
assert.fail('expected constructor to throw');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
assert.ok(!message.includes(rejected));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the rejected address value itself never appears in the constructor error message', () => {
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
const rejected = 'this-address-string-must-never-appear-in-the-thrown-message';
|
||||||
|
try {
|
||||||
|
new PrivateKeyKeySource(derived.privateKeyBech32, rejected);
|
||||||
|
assert.fail('expected constructor to throw');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
assert.ok(!message.includes(rejected));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Coordinator review pass ------------------------------------------------
|
||||||
|
//
|
||||||
|
// Independent review found one real leak the tests above did not cover, and
|
||||||
|
// one unpinned default. Both reproduced first against the pre-fix code
|
||||||
|
// (confirmed RED — see signer-task-5-report.md's addendum for the transcript)
|
||||||
|
// before the fix landed.
|
||||||
|
|
||||||
|
describe('key material is unreachable at RUNTIME, not just hidden from the compiler', () => {
|
||||||
|
// TypeScript `private` erases at compile time only. At runtime an instance
|
||||||
|
// built with plain `private readonly mnemonic: string` is an ordinary
|
||||||
|
// object with an ordinary enumerable `mnemonic` property — JSON.stringify,
|
||||||
|
// util.inspect (and therefore console.log, which calls it internally),
|
||||||
|
// Object.keys, Object.getOwnPropertyNames, spread, and for...in all see it.
|
||||||
|
// Reproduced directly before writing this fix: JSON.stringify() on the
|
||||||
|
// pre-fix class printed the mnemonic in full. This package already writes
|
||||||
|
// diagnostics to stderr elsewhere (audit.ts, index.ts), so this is one
|
||||||
|
// `console.log(keySource)` away from a real leak, not a theoretical one.
|
||||||
|
const mnemonicSource = new MnemonicKeySource(TEST_MNEMONIC, 2);
|
||||||
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
const privateKeySource = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address);
|
||||||
|
|
||||||
|
// TEST_MNEMONIC's words, checked individually: a substring check for the
|
||||||
|
// whole phrase would miss a leak that reformats or partially reproduces it
|
||||||
|
// (e.g. an inspect call that line-wraps between words).
|
||||||
|
const MNEMONIC_WORDS = TEST_MNEMONIC.split(' ');
|
||||||
|
|
||||||
|
function assertNoLeak(rendered: string, label: string) {
|
||||||
|
for (const word of MNEMONIC_WORDS) {
|
||||||
|
assert.ok(!rendered.includes(word), `${label} must not contain the mnemonic word "${word}"`);
|
||||||
|
}
|
||||||
|
assert.ok(!rendered.includes(derived.privateKeyBech32), `${label} must not contain the private key`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('JSON.stringify(MnemonicKeySource instance) leaks nothing', () => {
|
||||||
|
assertNoLeak(JSON.stringify(mnemonicSource), 'JSON.stringify');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('util.inspect(MnemonicKeySource instance) leaks nothing (covers console.log too, which calls util.inspect internally)', () => {
|
||||||
|
assertNoLeak(util.inspect(mnemonicSource), 'util.inspect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Object.keys(MnemonicKeySource instance) no longer exposes the "mnemonic" property name, and accountIndex (not secret) remains', () => {
|
||||||
|
// Object.keys returns property NAMES, which for the secret field are
|
||||||
|
// never themselves derived from the mnemonic's VALUE — the meaningful
|
||||||
|
// assertion is that the #private field is not enumerable at all.
|
||||||
|
// accountIndex is explicitly not secret and is allowed to stay visible.
|
||||||
|
assert.deepEqual(Object.keys(mnemonicSource), ['accountIndex']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Object.values(MnemonicKeySource instance) leaks nothing (the value-exposure half Object.keys alone does not test)', () => {
|
||||||
|
assertNoLeak(JSON.stringify(Object.values(mnemonicSource)), 'Object.values');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSON.stringify(PrivateKeyKeySource instance) leaks nothing', () => {
|
||||||
|
assertNoLeak(JSON.stringify(privateKeySource), 'JSON.stringify');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('util.inspect(PrivateKeyKeySource instance) leaks nothing (covers console.log too)', () => {
|
||||||
|
assertNoLeak(util.inspect(privateKeySource), 'util.inspect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Object.keys(PrivateKeyKeySource instance) exposes no property names at all — both fields are secret', () => {
|
||||||
|
assert.deepEqual(Object.keys(privateKeySource), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Object.values(PrivateKeyKeySource instance) leaks nothing', () => {
|
||||||
|
assertNoLeak(JSON.stringify(Object.values(privateKeySource)), 'Object.values');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSON.stringify still produces something useful (describe()\'s string), not an uninformative {}', () => {
|
||||||
|
// Defence-in-depth check: toJSON()/inspect.custom exist so an accidental
|
||||||
|
// serialisation attempt is still legible, not merely safe. Confirms the
|
||||||
|
// override actually engages rather than JSON.stringify silently falling
|
||||||
|
// back to `{}` because every enumerable property happened to be gone.
|
||||||
|
assert.equal(JSON.stringify(mnemonicSource), JSON.stringify(mnemonicSource.describe()));
|
||||||
|
assert.equal(JSON.stringify(privateKeySource), JSON.stringify(privateKeySource.describe()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MnemonicKeySource — the default account index is pinned to 0', () => {
|
||||||
|
// keysource.test.ts previously only ever compared an EXPLICIT 0 against an
|
||||||
|
// EXPLICIT 1 ("a different account index yields a different address");
|
||||||
|
// nothing tied the OMITTED-argument default itself to 0. A silent
|
||||||
|
// regression changing the default would derive a different, validly
|
||||||
|
// formatted wallet for every caller that omits the argument — and since
|
||||||
|
// policy.ts matches change outputs by exact address, that would make
|
||||||
|
// every real transaction look like a full outflow and get refused.
|
||||||
|
test('a default-constructed instance derives the identical address as an explicit accountIndex: 0', () => {
|
||||||
|
const defaulted = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
const explicit = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
||||||
|
assert.equal(defaulted.address, explicit.address);
|
||||||
|
assert.equal(defaulted.privateKeyBech32, explicit.privateKeyBech32);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MnemonicKeySource.load() — the error-wrap prefix is pinned', () => {
|
||||||
|
// Removing the try/catch around walletFromSeed entirely still lets the
|
||||||
|
// underlying bip39 error ("Invalid mnemonic checksum") through unwrapped,
|
||||||
|
// which satisfied every /checksum/i-style assertion above without the
|
||||||
|
// wrap actually being present. This pins the wrap itself.
|
||||||
|
const BAD_CHECKSUM_MNEMONIC =
|
||||||
|
'bulk walk nut penalty hip pave soap entry language right filter choice';
|
||||||
|
|
||||||
|
test('load() failure is prefixed with the source-attributing wrap message', () => {
|
||||||
|
// assert.throws matches against error.toString() ('Error: <message>'),
|
||||||
|
// not error.message directly, hence no leading ^ anchor here.
|
||||||
|
assert.throws(
|
||||||
|
() => new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC).load(),
|
||||||
|
/Mnemonic could not be derived into a wallet: Invalid mnemonic checksum/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
547
packages/signer/test/policy.test.ts
Normal file
547
packages/signer/test/policy.test.ts
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { computeOutflow, evaluate, type SpendLimits } from '../src/policy.ts';
|
||||||
|
import type { DecodedTx } from '../src/decode.ts';
|
||||||
|
|
||||||
|
const OWN = 'addr1own';
|
||||||
|
const OTHER_OWN = 'addr1own_second_index';
|
||||||
|
const FOREIGN = 'addr1foreign';
|
||||||
|
const FOREIGN2 = 'addr1foreign_second';
|
||||||
|
|
||||||
|
const LIMITS: SpendLimits = { perTxLovelace: 100_000_000n, dailyLovelace: 500_000_000n };
|
||||||
|
|
||||||
|
function tx(partial: Partial<DecodedTx>): DecodedTx {
|
||||||
|
return {
|
||||||
|
txHashHex: 'a'.repeat(64),
|
||||||
|
fee: 200_000n,
|
||||||
|
outputs: [],
|
||||||
|
inputCount: 1,
|
||||||
|
mintedAssetCount: 0,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeOutflow', () => {
|
||||||
|
test('counts a foreign output plus the fee', () => {
|
||||||
|
const r = computeOutflow(tx({ outputs: [{ address: FOREIGN, lovelace: 3_000_000n, assets: [] }] }), [OWN]);
|
||||||
|
assert.equal(r.netOutflowLovelace, 3_200_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes change returning to our own address', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [
|
||||||
|
{ address: FOREIGN, lovelace: 3_000_000n, assets: [] },
|
||||||
|
{ address: OWN, lovelace: 996_000_000n, assets: [] },
|
||||||
|
] }),
|
||||||
|
[OWN]
|
||||||
|
);
|
||||||
|
assert.equal(r.netOutflowLovelace, 3_200_000n, 'change must not count as outflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counts the fee even when every output returns to us', () => {
|
||||||
|
const r = computeOutflow(tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }), [OWN]);
|
||||||
|
assert.equal(r.netOutflowLovelace, 200_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honours multiple own addresses', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{ address: OTHER_OWN, lovelace: 7_000_000n, assets: [] }] }),
|
||||||
|
[OWN, OTHER_OWN]
|
||||||
|
);
|
||||||
|
assert.equal(r.netOutflowLovelace, 200_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('records assets leaving to a foreign address', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 2_000_000n, assets: [{ unit: 'policy1nft', quantity: 1n }] }] }),
|
||||||
|
[OWN]
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(r.assetMovements, [{ unit: 'policy1nft', quantity: 1n, toAddress: FOREIGN }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not record assets returning to us', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{ address: OWN, lovelace: 2_000_000n, assets: [{ unit: 'policy1nft', quantity: 1n }] }] }),
|
||||||
|
[OWN]
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(r.assetMovements, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate — adversarial', () => {
|
||||||
|
test('refuses a drain that exceeds the per-transaction limit', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 250_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /per-transaction limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows a transaction inside both limits', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 3_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
assert.equal(d.reason, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses when the daily budget is already exhausted', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 10_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 495_000_000n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /daily/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a large transaction is allowed if it fits exactly', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 100_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a transaction with no outputs still charges the fee', () => {
|
||||||
|
const d = evaluate(tx({ outputs: [] }), [OWN], LIMITS, 0n);
|
||||||
|
assert.equal(d.netOutflowLovelace, 200_000n);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses when ownAddresses is empty — nothing can be classified as change', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
[], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /own address/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the reported outflow is always present, even on refusal', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 250_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.netOutflowLovelace, 250_200_000n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Adversarial pass (beyond the brief) -----------------------------------
|
||||||
|
//
|
||||||
|
// The brief's 13 tests are a floor. Everything below was added after they
|
||||||
|
// passed, specifically hunting for the failure shape this module is uniquely
|
||||||
|
// exposed to: a wrong comparison or a missed exclusion that does not throw,
|
||||||
|
// it just quietly returns `allowed: true` when it should not.
|
||||||
|
|
||||||
|
describe('evaluate — ownAddresses containing only blank entries', () => {
|
||||||
|
// A realistic real-world source of this: `''.split(',')` in JS yields `['']`,
|
||||||
|
// not `[]`. If whatever populates ownAddresses from config/env ever hits that
|
||||||
|
// empty-string case, the array is non-empty but contains no usable address.
|
||||||
|
// `ownAddresses.length === 0` alone does not catch this: the array has
|
||||||
|
// length 1. Every real output then fails to match the blank entry, so it is
|
||||||
|
// over-counted as outflow rather than silently permitted — the safe
|
||||||
|
// direction — but the explicit, intentional fail-closed path (and its
|
||||||
|
// message) is bypassed, and if the (over-counted) outflow happens to still
|
||||||
|
// land under the configured limits, the transaction is signed even though
|
||||||
|
// no real own address was ever known. That is the same condition the
|
||||||
|
// empty-array check exists to refuse; it should refuse here too.
|
||||||
|
test('a single blank entry is treated as no known own address (fails closed)', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
[''], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /own address/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank entry alongside a real own address does not weaken matching', () => {
|
||||||
|
// Regression guard for the fix above: filtering blanks must not touch a
|
||||||
|
// list that also contains a genuine address.
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
['', OWN]
|
||||||
|
);
|
||||||
|
assert.equal(r.netOutflowLovelace, 200_000n, 'OWN must still be recognised as change');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate — ownAddresses entries built from invisible Unicode characters', () => {
|
||||||
|
// `.trim()` alone is not enough to detect "not a real address": it strips
|
||||||
|
// the ECMAScript WhiteSpace set (which includes NBSP U+00A0 and BOM
|
||||||
|
// U+FEFF) but not every invisible character. U+200B ZERO WIDTH SPACE and
|
||||||
|
// U+2060 WORD JOINER are Unicode category Cf (format), not Zs (space
|
||||||
|
// separator) or part of ECMAScript's WhiteSpace production, so trimming
|
||||||
|
// either one leaves a non-empty string — a trim-then-check-length filter
|
||||||
|
// alone would let either slip past exactly the way '' used to. (U+00A0 and
|
||||||
|
// U+FEFF, by contrast, ARE stripped by .trim() and were never actually a
|
||||||
|
// gap here — verified directly before writing these tests: only U+200B and
|
||||||
|
// U+2060 reproduce it.) A denylist of "invisible" codepoints is not the
|
||||||
|
// fix: there are dozens of Cf/Cc codepoints and more get added, so this is
|
||||||
|
// a positive check instead — an entry only counts as a known own address
|
||||||
|
// if, after trimming, it starts with the 'addr' prefix every
|
||||||
|
// Cardano/Vector payment address actually uses. Nothing built purely from
|
||||||
|
// invisible characters can satisfy that.
|
||||||
|
// Built via fromCharCode rather than a literal escape so the codepoint is
|
||||||
|
// unambiguous in source and cannot be misrendered by an editor or diff tool.
|
||||||
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||||
|
const WORD_JOINER = String.fromCharCode(0x2060);
|
||||||
|
|
||||||
|
test('a single ZERO WIDTH SPACE (U+200B) entry is treated as no known own address', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
[ZERO_WIDTH_SPACE], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /own address/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single WORD JOINER (U+2060) entry is treated as no known own address', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
[WORD_JOINER], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /own address/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a genuine addr1... address is still accepted (the positive check does not over-reject)', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
[OWN]
|
||||||
|
);
|
||||||
|
assert.equal(r.netOutflowLovelace, 200_000n, 'a real own address must still be recognised as change');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate — knownAddresses must store the trimmed value, not just check it', () => {
|
||||||
|
// Real bug, found reviewing the invisible-character fix, present since that
|
||||||
|
// fix's very first version: the filter checked `address.trim().startsWith(...)`
|
||||||
|
// but Array.prototype.filter always returns the ORIGINAL element on a match,
|
||||||
|
// never a transformed one. So an entry that only qualified after trimming
|
||||||
|
// was kept *with its padding intact*, then compared by exact string
|
||||||
|
// equality against real output addresses (always clean, never padded) —
|
||||||
|
// which can never match. The address passes the fail-closed gate (it
|
||||||
|
// "looks like" a known address) but is then functionally invisible to
|
||||||
|
// computeOutflow's classification, so every one of its own change outputs
|
||||||
|
// gets counted as outflow instead of being excluded. Since change is
|
||||||
|
// usually the dominant value in a UTxO transaction, a single incidental
|
||||||
|
// leading or trailing space on a configured own address would refuse
|
||||||
|
// nearly every real transaction at a wildly inflated outflow figure.
|
||||||
|
test('a leading space on a real own address no longer hides its change output (regression test for the fix)', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [
|
||||||
|
{ address: FOREIGN, lovelace: 3_000_000n, assets: [] },
|
||||||
|
{ address: OWN, lovelace: 996_000_000n, assets: [] },
|
||||||
|
] }),
|
||||||
|
[' ' + OWN], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
assert.equal(d.netOutflowLovelace, 3_200_000n, 'only the genuine foreign spend + fee, not the misclassified change');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a trailing space on a real own address no longer hides its change output', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [
|
||||||
|
{ address: FOREIGN, lovelace: 3_000_000n, assets: [] },
|
||||||
|
{ address: OWN, lovelace: 996_000_000n, assets: [] },
|
||||||
|
] }),
|
||||||
|
[OWN + ' '], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
assert.equal(d.netOutflowLovelace, 3_200_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('residual: a trailing invisible character still hides the change output, since .trim() does not strip it — same safe direction', () => {
|
||||||
|
// Documented, deliberate residual (see the comment on knownAddresses in
|
||||||
|
// policy.ts): fixing this specific case would mean reaching for the
|
||||||
|
// denylist-of-invisible-codepoints approach the positive check exists to
|
||||||
|
// avoid. Pinned here so it stays a known, bounded gap rather than an
|
||||||
|
// implicit assumption — and because it fails in the safe direction
|
||||||
|
// (refuses a legitimate transaction) rather than the dangerous one.
|
||||||
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [
|
||||||
|
{ address: FOREIGN, lovelace: 3_000_000n, assets: [] },
|
||||||
|
{ address: OWN, lovelace: 996_000_000n, assets: [] },
|
||||||
|
] }),
|
||||||
|
[OWN + ZERO_WIDTH_SPACE], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.equal(d.netOutflowLovelace, 999_200_000n, 'the change is still misclassified as outflow — known, bounded, safe-direction residual');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an entry merely containing "addr" without starting with it is not a known own address (prefix, not substring)', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
['xxxaddrxxx'], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /own address/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a case-variant entry does not match a real own address (pins the existing case-sensitivity decision)', () => {
|
||||||
|
const CASE_VARIANT = 'ADDR1own'; // OWN is 'addr1own'
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
[CASE_VARIANT], LIMITS, 0n
|
||||||
|
);
|
||||||
|
// knownAddresses([CASE_VARIANT]) is empty (case-sensitive prefix check
|
||||||
|
// fails), so this hits the fail-closed "no own address" path rather than
|
||||||
|
// the over-counting path — either way, OWN must not be recognised as
|
||||||
|
// change, which is the property this test pins.
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /own address/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeOutflow — the filter applies at the Set-construction site itself, not only in evaluate()', () => {
|
||||||
|
// evaluate()'s fail-closed guard and computeOutflow()'s own Set both call
|
||||||
|
// knownAddresses(), but nothing above exercises them independently: every
|
||||||
|
// prior blank/invisible-entry test either goes through evaluate() (whose
|
||||||
|
// guard would refuse before computeOutflow's classification could matter)
|
||||||
|
// or pairs a blank ownAddresses entry with a *real* output address (which
|
||||||
|
// would pass either way, filtered or not). That leaves a real gap: if a
|
||||||
|
// future edit filtered ownAddresses in evaluate()'s guard but left
|
||||||
|
// computeOutflow's `new Set(ownAddresses)` unfiltered, nothing here would
|
||||||
|
// catch it, because a blank entry in an otherwise-unfiltered Set only
|
||||||
|
// matters when something can actually match it — i.e. when an output
|
||||||
|
// address is *also* blank. decode.ts can never produce that: every output
|
||||||
|
// address comes from `o.address().to_bech32()`, which throws before an
|
||||||
|
// empty or blank address could ever reach a DecodedOutput (see decode.ts's
|
||||||
|
// per-output try/catch). So this is unreachable through the real pipeline
|
||||||
|
// — but computeOutflow is an exported pure function with its own contract,
|
||||||
|
// and that contract must hold regardless of what the current pipeline
|
||||||
|
// happens to be able to construct.
|
||||||
|
test('a blank output address is counted as outflow, not matched as change via a blank ownAddresses entry', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{ address: '', lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
['']
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
r.netOutflowLovelace,
|
||||||
|
5_200_000n,
|
||||||
|
'a blank output address must never be classified as change, even against a blank ownAddresses entry'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an output address built from an invisible character is counted as outflow, not matched as change', () => {
|
||||||
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{ address: ZERO_WIDTH_SPACE, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
[ZERO_WIDTH_SPACE]
|
||||||
|
);
|
||||||
|
assert.equal(r.netOutflowLovelace, 5_200_000n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate — exact limit boundaries, one lovelace either side', () => {
|
||||||
|
test('one lovelace under the per-transaction limit is allowed', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 99_999_999n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one lovelace over the per-transaction limit is refused', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 100_000_001n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /per-transaction limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a daily total landing exactly on the limit is allowed', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 50_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 450_000_000n // 450M + 50M == 500M dailyLovelace, exactly
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one lovelace over the daily limit is refused', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 50_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 450_000_001n // sum == dailyLovelace + 1
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /daily/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one lovelace under the daily limit is allowed', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 50_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 449_999_999n // sum == dailyLovelace - 1
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate — many small foreign outputs must sum, not spot-check', () => {
|
||||||
|
test('34 outputs of 3 AP3X each breach a 100 AP3X per-transaction limit on their sum', () => {
|
||||||
|
// No single output is anywhere near the limit (3_000_000n each); only the
|
||||||
|
// total is. A check that inspected outputs individually rather than
|
||||||
|
// summing would wrongly allow this.
|
||||||
|
const outputs = Array.from({ length: 34 }, () => ({ address: FOREIGN, lovelace: 3_000_000n, assets: [] }));
|
||||||
|
const d = evaluate(tx({ outputs }), [OWN], LIMITS, 0n);
|
||||||
|
assert.equal(d.netOutflowLovelace, 102_200_000n); // 34 * 3,000,000 + 200,000 fee
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /per-transaction limit/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeOutflow — aggregation correctness', () => {
|
||||||
|
test('sums repeated outputs to the same foreign address rather than deduplicating', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [
|
||||||
|
{ address: FOREIGN, lovelace: 1_000_000n, assets: [] },
|
||||||
|
{ address: FOREIGN, lovelace: 2_000_000n, assets: [] }, // same address again
|
||||||
|
{ address: OWN, lovelace: 50_000_000n, assets: [] }, // change, excluded
|
||||||
|
{ address: FOREIGN2, lovelace: 3_000_000n, assets: [] }, // a different foreign address
|
||||||
|
] }),
|
||||||
|
[OWN]
|
||||||
|
);
|
||||||
|
assert.equal(r.netOutflowLovelace, 6_200_000n); // 1M + 2M + 3M + 200K fee; 50M change excluded
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate entries in ownAddresses do not change classification', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [
|
||||||
|
{ address: OWN, lovelace: 5_000_000n, assets: [] },
|
||||||
|
{ address: OTHER_OWN, lovelace: 3_000_000n, assets: [] },
|
||||||
|
{ address: FOREIGN, lovelace: 2_000_000n, assets: [] },
|
||||||
|
] }),
|
||||||
|
[OWN, OWN, OTHER_OWN, OTHER_OWN]
|
||||||
|
);
|
||||||
|
assert.equal(r.netOutflowLovelace, 2_200_000n); // only the foreign output + fee
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an own address supplied in a different case does not match — over-counts as outflow, the safe direction', () => {
|
||||||
|
// Bech32 addresses are canonically lowercase and decode.ts only ever emits
|
||||||
|
// that canonical form, so this should not arise in practice. Pinning it
|
||||||
|
// anyway: a mismatch here fails toward refusing too much, never toward
|
||||||
|
// missing real outflow, which is the direction that matters.
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
||||||
|
['ADDR1OWN']
|
||||||
|
);
|
||||||
|
assert.equal(r.netOutflowLovelace, 5_200_000n, 'differently-cased own address must not be recognised as change');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('records every distinct asset on a single foreign output, not just the first', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [{
|
||||||
|
address: FOREIGN,
|
||||||
|
lovelace: 2_000_000n,
|
||||||
|
assets: [
|
||||||
|
{ unit: 'policy1nft', quantity: 1n },
|
||||||
|
{ unit: 'policy2token', quantity: 500n },
|
||||||
|
],
|
||||||
|
}] }),
|
||||||
|
[OWN]
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(r.assetMovements, [
|
||||||
|
{ unit: 'policy1nft', quantity: 1n, toAddress: FOREIGN },
|
||||||
|
{ unit: 'policy2token', quantity: 500n, toAddress: FOREIGN },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates assets across multiple foreign outputs rather than keeping only the last', () => {
|
||||||
|
const r = computeOutflow(
|
||||||
|
tx({ outputs: [
|
||||||
|
{ address: FOREIGN, lovelace: 2_000_000n, assets: [{ unit: 'nftA', quantity: 1n }] },
|
||||||
|
{ address: FOREIGN2, lovelace: 3_000_000n, assets: [{ unit: 'nftB', quantity: 1n }] },
|
||||||
|
] }),
|
||||||
|
[OWN]
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(r.assetMovements, [
|
||||||
|
{ unit: 'nftA', quantity: 1n, toAddress: FOREIGN },
|
||||||
|
{ unit: 'nftB', quantity: 1n, toAddress: FOREIGN2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate — check ordering is pinned, not incidental', () => {
|
||||||
|
test('a transaction breaching both limits at once reports the per-transaction reason first', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 250_000_000n, assets: [] }] }),
|
||||||
|
[OWN], LIMITS, 400_000_000n // also breaches the daily limit (250M + 400M > 500M)
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /per-transaction limit/i);
|
||||||
|
assert.doesNotMatch(d.reason!, /daily/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty ownAddresses list reports the own-address reason before any limit is even considered', () => {
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 250_000_000n, assets: [] }] }),
|
||||||
|
[], LIMITS, 400_000_000n // this transaction would also fail both limit checks
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /own address/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate — zero limits and an already-over-budget day', () => {
|
||||||
|
test('zero limits still allow a genuinely zero-value transaction', () => {
|
||||||
|
const zeroLimits: SpendLimits = { perTxLovelace: 0n, dailyLovelace: 0n };
|
||||||
|
const d = evaluate(tx({ fee: 0n, outputs: [] }), [OWN], zeroLimits, 0n);
|
||||||
|
assert.equal(d.netOutflowLovelace, 0n);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero limits refuse even the smallest positive outflow', () => {
|
||||||
|
const zeroLimits: SpendLimits = { perTxLovelace: 0n, dailyLovelace: 0n };
|
||||||
|
const d = evaluate(tx({ fee: 1n, outputs: [] }), [OWN], zeroLimits, 0n);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /per-transaction limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an already over-budget day clamps the reported remaining budget to zero, not negative', () => {
|
||||||
|
// alreadySpentTodayLovelace (600M) already exceeds dailyLovelace (500M) —
|
||||||
|
// should not happen if the audit log is accounted correctly, but the
|
||||||
|
// module must still behave sanely (and keep refusing) if it ever does.
|
||||||
|
const d = evaluate(tx({ outputs: [] }), [OWN], LIMITS, 600_000_000n);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.match(d.reason!, /daily/i);
|
||||||
|
assert.match(d.reason!, /remaining daily budget of 0\.000000 AP3X/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('policy — known gap: SpendLimits governs lovelace only, not native asset value', () => {
|
||||||
|
test('a transaction moving a native asset to a foreign address is allowed if lovelace stays under both limits', () => {
|
||||||
|
// computeOutflow tracks assetMovements, but evaluate() only compares
|
||||||
|
// netOutflowLovelace against limits — it never inspects assetMovements.
|
||||||
|
// A transaction that sends an NFT or a large token balance out while
|
||||||
|
// keeping the lovelace side minimal (e.g. just the ~2 AP3X min-UTxO
|
||||||
|
// deposit) passes both limit checks untouched. SpendLimits has no
|
||||||
|
// asset-value dimension to check against, so this is not a coding
|
||||||
|
// mistake relative to the given interface — it is a scope boundary of
|
||||||
|
// this policy, pinned here so it is visible rather than silently assumed.
|
||||||
|
const d = evaluate(
|
||||||
|
tx({ outputs: [{ address: FOREIGN, lovelace: 2_000_000n, assets: [{ unit: 'preciousNFT', quantity: 1n }] }] }),
|
||||||
|
[OWN], LIMITS, 0n
|
||||||
|
);
|
||||||
|
assert.equal(d.allowed, true);
|
||||||
|
assert.deepStrictEqual(d.assetMovements, [{ unit: 'preciousNFT', quantity: 1n, toAddress: FOREIGN }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluate — bigint precision at magnitudes beyond float safety', () => {
|
||||||
|
test('the bigint outflow figure stays exact past Number.MAX_SAFE_INTEGER, even though the human-readable message may round', () => {
|
||||||
|
// ada() formats via Number() for the reason string only; the allow/deny
|
||||||
|
// decision itself is decided entirely in bigint arithmetic and must never
|
||||||
|
// lose precision, no matter how large the values get. Not reachable at
|
||||||
|
// this system's real scale (limits and balances run to hundreds of AP3X,
|
||||||
|
// far below 2^53 lovelace) — tested anyway because the decision-critical
|
||||||
|
// field must hold regardless of realistic bounds.
|
||||||
|
const HUGE_FEE = 9_007_199_254_740_993n; // Number.MAX_SAFE_INTEGER + 2
|
||||||
|
const zeroLimits: SpendLimits = { perTxLovelace: 0n, dailyLovelace: 0n };
|
||||||
|
const d = evaluate(tx({ fee: HUGE_FEE, outputs: [] }), [OWN], zeroLimits, 0n);
|
||||||
|
assert.equal(d.allowed, false);
|
||||||
|
assert.equal(d.netOutflowLovelace, HUGE_FEE, 'the bigint decision figure must never lose precision');
|
||||||
|
});
|
||||||
|
});
|
||||||
523
packages/signer/test/sign.test.ts
Normal file
523
packages/signer/test/sign.test.ts
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
|
||||||
|
import { signTransaction } from '../src/sign.ts';
|
||||||
|
import { MnemonicKeySource } from '../src/keysource.ts';
|
||||||
|
import { decodeTransaction } from '../src/decode.ts';
|
||||||
|
// /tx is an explicitly allowed pure subpath (boundary.test.ts's own
|
||||||
|
// ALLOWLIST — /tx and /types are pure, /provider is network-capable). Used
|
||||||
|
// below to build the exact same indefinite-length CBOR constructor shape
|
||||||
|
// deriveNftAssetName() builds, via the same helper functions, rather than a
|
||||||
|
// hand-rolled reimplementation that could subtly diverge from the real
|
||||||
|
// encoding this ecosystem actually produces. Test-only import — sign.ts
|
||||||
|
// itself imports nothing beyond CML.
|
||||||
|
import { cborBytes, cborUint } from '@apexfusion/vector-mcp-shared/tx';
|
||||||
|
|
||||||
|
const TEST_MNEMONIC =
|
||||||
|
'test walk nut penalty hip pave soap entry language right filter choice';
|
||||||
|
|
||||||
|
const FIXTURE_CBOR =
|
||||||
|
'84a300d9010281825820e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c3200018282581d71ab1aad52c4774e5da9f2c0fa1a4d07220a0bdd57ee3dce9be860dac61a002dc6c0825839013fd872a48d71872cf76528bb41dd2a5775e2c532c21662a47c6cebf44b6be5f33456f28523ed04d1aaad158541176425378d8ed8fce53f881a3b6a7221021a0002911fa0f5f6';
|
||||||
|
|
||||||
|
const KEY = new MnemonicKeySource(TEST_MNEMONIC).load().privateKeyBech32;
|
||||||
|
|
||||||
|
describe('signTransaction', () => {
|
||||||
|
test('attaches exactly one vkey witness', () => {
|
||||||
|
const { signedCborHex } = signTransaction(FIXTURE_CBOR, KEY);
|
||||||
|
const ws = CML.Transaction.from_cbor_hex(signedCborHex).witness_set().vkeywitnesses();
|
||||||
|
assert.ok(ws);
|
||||||
|
assert.equal(ws!.len(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not alter the transaction body — hash is unchanged', () => {
|
||||||
|
const { signedCborHex, txHashHex } = signTransaction(FIXTURE_CBOR, KEY);
|
||||||
|
assert.equal(txHashHex, decodeTransaction(FIXTURE_CBOR).txHashHex, 'signing must not change the body');
|
||||||
|
assert.equal(decodeTransaction(signedCborHex).txHashHex, txHashHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the signature verifies against the signing public key', () => {
|
||||||
|
const { signedCborHex, txHashHex } = signTransaction(FIXTURE_CBOR, KEY);
|
||||||
|
const w = CML.Transaction.from_cbor_hex(signedCborHex).witness_set().vkeywitnesses()!.get(0);
|
||||||
|
const hashBytes = CML.TransactionHash.from_hex(txHashHex).to_raw_bytes();
|
||||||
|
assert.equal(w.vkey().verify(hashBytes, w.ed25519_signature()), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output is longer than input — witnesses were added', () => {
|
||||||
|
const { signedCborHex } = signTransaction(FIXTURE_CBOR, KEY);
|
||||||
|
assert.ok(signedCborHex.length > FIXTURE_CBOR.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signing is deterministic for the same key and transaction', () => {
|
||||||
|
assert.equal(
|
||||||
|
signTransaction(FIXTURE_CBOR, KEY).signedCborHex,
|
||||||
|
signTransaction(FIXTURE_CBOR, KEY).signedCborHex
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects undecodable CBOR rather than producing garbage', () => {
|
||||||
|
assert.throws(() => signTransaction('not-hex', KEY), /could not be decoded/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an invalid private key', () => {
|
||||||
|
assert.throws(() => signTransaction(FIXTURE_CBOR, 'nonsense'), /private key/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Edge-case pass (beyond the brief) --------------------------------------
|
||||||
|
//
|
||||||
|
// The brief's 7 tests are a floor. sign.ts calls CML.Transaction.from_cbor_hex
|
||||||
|
// independently of decode.ts — it does not automatically inherit decode.ts's
|
||||||
|
// trailing-bytes consumption guard just because that module happens to have
|
||||||
|
// one. Checked directly, not assumed.
|
||||||
|
describe('unconsumed input (trailing bytes) — sign.ts decodes independently of decode.ts', () => {
|
||||||
|
// CML's from_cbor_hex only requires that a *prefix* of the input parse as a
|
||||||
|
// valid transaction — decode.ts's own history (commit 5da4447) documents
|
||||||
|
// this exact gap and fixes it with a re-serialise consumption check. sign.ts
|
||||||
|
// is a SEPARATE call site that does not inherit that fix automatically.
|
||||||
|
// Reproduced empirically before writing this test (see the task report):
|
||||||
|
// against the brief's unmodified implementation, signing
|
||||||
|
// FIXTURE_CBOR + 'deadbeef' did NOT throw — it silently signed the 156-byte
|
||||||
|
// prefix and returned a confident, fully-formed result, with the appended
|
||||||
|
// 4 bytes dropped and no indication anything was wrong. That is precisely
|
||||||
|
// the failure this task warned about: the caller asked to sign one set of
|
||||||
|
// bytes and, with no error, received a signature over a DIFFERENT
|
||||||
|
// (shorter) set of bytes than it supplied.
|
||||||
|
test('rejects a transaction with trailing bytes appended, rather than silently signing a shorter prefix', () => {
|
||||||
|
assert.throws(() => signTransaction(FIXTURE_CBOR + 'deadbeef', KEY), /could not be decoded/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still accepts the clean fixture (no false positive on legitimate input)', () => {
|
||||||
|
assert.doesNotThrow(() => signTransaction(FIXTURE_CBOR, KEY));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single self-valid trailing byte is still caught, not just multi-byte garbage', () => {
|
||||||
|
// Mirrors decode.test.ts's own strongest version of this check: even one
|
||||||
|
// extra byte that would itself decode as something (rather than obvious
|
||||||
|
// noise) must not slip through.
|
||||||
|
assert.throws(() => signTransaction(FIXTURE_CBOR + '00', KEY), /could not be decoded/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The consumption check compares tx.to_cbor_hex() against unsignedCborHex
|
||||||
|
// case-INSENSITIVELY, specifically because CML re-serialises hex as
|
||||||
|
// lowercase regardless of the input's casing — a strict comparison would
|
||||||
|
// reject perfectly legitimate uppercase-hex input as a false positive
|
||||||
|
// "trailing data" mismatch. Notably, decode.ts's OWN test suite documents
|
||||||
|
// this reasoning in a comment but never actually pins it with a test — this
|
||||||
|
// closes that same gap here, verified empirically first (see the task
|
||||||
|
// report) rather than assumed safe by analogy.
|
||||||
|
test('an uppercase-hex rendering of the same clean transaction is still accepted (no false positive from the case-insensitive consumption check)', () => {
|
||||||
|
const result = signTransaction(FIXTURE_CBOR.toUpperCase(), KEY);
|
||||||
|
assert.equal(result.txHashHex, decodeTransaction(FIXTURE_CBOR).txHashHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('indefinite-length CBOR — the agent registry\'s D8 79 9F … FF constructor encoding', () => {
|
||||||
|
// packages/shared/src/tx.ts's deriveNftAssetName() documents that the
|
||||||
|
// agent registry's Conway-era Aiken validators require INDEFINITE-length
|
||||||
|
// CBOR for a Plutus constructor's field array (D8 79 9F … FF) — Lucid's
|
||||||
|
// Data.to instead emits definite-length arrays (D8 79 82 …), which those
|
||||||
|
// validators reject. Both sign.ts's consumption check (above) and
|
||||||
|
// decode.ts's equivalent assert in a comment that CML's parse-then-
|
||||||
|
// reserialise round trip preserves this exact encoding rather than
|
||||||
|
// silently canonicalising it to definite-length — which matters because if
|
||||||
|
// it did NOT, the consumption check would misread that harmless
|
||||||
|
// re-encoding as "unrecognised trailing data" and refuse to sign every
|
||||||
|
// transaction using this ecosystem's own real encoding. That claim was
|
||||||
|
// asserted only in prose until now; this fixture makes it an executable,
|
||||||
|
// permanent check. (decode.ts's own test suite has the exact same untested
|
||||||
|
// gap — out of scope for this task's file boundary; flagged in the report
|
||||||
|
// for a later task to close.)
|
||||||
|
//
|
||||||
|
// Built using the SAME helper functions (cborBytes, cborUint) that
|
||||||
|
// packages/shared/src/tx.ts's deriveNftAssetName() itself uses to build its
|
||||||
|
// OutputReference constructor, for maximum fidelity to the real encoding.
|
||||||
|
function indefiniteLengthConstructor(): Buffer {
|
||||||
|
// Same shape as deriveNftAssetName's outRefCbor: tag 121 (Plutus
|
||||||
|
// constructor index 0), indefinite-length array open, one bytestring
|
||||||
|
// field (reusing FIXTURE_CBOR's own input tx hash, so this is a
|
||||||
|
// realistic 32-byte field rather than arbitrary filler), one small uint
|
||||||
|
// field, indefinite-length array close.
|
||||||
|
const someHash32 = 'e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c3';
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0xd8, 0x79, 0x9f]),
|
||||||
|
cborBytes(someHash32),
|
||||||
|
cborUint(0n),
|
||||||
|
Buffer.from([0xff]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sanity: the hand-rolled bytes are actually the D8 79 9F … FF shape under test', () => {
|
||||||
|
const hex = indefiniteLengthConstructor().toString('hex');
|
||||||
|
assert.ok(hex.startsWith('d8799f'), 'must open with tag 121 + indefinite-length array');
|
||||||
|
assert.ok(hex.endsWith('ff'), 'must close with the indefinite-length-array break byte');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('embedded in the transaction BODY (an inline datum on an output)', () => {
|
||||||
|
// Body-embedded specifically because this is the placement where the
|
||||||
|
// encoding actually participates in the body hash: if CML's round trip
|
||||||
|
// ever DID mangle this encoding, this is the fixture that would show up
|
||||||
|
// as a changed hash, not just a rejected consumption check.
|
||||||
|
function fixtureWithBodyEmbeddedDatum(): string {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const body = unsigned.body();
|
||||||
|
const datum = CML.PlutusData.from_cbor_hex(indefiniteLengthConstructor().toString('hex'));
|
||||||
|
const existingOutput = body.outputs().get(0);
|
||||||
|
const outputWithDatum = CML.TransactionOutput.new(
|
||||||
|
existingOutput.address(),
|
||||||
|
existingOutput.amount(),
|
||||||
|
CML.DatumOption.new_datum(datum),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const outputs = CML.TransactionOutputList.new();
|
||||||
|
outputs.add(outputWithDatum);
|
||||||
|
const newBody = CML.TransactionBody.new(body.inputs(), outputs, body.fee());
|
||||||
|
const tx = CML.Transaction.new(newBody, CML.TransactionWitnessSet.new(), true, undefined);
|
||||||
|
return tx.to_cbor_hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('signTransaction accepts it — no false-positive "unrecognised trailing data" rejection', () => {
|
||||||
|
const cborHex = fixtureWithBodyEmbeddedDatum();
|
||||||
|
assert.doesNotThrow(() => signTransaction(cborHex, KEY));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the body hash is unchanged by signing', () => {
|
||||||
|
const cborHex = fixtureWithBodyEmbeddedDatum();
|
||||||
|
const { txHashHex } = signTransaction(cborHex, KEY);
|
||||||
|
const independentHash = CML.hash_transaction(CML.Transaction.from_cbor_hex(cborHex).body()).to_hex();
|
||||||
|
assert.equal(txHashHex, independentHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the indefinite-length datum itself survives signing byte-for-byte, not just the surrounding transaction', () => {
|
||||||
|
const cborHex = fixtureWithBodyEmbeddedDatum();
|
||||||
|
const { signedCborHex } = signTransaction(cborHex, KEY);
|
||||||
|
const survivingDatum = CML.Transaction.from_cbor_hex(signedCborHex).body().outputs().get(0).datum()!.as_datum()!;
|
||||||
|
assert.equal(survivingDatum.to_cbor_hex(), indefiniteLengthConstructor().toString('hex'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('embedded in the WITNESS SET (a plutus_datums entry)', () => {
|
||||||
|
// The other placement the coordinator's reviewer independently checked.
|
||||||
|
// Unlike the body-embedded case, the body hash can never be affected
|
||||||
|
// here (plutus_datums lives on TransactionWitnessSet, not
|
||||||
|
// TransactionBody) — this fixture instead exercises the OTHER risk: the
|
||||||
|
// consumption check compares the WHOLE transaction's re-serialised CBOR,
|
||||||
|
// witness set included, so a mangled round trip here would still cause a
|
||||||
|
// false-positive rejection even though it could never show up as a
|
||||||
|
// changed hash.
|
||||||
|
function fixtureWithWitnessEmbeddedDatum(): string {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const body = unsigned.body();
|
||||||
|
const datum = CML.PlutusData.from_cbor_hex(indefiniteLengthConstructor().toString('hex'));
|
||||||
|
const witnessSet = CML.TransactionWitnessSet.new();
|
||||||
|
const datums = CML.PlutusDataList.new();
|
||||||
|
datums.add(datum);
|
||||||
|
witnessSet.set_plutus_datums(datums);
|
||||||
|
const tx = CML.Transaction.new(body, witnessSet, true, undefined);
|
||||||
|
return tx.to_cbor_hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('signTransaction accepts it — no false-positive "unrecognised trailing data" rejection', () => {
|
||||||
|
const cborHex = fixtureWithWitnessEmbeddedDatum();
|
||||||
|
assert.doesNotThrow(() => signTransaction(cborHex, KEY));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the body hash is unchanged (same body as FIXTURE_CBOR — only the witness set differs)', () => {
|
||||||
|
const cborHex = fixtureWithWitnessEmbeddedDatum();
|
||||||
|
const { txHashHex } = signTransaction(cborHex, KEY);
|
||||||
|
assert.equal(txHashHex, decodeTransaction(FIXTURE_CBOR).txHashHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A second, independent signing identity — a different account index derives
|
||||||
|
// a different key pair from the same throwaway mnemonic (already proven
|
||||||
|
// distinct in keysource.test.ts: "a different account index yields a
|
||||||
|
// different address"). Used below wherever a test needs two DIFFERENT keys.
|
||||||
|
const KEY_2 = new MnemonicKeySource(TEST_MNEMONIC, 1).load().privateKeyBech32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a variant of FIXTURE_CBOR that already carries one vkey witness from
|
||||||
|
* a throwaway, unrelated key — the same fixture shape decode.test.ts's own
|
||||||
|
* "still accepts a signed transaction" test uses. Returns the CBOR plus the
|
||||||
|
* pre-existing witness's public key (bech32) and raw signature bytes, so a
|
||||||
|
* test can confirm that EXACT witness — not merely "some" witness — survives.
|
||||||
|
*/
|
||||||
|
function fixtureWithOneWitness(): {
|
||||||
|
cborHex: string;
|
||||||
|
existingVkeyBech32: string;
|
||||||
|
existingSignatureHex: string;
|
||||||
|
} {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const body = unsigned.body();
|
||||||
|
const hash = CML.hash_transaction(body);
|
||||||
|
const preexistingKey = CML.PrivateKey.generate_ed25519();
|
||||||
|
const signature = preexistingKey.sign(hash.to_raw_bytes());
|
||||||
|
|
||||||
|
const vkeywitnesses = CML.VkeywitnessList.new();
|
||||||
|
vkeywitnesses.add(CML.Vkeywitness.new(preexistingKey.to_public(), signature));
|
||||||
|
const witnessSet = CML.TransactionWitnessSet.new();
|
||||||
|
witnessSet.set_vkeywitnesses(vkeywitnesses);
|
||||||
|
|
||||||
|
const withWitness = CML.Transaction.new(body, witnessSet, true, undefined);
|
||||||
|
return {
|
||||||
|
cborHex: withWitness.to_cbor_hex(),
|
||||||
|
existingVkeyBech32: preexistingKey.to_public().to_bech32(),
|
||||||
|
existingSignatureHex: Buffer.from(signature.to_raw_bytes()).toString('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('signing a transaction that already carries a vkey witness', () => {
|
||||||
|
// The brief's implementation copies existing witnesses into a fresh
|
||||||
|
// VkeywitnessList before appending the new one. This is the test that
|
||||||
|
// actually exercises that copy step — nothing in the brief's own 7 tests
|
||||||
|
// ever signs a transaction that already has a witness, so a version that
|
||||||
|
// dropped the copy (built the new list containing ONLY the freshly-added
|
||||||
|
// witness) would satisfy every brief test while silently discarding any
|
||||||
|
// other signer's contribution — fatal for anything requiring more than one
|
||||||
|
// signature. Verified below (mutation) that this test does in fact catch
|
||||||
|
// that exact regression.
|
||||||
|
test('the pre-existing witness survives, and the new one is appended rather than replacing it', () => {
|
||||||
|
const { cborHex, existingVkeyBech32, existingSignatureHex } = fixtureWithOneWitness();
|
||||||
|
const { signedCborHex, txHashHex } = signTransaction(cborHex, KEY);
|
||||||
|
|
||||||
|
const ws = CML.Transaction.from_cbor_hex(signedCborHex).witness_set().vkeywitnesses();
|
||||||
|
assert.ok(ws);
|
||||||
|
assert.equal(ws!.len(), 2, 'expected the pre-existing witness PLUS the newly added one, not one replacing the other');
|
||||||
|
|
||||||
|
const witnesses = [ws!.get(0), ws!.get(1)];
|
||||||
|
const vkeyBech32s = witnesses.map((w) => w.vkey().to_bech32());
|
||||||
|
const signatureHexes = witnesses.map((w) => Buffer.from(w.ed25519_signature().to_raw_bytes()).toString('hex'));
|
||||||
|
|
||||||
|
assert.ok(vkeyBech32s.includes(existingVkeyBech32), 'the pre-existing witness public key must still be present');
|
||||||
|
assert.ok(
|
||||||
|
signatureHexes.includes(existingSignatureHex),
|
||||||
|
'the pre-existing witness signature bytes must be byte-for-byte unchanged, not merely a witness for the same key'
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSignerVkeyBech32 = CML.PrivateKey.from_bech32(KEY).to_public().to_bech32();
|
||||||
|
assert.ok(vkeyBech32s.includes(newSignerVkeyBech32), 'the newly added witness must also be present');
|
||||||
|
|
||||||
|
// Both witnesses must independently verify against the (unchanged) tx hash.
|
||||||
|
const hashBytes = CML.TransactionHash.from_hex(txHashHex).to_raw_bytes();
|
||||||
|
for (const w of witnesses) {
|
||||||
|
assert.equal(w.vkey().verify(hashBytes, w.ed25519_signature()), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signing a witnessed transaction still does not alter the body — hash matches the pre-witnessed fixture', () => {
|
||||||
|
const { cborHex } = fixtureWithOneWitness();
|
||||||
|
const { txHashHex } = signTransaction(cborHex, KEY);
|
||||||
|
// Same underlying body as FIXTURE_CBOR (only the witness set differs), so
|
||||||
|
// the hash must match the plain fixture's hash too.
|
||||||
|
assert.equal(txHashHex, decodeTransaction(FIXTURE_CBOR).txHashHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signing with two different keys', () => {
|
||||||
|
test('produces two distinct witnesses when applied one after the other, and both verify', () => {
|
||||||
|
const firstPass = signTransaction(FIXTURE_CBOR, KEY);
|
||||||
|
const secondPass = signTransaction(firstPass.signedCborHex, KEY_2);
|
||||||
|
|
||||||
|
const ws = CML.Transaction.from_cbor_hex(secondPass.signedCborHex).witness_set().vkeywitnesses();
|
||||||
|
assert.ok(ws);
|
||||||
|
assert.equal(ws!.len(), 2, 'both signatures must be present — the second call must not replace the first');
|
||||||
|
|
||||||
|
const witnesses = [ws!.get(0), ws!.get(1)];
|
||||||
|
const vkeyBech32s = witnesses.map((w) => w.vkey().to_bech32());
|
||||||
|
assert.notEqual(vkeyBech32s[0], vkeyBech32s[1], 'the two witnesses must be for two DIFFERENT public keys');
|
||||||
|
|
||||||
|
const signatureHexes = witnesses.map((w) => Buffer.from(w.ed25519_signature().to_raw_bytes()).toString('hex'));
|
||||||
|
assert.notEqual(signatureHexes[0], signatureHexes[1], 'the two witnesses must carry two DIFFERENT signatures');
|
||||||
|
|
||||||
|
const hashBytes = CML.TransactionHash.from_hex(secondPass.txHashHex).to_raw_bytes();
|
||||||
|
for (const w of witnesses) {
|
||||||
|
assert.equal(w.vkey().verify(hashBytes, w.ed25519_signature()), true, 'every witness must independently verify');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither key's public key can verify the OTHER key's signature — confirms
|
||||||
|
// the two witnesses are not accidentally duplicates of one another.
|
||||||
|
assert.equal(witnesses[0].vkey().verify(hashBytes, witnesses[1].ed25519_signature()), false);
|
||||||
|
assert.equal(witnesses[1].vkey().verify(hashBytes, witnesses[0].ed25519_signature()), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signing the SAME transaction independently (not chained) with two different keys yields two different single-witness results', () => {
|
||||||
|
const signedByKey1 = signTransaction(FIXTURE_CBOR, KEY);
|
||||||
|
const signedByKey2 = signTransaction(FIXTURE_CBOR, KEY_2);
|
||||||
|
assert.notEqual(signedByKey1.signedCborHex, signedByKey2.signedCborHex);
|
||||||
|
// Both still sign the exact same, unaltered body.
|
||||||
|
assert.equal(signedByKey1.txHashHex, signedByKey2.txHashHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signing is deterministic — strengthened beyond the brief\'s single check', () => {
|
||||||
|
// The brief's own determinism test only compares the whole signedCborHex
|
||||||
|
// string across two calls using the SAME already-loaded KEY constant. This
|
||||||
|
// strengthens it two ways: (a) asserts on the witness's raw signature bytes
|
||||||
|
// specifically, the property that actually matters (ed25519 is a
|
||||||
|
// deterministic signature scheme per RFC 8032 — no random nonce, so the
|
||||||
|
// same key signing the same message must always produce the same
|
||||||
|
// signature), and (b) re-derives the key from the mnemonic a SECOND time
|
||||||
|
// (a fresh MnemonicKeySource / fresh load()) rather than reusing the one
|
||||||
|
// in-memory KEY constant, so a hypothetical bug that made determinism
|
||||||
|
// depend on JS object identity rather than the key's actual bytes would be
|
||||||
|
// caught.
|
||||||
|
test('two independently re-derived instances of the same key produce byte-identical signatures', () => {
|
||||||
|
const rederivedKey = new MnemonicKeySource(TEST_MNEMONIC).load().privateKeyBech32;
|
||||||
|
assert.equal(rederivedKey, KEY, 'sanity check: re-derivation must reproduce the same key bech32');
|
||||||
|
|
||||||
|
const a = signTransaction(FIXTURE_CBOR, KEY);
|
||||||
|
const b = signTransaction(FIXTURE_CBOR, rederivedKey);
|
||||||
|
|
||||||
|
const wa = CML.Transaction.from_cbor_hex(a.signedCborHex).witness_set().vkeywitnesses()!.get(0);
|
||||||
|
const wb = CML.Transaction.from_cbor_hex(b.signedCborHex).witness_set().vkeywitnesses()!.get(0);
|
||||||
|
assert.equal(
|
||||||
|
Buffer.from(wa.ed25519_signature().to_raw_bytes()).toString('hex'),
|
||||||
|
Buffer.from(wb.ed25519_signature().to_raw_bytes()).toString('hex')
|
||||||
|
);
|
||||||
|
assert.equal(a.signedCborHex, b.signedCborHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a transaction carrying auxiliary data / metadata', () => {
|
||||||
|
// Builds a variant of FIXTURE_CBOR with a real AuxiliaryData (metadata)
|
||||||
|
// section attached to the transaction, the same way the brief's own
|
||||||
|
// implementation passes `tx.auxiliary_data()` through into the reconstructed
|
||||||
|
// signed Transaction. Confirms that plumbing actually works end to end,
|
||||||
|
// rather than trusting the pass-through argument is wired correctly.
|
||||||
|
function fixtureWithMetadata(): { cborHex: string; auxDataCborHex: string } {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const body = unsigned.body();
|
||||||
|
|
||||||
|
const metadata = CML.Metadata.new();
|
||||||
|
metadata.set(674n, CML.TransactionMetadatum.new_text('sign.ts edge-case pass'));
|
||||||
|
const auxData = CML.AuxiliaryData.new();
|
||||||
|
auxData.add_metadata(metadata);
|
||||||
|
// Captured BEFORE auxData is passed into Transaction.new() below.
|
||||||
|
// wasm-bindgen gives Transaction.new() ownership of its AuxiliaryData
|
||||||
|
// argument (Rust move semantics leaking through the WASM boundary):
|
||||||
|
// reusing `auxData` itself after this call throws "null pointer passed
|
||||||
|
// to rust" (confirmed empirically — see the task report). This is a
|
||||||
|
// property of CML's bindings, not of sign.ts, which never reuses a
|
||||||
|
// getter-returned object after handing it to `.new()` for exactly this
|
||||||
|
// reason. A caller of THIS fixture function only ever sees the CBOR
|
||||||
|
// string, so it is unaffected either way.
|
||||||
|
const auxDataCborHex = auxData.to_cbor_hex();
|
||||||
|
|
||||||
|
const withAux = CML.Transaction.new(body, CML.TransactionWitnessSet.new(), true, auxData);
|
||||||
|
return { cborHex: withAux.to_cbor_hex(), auxDataCborHex };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('auxiliary data survives signing byte-for-byte', () => {
|
||||||
|
const { cborHex, auxDataCborHex } = fixtureWithMetadata();
|
||||||
|
const { signedCborHex } = signTransaction(cborHex, KEY);
|
||||||
|
|
||||||
|
const signedTx = CML.Transaction.from_cbor_hex(signedCborHex);
|
||||||
|
const survivingAuxData = signedTx.auxiliary_data();
|
||||||
|
assert.ok(survivingAuxData, 'auxiliary data must not be dropped when a witness is added');
|
||||||
|
assert.equal(survivingAuxData!.to_cbor_hex(), auxDataCborHex);
|
||||||
|
|
||||||
|
// Content-level check too, not just byte-equality of the CBOR blob.
|
||||||
|
const label = survivingAuxData!.metadata()!.get(674n);
|
||||||
|
assert.ok(label);
|
||||||
|
assert.equal(label!.as_text(), 'sign.ts edge-case pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the body hash is unchanged by the presence of auxiliary data', () => {
|
||||||
|
const { cborHex } = fixtureWithMetadata();
|
||||||
|
const { txHashHex } = signTransaction(cborHex, KEY);
|
||||||
|
// Same body as FIXTURE_CBOR — aux data lives alongside the body, not
|
||||||
|
// inside it, so the hash must be identical to the plain fixture's.
|
||||||
|
assert.equal(txHashHex, decodeTransaction(FIXTURE_CBOR).txHashHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the witness itself still verifies against the unchanged hash when auxiliary data is present', () => {
|
||||||
|
const { cborHex } = fixtureWithMetadata();
|
||||||
|
const { signedCborHex, txHashHex } = signTransaction(cborHex, KEY);
|
||||||
|
const w = CML.Transaction.from_cbor_hex(signedCborHex).witness_set().vkeywitnesses()!.get(0);
|
||||||
|
const hashBytes = CML.TransactionHash.from_hex(txHashHex).to_raw_bytes();
|
||||||
|
assert.equal(w.vkey().verify(hashBytes, w.ed25519_signature()), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the is_valid flag is threaded through, not hardcoded', () => {
|
||||||
|
// is_valid lives on CML.Transaction itself, not on TransactionBody — so it
|
||||||
|
// is INVISIBLE to every hash-equality check elsewhere in this file
|
||||||
|
// (hash_transaction only ever hashes the body). A version of sign.ts that
|
||||||
|
// hardcodes Transaction.new(body, witnessSet, true, ...) regardless of the
|
||||||
|
// INPUT transaction's own is_valid() would satisfy every other test in
|
||||||
|
// this file, including "does not alter the transaction body — hash is
|
||||||
|
// unchanged", because FIXTURE_CBOR's own is_valid happens to already be
|
||||||
|
// true. Confirmed by mutation (see the task report): flipping the
|
||||||
|
// hardcoded literal from true to false still passed all 21 pre-existing
|
||||||
|
// tests, with zero failures.
|
||||||
|
//
|
||||||
|
// is_valid: false is Cardano's collateral-forfeit encoding: a node sets it
|
||||||
|
// when a script transaction is included specifically to consume collateral
|
||||||
|
// after a phase-2 validation failure. If the signer silently rewrote this
|
||||||
|
// to true, a node re-executing phase 2 would find the claimed validity
|
||||||
|
// mismatched and reject the transaction outright — the signer would have
|
||||||
|
// silently rewritten a field of the transaction it was asked to sign.
|
||||||
|
//
|
||||||
|
// Confirmed directly (throwaway probe, see the task report) that CML can
|
||||||
|
// construct, serialise, and correctly round-trip an is_valid: false
|
||||||
|
// Transaction before relying on that here.
|
||||||
|
function fixtureWithIsValid(isValid: boolean): string {
|
||||||
|
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
|
||||||
|
const tx = CML.Transaction.new(unsigned.body(), unsigned.witness_set(), isValid, undefined);
|
||||||
|
return tx.to_cbor_hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an is_valid: false transaction is signed with is_valid still false, not silently flipped to true', () => {
|
||||||
|
const cborHex = fixtureWithIsValid(false);
|
||||||
|
const { signedCborHex } = signTransaction(cborHex, KEY);
|
||||||
|
const signedTx = CML.Transaction.from_cbor_hex(signedCborHex);
|
||||||
|
assert.equal(signedTx.is_valid(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an is_valid: true transaction is signed with is_valid still true (pinned explicitly, not just incidentally true because the default fixture happens to be)', () => {
|
||||||
|
const cborHex = fixtureWithIsValid(true);
|
||||||
|
const { signedCborHex } = signTransaction(cborHex, KEY);
|
||||||
|
const signedTx = CML.Transaction.from_cbor_hex(signedCborHex);
|
||||||
|
assert.equal(signedTx.is_valid(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is_valid: false does not affect the body hash — is_valid lives on Transaction, not TransactionBody', () => {
|
||||||
|
const cborHex = fixtureWithIsValid(false);
|
||||||
|
const { txHashHex } = signTransaction(cborHex, KEY);
|
||||||
|
assert.equal(txHashHex, decodeTransaction(FIXTURE_CBOR).txHashHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('key material is never emitted', () => {
|
||||||
|
// Task 5's keysource.ts had to fix an actual leak (TypeScript `private` is
|
||||||
|
// compile-time only). sign.ts never holds a KeySource instance and never
|
||||||
|
// constructs a class at all — privateKeyBech32 is a plain function
|
||||||
|
// parameter — but the discipline from that task (verify by execution, not
|
||||||
|
// by reading the code and assuming) is applied here too: every throw site
|
||||||
|
// in sign.ts is checked directly against the actual rejected key value.
|
||||||
|
test('an invalid private key value never appears in the thrown error message', () => {
|
||||||
|
const rejected = 'this-key-value-must-never-appear-in-the-thrown-message';
|
||||||
|
try {
|
||||||
|
signTransaction(FIXTURE_CBOR, rejected);
|
||||||
|
assert.fail('expected signTransaction to throw');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
assert.ok(!message.includes(rejected));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a VALID private key never appears in any thrown error message either (decode failure path)', () => {
|
||||||
|
// Distinct code path from the test above: here the KEY is well-formed
|
||||||
|
// and never even reaches PrivateKey.from_bech32, because the CBOR is
|
||||||
|
// rejected first. Confirms the decode-failure branch's error message
|
||||||
|
// (built from CML's own err.message, not from any local variable) cannot
|
||||||
|
// somehow embed the key argument either.
|
||||||
|
try {
|
||||||
|
signTransaction('not-hex', KEY);
|
||||||
|
assert.fail('expected signTransaction to throw');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
assert.ok(!message.includes(KEY));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
718
packages/signer/test/smoke/signer-tools.test.ts
Normal file
718
packages/signer/test/smoke/signer-tools.test.ts
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
import { describe, test, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { MnemonicKeySource } from '../../src/keysource.ts';
|
||||||
|
import { loadConfig } from '../../src/config.ts';
|
||||||
|
import { signTransaction } from '../../src/sign.ts';
|
||||||
|
|
||||||
|
const TEST_MNEMONIC =
|
||||||
|
'test walk nut penalty hip pave soap entry language right filter choice';
|
||||||
|
|
||||||
|
const FIXTURE_CBOR =
|
||||||
|
'84a300d9010281825820e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c3200018282581d71ab1aad52c4774e5da9f2c0fa1a4d07220a0bdd57ee3dce9be860dac61a002dc6c0825839013fd872a48d71872cf76528bb41dd2a5775e2c532c21662a47c6cebf44b6be5f33456f28523ed04d1aaad158541176425378d8ed8fce53f881a3b6a7221021a0002911fa0f5f6';
|
||||||
|
|
||||||
|
const EXPECTED_TOOLS = [
|
||||||
|
'vector_signer_decode_transaction',
|
||||||
|
'vector_signer_get_address',
|
||||||
|
'vector_signer_get_spend_limits',
|
||||||
|
'vector_signer_sign',
|
||||||
|
];
|
||||||
|
|
||||||
|
let client: Client;
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
async function callText(name: string, args: Record<string, unknown> = {}): Promise<string> {
|
||||||
|
const r = await client.callTool({ name, arguments: args });
|
||||||
|
const c = r.content as Array<{ type: string; text?: string }>;
|
||||||
|
return c?.[0]?.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'signer-smoke-'));
|
||||||
|
const mnemonicFile = join(dir, 'mnemonic.txt');
|
||||||
|
writeFileSync(mnemonicFile, TEST_MNEMONIC, { mode: 0o600 });
|
||||||
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: 'node',
|
||||||
|
args: ['packages/signer/build/index.js'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
VECTOR_SIGNER_MNEMONIC_FILE: mnemonicFile,
|
||||||
|
VECTOR_SIGNER_AUDIT_LOG_PATH: join(dir, 'audit.json'),
|
||||||
|
VECTOR_SIGNER_SPEND_LIMIT_PER_TX: '100000000',
|
||||||
|
VECTOR_SIGNER_SPEND_LIMIT_DAILY: '500000000',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client = new Client({ name: 'signer-smoke', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
try { await client.close(); } catch {}
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signer MCP server over stdio', () => {
|
||||||
|
test('exposes exactly the four expected tools', async () => {
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
assert.deepStrictEqual(tools.map((t) => t.name).sort(), EXPECTED_TOOLS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no tool accepts a mnemonic or private key parameter', async () => {
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
for (const t of tools) {
|
||||||
|
const props = Object.keys((t.inputSchema as any).properties ?? {});
|
||||||
|
for (const p of props) {
|
||||||
|
assert.ok(!/mnemonic|private_?key|seed/i.test(p), `${t.name} exposes key material as parameter "${p}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get_address returns the derived addr1 address', async () => {
|
||||||
|
assert.match(await callText('vector_signer_get_address'), /addr1[0-9a-z]+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decode_transaction reports the net outflow without signing', async () => {
|
||||||
|
const text = await callText('vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR });
|
||||||
|
assert.match(text, /Net outflow/i);
|
||||||
|
assert.ok(!/signed/i.test(text), 'decode must not sign');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fixture was built for a DIFFERENT wallet, so for this test key both of
|
||||||
|
// its outputs are foreign: net outflow is ~1000 AP3X, far over the 100 AP3X
|
||||||
|
// per-transaction limit. Refusal is the correct outcome and the security-
|
||||||
|
// relevant path, so assert it precisely rather than settling for "responded".
|
||||||
|
test('sign refuses a transaction whose net outflow exceeds the per-transaction limit', async () => {
|
||||||
|
const text = await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||||
|
assert.match(text, /REFUSED/);
|
||||||
|
assert.match(text, /per-transaction limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refusal returns no signed CBOR', async () => {
|
||||||
|
const text = await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||||
|
assert.ok(!/Signed CBOR/i.test(text), 'a refused transaction must not return signed bytes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refusal is recorded in the audit log but consumes no daily budget', async () => {
|
||||||
|
await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||||
|
const limits = await callText('vector_signer_get_spend_limits');
|
||||||
|
assert.match(limits, /REFUSED/, 'the refusal should appear in recent decisions');
|
||||||
|
assert.match(limits, /Committed today:\s*0\.000000 AP3X/, 'a refusal must not consume budget');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get_spend_limits reports the configured limits', async () => {
|
||||||
|
const text = await callText('vector_signer_get_spend_limits');
|
||||||
|
assert.match(text, /100\.000000/);
|
||||||
|
assert.match(text, /500\.000000/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sign rejects malformed CBOR', async () => {
|
||||||
|
const text = await callText('vector_signer_sign', { txCbor: 'not-hex' });
|
||||||
|
assert.match(text, /could not be decoded|refus/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Edge-case pass (beyond the brief) --------------------------------------
|
||||||
|
//
|
||||||
|
// The brief's 9 tests above are a floor. Every previous task in this signer
|
||||||
|
// package shipped at least one defect that produced a plausible-looking
|
||||||
|
// success rather than an error (see .superpowers/sdd/signer-task-*-report.md)
|
||||||
|
// — that same suspicion is applied here, especially to the audit-then-return
|
||||||
|
// ordering in the sign tool, which is THE thing this task's brief calls out
|
||||||
|
// as mattering most: audit.ts's append() throws by design on a write
|
||||||
|
// failure (see the CONTRACT note at the top of audit.ts), and a signature
|
||||||
|
// must never be released to a caller when its own audit record failed to
|
||||||
|
// land durably on disk.
|
||||||
|
//
|
||||||
|
// Each test below spins up its OWN dedicated server (own temp dir, own
|
||||||
|
// mnemonic file, own audit log path) rather than sharing the brief's module-
|
||||||
|
// level `client`/`dir` — the brief's own 9 tests are order-dependent on a
|
||||||
|
// single accumulating audit log, and coupling more tests onto that same
|
||||||
|
// shared state would make failures harder to isolate for no benefit, since
|
||||||
|
// nothing below needs to observe the brief's own tests' history.
|
||||||
|
|
||||||
|
const TEST_IDENTITY = new MnemonicKeySource(TEST_MNEMONIC).load();
|
||||||
|
|
||||||
|
interface EdgeServer {
|
||||||
|
client: Client;
|
||||||
|
dir: string;
|
||||||
|
stderrChunks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnSigner(
|
||||||
|
opts: {
|
||||||
|
auditLogPath?: (dir: string) => string;
|
||||||
|
perTx?: string;
|
||||||
|
daily?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<EdgeServer> {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'signer-smoke-edge-'));
|
||||||
|
const mnemonicFile = join(dir, 'mnemonic.txt');
|
||||||
|
writeFileSync(mnemonicFile, TEST_MNEMONIC, { mode: 0o600 });
|
||||||
|
const auditLogPath = opts.auditLogPath ? opts.auditLogPath(dir) : join(dir, 'audit.json');
|
||||||
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: 'node',
|
||||||
|
args: ['packages/signer/build/index.js'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
VECTOR_SIGNER_MNEMONIC_FILE: mnemonicFile,
|
||||||
|
VECTOR_SIGNER_AUDIT_LOG_PATH: auditLogPath,
|
||||||
|
VECTOR_SIGNER_SPEND_LIMIT_PER_TX: opts.perTx ?? '100000000',
|
||||||
|
VECTOR_SIGNER_SPEND_LIMIT_DAILY: opts.daily ?? '500000000',
|
||||||
|
},
|
||||||
|
// Piped (not the brief's default 'inherit') specifically so the startup
|
||||||
|
// banner can be captured and inspected for leaked key material below.
|
||||||
|
// Per the SDK's own doc comment on StdioClientTransport#stderr, the
|
||||||
|
// PassThrough stream exists immediately after construction, so the
|
||||||
|
// listener below is attached before connect() (which spawns the child)
|
||||||
|
// to avoid losing early output.
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
const stderrChunks: string[] = [];
|
||||||
|
transport.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk.toString('utf8')));
|
||||||
|
|
||||||
|
const client = new Client({ name: 'signer-smoke-edge', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
// client.connect() only waits for the MCP initialize handshake on stdout;
|
||||||
|
// stderr is a separate, independently-buffered pipe with no ordering
|
||||||
|
// guarantee relative to stdout at the Node event-loop level, even though
|
||||||
|
// the server writes its banner before it ever starts that handshake. A
|
||||||
|
// short grace period avoids a race between "handshake done" and "banner
|
||||||
|
// chunk delivered" when a test reads stderrChunks immediately afterward.
|
||||||
|
await new Promise((r) => setTimeout(r, 75));
|
||||||
|
return { client, dir, stderrChunks };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopSigner(server: EdgeServer): Promise<void> {
|
||||||
|
try {
|
||||||
|
await server.client.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
rmSync(server.dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callToolText(client: Client, name: string, args: Record<string, unknown> = {}): Promise<string> {
|
||||||
|
const r = await client.callTool({ name, arguments: args });
|
||||||
|
const c = r.content as Array<{ type: string; text?: string }>;
|
||||||
|
return c?.[0]?.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a synthetic transaction whose SOLE output pays `payToAddress`, with
|
||||||
|
// a fee well inside the smoke test's 100/500 AP3X limits. The brief's own
|
||||||
|
// FIXTURE_CBOR is deliberately refused for TEST_MNEMONIC (built for a
|
||||||
|
// different, funded wallet — see the note above the "sign refuses..." test),
|
||||||
|
// so a genuinely in-policy transaction has to be constructed by hand to
|
||||||
|
// exercise the "signed" branch of vector_signer_sign at all.
|
||||||
|
//
|
||||||
|
// Verified against a throwaway probe before use in any test (see the task
|
||||||
|
// report): decodes cleanly with decode.ts, policy.evaluate() returns
|
||||||
|
// allowed:true with netOutflowLovelace equal to feeLovelace alone (the
|
||||||
|
// output returns to its own address, so it counts as change, not outflow),
|
||||||
|
// and signTransaction() accepts it. The referenced input UTXO does not need
|
||||||
|
// to exist on-chain — decode.ts/policy.ts/sign.ts never resolve inputs
|
||||||
|
// against the chain, by design (that is exactly why this signer needs no
|
||||||
|
// network access at all).
|
||||||
|
function buildInPolicyTxCbor(payToAddress: string, feeLovelace = 200_000n, outputLovelace = 5_000_000n): string {
|
||||||
|
const inputTxHash = CML.TransactionHash.from_hex(
|
||||||
|
'e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c32'
|
||||||
|
);
|
||||||
|
const inputs = CML.TransactionInputList.new();
|
||||||
|
inputs.add(CML.TransactionInput.new(inputTxHash, 0n));
|
||||||
|
|
||||||
|
const outputs = CML.TransactionOutputList.new();
|
||||||
|
outputs.add(
|
||||||
|
CML.TransactionOutput.new(CML.Address.from_bech32(payToAddress), CML.Value.from_coin(outputLovelace))
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = CML.TransactionBody.new(inputs, outputs, feeLovelace);
|
||||||
|
const tx = CML.Transaction.new(body, CML.TransactionWitnessSet.new(), true, undefined);
|
||||||
|
return tx.to_cbor_hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A minimal hand-rolled Transport, used ONLY by the stdout-purity test below.
|
||||||
|
// StdioClientTransport (used everywhere else in this file) does not expose
|
||||||
|
// the child process's raw stdout stream through any public API — only
|
||||||
|
// `.stderr` and `.pid` are getters on it, by design, since a normal caller
|
||||||
|
// should only ever see fully parsed messages. That is exactly the wrong tool
|
||||||
|
// for this test: verified directly against the SDK's own source
|
||||||
|
// (node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js and
|
||||||
|
// client/stdio.js) that `ReadBuffer.readMessage()` advances its internal
|
||||||
|
// buffer PAST a line before attempting to parse it, so a line that fails
|
||||||
|
// `JSON.parse` is silently skipped — the read loop moves on to the next
|
||||||
|
// line and the connection keeps working. A stray non-JSON line on stdout
|
||||||
|
// (e.g. index.ts's banner lines written via console.log instead of
|
||||||
|
// console.error) would therefore corrupt the protocol stream without
|
||||||
|
// failing the handshake, without failing any tool call, and without
|
||||||
|
// tripping any assertion that only checks "did the connection work" —
|
||||||
|
// which is all every other test in this file does. This class exists so a
|
||||||
|
// test can instead read every raw byte written to stdout directly and
|
||||||
|
// assert on it, independent of the SDK's own leniency.
|
||||||
|
//
|
||||||
|
// Reimplements the wire format directly (JSON.stringify(message) + '\n' to
|
||||||
|
// send; split-on-'\n', JSON.parse each line to receive) rather than
|
||||||
|
// importing the SDK's own ReadBuffer/serializeMessage — both are internal,
|
||||||
|
// unexported implementation details of shared/stdio.js, not part of the
|
||||||
|
// package's public export surface (unlike client/index.js and
|
||||||
|
// client/stdio.js, which this file already imports). The format itself is
|
||||||
|
// simple enough (confirmed by reading the same source above) that
|
||||||
|
// reproducing it directly here is more honest than reaching into another
|
||||||
|
// module's internals.
|
||||||
|
class RawCapturingStdioTransport implements Transport {
|
||||||
|
private child?: ChildProcess;
|
||||||
|
private lineBuffer = '';
|
||||||
|
readonly rawStdoutChunks: string[] = [];
|
||||||
|
onclose?: () => void;
|
||||||
|
onerror?: (error: Error) => void;
|
||||||
|
onmessage?: (message: JSONRPCMessage) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly command: string,
|
||||||
|
private readonly args: string[],
|
||||||
|
private readonly env: NodeJS.ProcessEnv
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.child = spawn(this.command, this.args, {
|
||||||
|
env: this.env,
|
||||||
|
stdio: ['pipe', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
this.child.stdout!.on('data', (chunk: Buffer) => {
|
||||||
|
const chunkText = chunk.toString('utf8');
|
||||||
|
// Captured BEFORE any line-splitting/parsing below — this is the raw
|
||||||
|
// ground truth the test asserts against, deliberately independent of
|
||||||
|
// whatever the parsing loop below does with it.
|
||||||
|
this.rawStdoutChunks.push(chunkText);
|
||||||
|
|
||||||
|
this.lineBuffer += chunkText;
|
||||||
|
let index: number;
|
||||||
|
while ((index = this.lineBuffer.indexOf('\n')) !== -1) {
|
||||||
|
const line = this.lineBuffer.slice(0, index).replace(/\r$/, '');
|
||||||
|
this.lineBuffer = this.lineBuffer.slice(index + 1);
|
||||||
|
if (line.length === 0) continue;
|
||||||
|
try {
|
||||||
|
this.onmessage?.(JSON.parse(line) as JSONRPCMessage);
|
||||||
|
} catch (err) {
|
||||||
|
this.onerror?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.child.on('close', () => this.onclose?.());
|
||||||
|
this.child.on('error', (err) => this.onerror?.(err));
|
||||||
|
await new Promise<void>((resolvePromise, reject) => {
|
||||||
|
this.child!.once('spawn', () => resolvePromise());
|
||||||
|
this.child!.once('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(message: JSONRPCMessage): Promise<void> {
|
||||||
|
const stdin = this.child?.stdin;
|
||||||
|
if (!stdin) throw new Error('RawCapturingStdioTransport: not connected');
|
||||||
|
const json = JSON.stringify(message) + '\n';
|
||||||
|
await new Promise<void>((resolvePromise) => {
|
||||||
|
if (stdin.write(json)) resolvePromise();
|
||||||
|
else stdin.once('drain', resolvePromise);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.child?.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stdout carries ONLY the MCP protocol — direct assertion, not an incidental one', () => {
|
||||||
|
// Review finding (Task 7 follow-up): swapping index.ts's two startup-
|
||||||
|
// banner console.error calls for console.log was previously caught only
|
||||||
|
// incidentally, by the "startup banner (stderr)" test's
|
||||||
|
// `banner.length > 0` sanity check happening to fail once the banner
|
||||||
|
// stopped going to stderr at all — a coincidence of a DIFFERENT test's
|
||||||
|
// setup, not a real check of stdout. Confirmed directly against the SDK's
|
||||||
|
// own source (see the comment on RawCapturingStdioTransport above) that
|
||||||
|
// there is no implicit tripwire either: a corrupted line is silently
|
||||||
|
// skipped by the read loop, so the connection and every tool call in this
|
||||||
|
// suite would keep working normally even with genuinely corrupted stdout.
|
||||||
|
test('every line written to stdout, across a healthy startup and a normal tool call, is valid JSON', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'signer-smoke-stdout-'));
|
||||||
|
const mnemonicFile = join(dir, 'mnemonic.txt');
|
||||||
|
writeFileSync(mnemonicFile, TEST_MNEMONIC, { mode: 0o600 });
|
||||||
|
|
||||||
|
const transport = new RawCapturingStdioTransport('node', ['packages/signer/build/index.js'], {
|
||||||
|
...process.env,
|
||||||
|
VECTOR_SIGNER_MNEMONIC_FILE: mnemonicFile,
|
||||||
|
VECTOR_SIGNER_AUDIT_LOG_PATH: join(dir, 'audit.json'),
|
||||||
|
VECTOR_SIGNER_SPEND_LIMIT_PER_TX: '100000000',
|
||||||
|
VECTOR_SIGNER_SPEND_LIMIT_DAILY: '500000000',
|
||||||
|
});
|
||||||
|
const client = new Client({ name: 'signer-smoke-stdout-purity', version: '1.0.0' });
|
||||||
|
try {
|
||||||
|
// The real handshake (initialize -> notifications/initialized), driven
|
||||||
|
// by the actual Client class for protocol correctness — only the
|
||||||
|
// transport underneath it is custom. See Client.connect() in
|
||||||
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/client/index.js: it
|
||||||
|
// calls transport.start(), sends 'initialize', then
|
||||||
|
// 'notifications/initialized' once the response validates.
|
||||||
|
await client.connect(transport);
|
||||||
|
// One normal tool call, matching "a normal tool call" from the review.
|
||||||
|
await callToolText(client, 'vector_signer_get_address');
|
||||||
|
|
||||||
|
const rawOutput = transport.rawStdoutChunks.join('');
|
||||||
|
const lines = rawOutput
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.replace(/\r$/, ''))
|
||||||
|
.filter((l) => l.length > 0);
|
||||||
|
|
||||||
|
assert.ok(lines.length >= 2, `sanity: expected at least an initialize response and a tool-call response, got ${lines.length} line(s)`);
|
||||||
|
for (const [i, line] of lines.entries()) {
|
||||||
|
assert.doesNotThrow(
|
||||||
|
() => JSON.parse(line),
|
||||||
|
`stdout line #${i} is not valid JSON — the MCP protocol stream is corrupted by a stray write: ${line.slice(0, 200)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadConfig() — no key material configured', () => {
|
||||||
|
test('throws a clear, specific error naming what is missing, not a vague one', () => {
|
||||||
|
// No VECTOR_SIGNER_MNEMONIC / _MNEMONIC_FILE / _PRIVATE_KEY+_ADDRESS at
|
||||||
|
// all — the "nobody configured a key yet" case a fresh deployment or a
|
||||||
|
// misconfigured launcher would actually hit.
|
||||||
|
const bareEnv = {} as NodeJS.ProcessEnv;
|
||||||
|
assert.throws(() => loadConfig(bareEnv), /No key material configured/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the built server exits promptly and loudly on stderr rather than hanging or crashing unhelpfully', async () => {
|
||||||
|
const strippedEnv: NodeJS.ProcessEnv = { ...process.env };
|
||||||
|
delete strippedEnv.VECTOR_SIGNER_MNEMONIC;
|
||||||
|
delete strippedEnv.VECTOR_SIGNER_MNEMONIC_FILE;
|
||||||
|
delete strippedEnv.VECTOR_SIGNER_PRIVATE_KEY;
|
||||||
|
delete strippedEnv.VECTOR_SIGNER_ADDRESS;
|
||||||
|
|
||||||
|
const child: ChildProcess = spawn('node', ['packages/signer/build/index.js'], {
|
||||||
|
env: strippedEnv,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout!.on('data', (c: Buffer) => {
|
||||||
|
stdout += c.toString('utf8');
|
||||||
|
});
|
||||||
|
child.stderr!.on('data', (c: Buffer) => {
|
||||||
|
stderr += c.toString('utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await new Promise<number | null>((resolvePromise, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error(`process did not exit within 5s — looks like a hang. stderr so far: ${stderr}`)),
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolvePromise(code);
|
||||||
|
});
|
||||||
|
child.on('error', (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.notEqual(exitCode, 0, 'must exit non-zero when it cannot start at all');
|
||||||
|
assert.match(stderr, /No key material configured/, 'the real, specific reason must reach stderr');
|
||||||
|
assert.equal(stdout, '', 'nothing MCP-protocol-shaped should ever reach stdout on a startup failure');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('key material is never emitted', () => {
|
||||||
|
test('the startup banner (stderr) never contains a mnemonic word or the derived private key', async () => {
|
||||||
|
const server = await spawnSigner();
|
||||||
|
try {
|
||||||
|
const banner = server.stderrChunks.join('');
|
||||||
|
assert.ok(banner.length > 0, 'sanity: something was actually captured on stderr');
|
||||||
|
assert.match(banner, /ready/i);
|
||||||
|
for (const word of TEST_MNEMONIC.split(' ')) {
|
||||||
|
assert.ok(!banner.includes(word), `startup banner must not contain mnemonic word "${word}": ${banner}`);
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
!banner.includes(TEST_IDENTITY.privateKeyBech32),
|
||||||
|
'startup banner must not contain the derived private key'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no tool response across a sweep of calls (including a real successful sign) contains a mnemonic word or the derived private key', async () => {
|
||||||
|
const server = await spawnSigner();
|
||||||
|
try {
|
||||||
|
const inPolicyTx = buildInPolicyTxCbor(TEST_IDENTITY.address);
|
||||||
|
|
||||||
|
const responses: string[] = [];
|
||||||
|
responses.push(await callToolText(server.client, 'vector_signer_get_address'));
|
||||||
|
responses.push(await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR }));
|
||||||
|
responses.push(
|
||||||
|
await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: inPolicyTx })
|
||||||
|
);
|
||||||
|
responses.push(await callToolText(server.client, 'vector_signer_sign', { txCbor: FIXTURE_CBOR })); // refused
|
||||||
|
responses.push(await callToolText(server.client, 'vector_signer_sign', { txCbor: 'not-hex' })); // malformed
|
||||||
|
responses.push(await callToolText(server.client, 'vector_signer_sign', { txCbor: inPolicyTx })); // actually signed
|
||||||
|
responses.push(await callToolText(server.client, 'vector_signer_get_spend_limits'));
|
||||||
|
|
||||||
|
// Sanity: the sweep must actually exercise the real success path, or
|
||||||
|
// this test would vacuously pass by never looking at a response that
|
||||||
|
// could plausibly contain key material in the first place.
|
||||||
|
assert.ok(responses.some((r) => /Signed CBOR/i.test(r)), 'sanity: the sweep must include a real signed response');
|
||||||
|
|
||||||
|
for (const [i, resp] of responses.entries()) {
|
||||||
|
for (const word of TEST_MNEMONIC.split(' ')) {
|
||||||
|
assert.ok(!resp.includes(word), `response #${i} contains mnemonic word "${word}": ${resp.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
assert.ok(!resp.includes(TEST_IDENTITY.privateKeyBech32), `response #${i} contains the raw private key`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decode_transaction is strictly read-only', () => {
|
||||||
|
test('never appears in get_spend_limits recent decisions and never changes committed-today, even for an in-policy transaction', async () => {
|
||||||
|
const server = await spawnSigner();
|
||||||
|
try {
|
||||||
|
const before = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||||
|
assert.match(before, /No decisions recorded yet\./, 'sanity: starts with an empty audit log');
|
||||||
|
|
||||||
|
await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR });
|
||||||
|
await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR });
|
||||||
|
await callToolText(server.client, 'vector_signer_decode_transaction', {
|
||||||
|
txCbor: buildInPolicyTxCbor(TEST_IDENTITY.address),
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||||
|
assert.match(
|
||||||
|
after,
|
||||||
|
/No decisions recorded yet\./,
|
||||||
|
'decode_transaction must never write an audit entry, even when previewing a transaction that would be ALLOWED'
|
||||||
|
);
|
||||||
|
assert.match(after, /Committed today:\s*0\.000000 AP3X/);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review finding (Task 7 follow-up): the audit-log check above does NOT
|
||||||
|
// catch a decode_transaction that has been made to genuinely sign a
|
||||||
|
// transaction whenever policy would have allowed it, worded to avoid the
|
||||||
|
// substring "sign" (e.g. "Witnessed bytes:") - that mutation writes no
|
||||||
|
// audit entry at all, so "No decisions recorded yet." stays true right up
|
||||||
|
// until real signed bytes are already sitting in the response text. This
|
||||||
|
// test targets that different failure mode directly: it does not depend on
|
||||||
|
// wording, and it does not depend on FIXTURE_CBOR happening to be refused
|
||||||
|
// (the mutation is only reachable when a transaction is genuinely allowed).
|
||||||
|
//
|
||||||
|
// registerReadOnlyTools() in tools.ts (see its own comment) now makes the
|
||||||
|
// underlying mutation impossible to write as a one-line addition in the
|
||||||
|
// first place - decode_transaction's handler has no `identity` or
|
||||||
|
// `keySource` in scope at all, structurally, not by convention. This test
|
||||||
|
// is the defense-in-depth layer underneath that structural guard: it pins
|
||||||
|
// the CURRENT behaviour directly against what real signing would actually
|
||||||
|
// produce, so it would still catch a future regression that re-threads
|
||||||
|
// signing capability into that scope some other way.
|
||||||
|
test('the response never contains the transaction\'s actual signed bytes, computed independently, for a transaction that would be ALLOWED', async () => {
|
||||||
|
const server = await spawnSigner();
|
||||||
|
try {
|
||||||
|
const tx = buildInPolicyTxCbor(TEST_IDENTITY.address);
|
||||||
|
// Computed independently, in the test, via the same signTransaction()
|
||||||
|
// the real vector_signer_sign tool uses - this is exactly what a
|
||||||
|
// "helpfully pre-computed" decode_transaction would produce and return,
|
||||||
|
// whatever label it gave those bytes.
|
||||||
|
const { signedCborHex } = signTransaction(tx, TEST_IDENTITY.privateKeyBech32);
|
||||||
|
|
||||||
|
const result = await callToolText(server.client, 'vector_signer_decode_transaction', { txCbor: tx });
|
||||||
|
|
||||||
|
assert.match(result, /would be ALLOWED/i, 'sanity: this fixture must actually reach the allowed branch decode_transaction previews, or the check below is vacuous');
|
||||||
|
assert.ok(
|
||||||
|
!result.includes(signedCborHex),
|
||||||
|
'decode_transaction must never return the transaction\'s actual signed bytes, under any wording or label'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a decode failure inside sign() is refused before any audit entry is even attempted', () => {
|
||||||
|
test('malformed CBOR does not appear in recent decisions and does not change committed-today', async () => {
|
||||||
|
const server = await spawnSigner();
|
||||||
|
try {
|
||||||
|
await callToolText(server.client, 'vector_signer_sign', { txCbor: 'not-hex' });
|
||||||
|
const after = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||||
|
assert.match(
|
||||||
|
after,
|
||||||
|
/No decisions recorded yet\./,
|
||||||
|
'undecodable input never reaches evaluate(), so there is no PolicyDecision to audit yet — by design, not a gap'
|
||||||
|
);
|
||||||
|
assert.match(after, /Committed today:\s*0\.000000 AP3X/);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_spend_limits surfaces the concrete refusal reason, not just the word REFUSED', () => {
|
||||||
|
test('the per-transaction-limit reason text appears in recent decisions', async () => {
|
||||||
|
const server = await spawnSigner();
|
||||||
|
try {
|
||||||
|
await callToolText(server.client, 'vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||||
|
const limits = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||||
|
assert.match(limits, /REFUSED/);
|
||||||
|
assert.match(
|
||||||
|
limits,
|
||||||
|
/per-transaction limit/i,
|
||||||
|
'the recent-decisions line should carry the actual policy reason, not just the decision label'
|
||||||
|
);
|
||||||
|
assert.match(limits, /Committed today:\s*0\.000000 AP3X/);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('THE hard requirement: an audit append failure on the SIGNED path withholds the signature', () => {
|
||||||
|
// audit.ts's own CONTRACT note (top of the file) is explicit: append()
|
||||||
|
// throws if the durable write fails, and a caller that catches the throw
|
||||||
|
// and still returns the signature it already produced reproduces exactly
|
||||||
|
// the failure the throw exists to prevent — a real signature reaching a
|
||||||
|
// caller with no durable record of it, so a restarted process's
|
||||||
|
// committedTodayLovelace() silently under-counts and the daily limit
|
||||||
|
// stops binding. This describe block is the test that proves tools.ts
|
||||||
|
// does not fall into that trap.
|
||||||
|
|
||||||
|
test('control: the SAME in-policy transaction against a HEALTHY audit log genuinely signs (proves the fixture below is not simply broken)', async () => {
|
||||||
|
const server = await spawnSigner();
|
||||||
|
try {
|
||||||
|
const tx = buildInPolicyTxCbor(TEST_IDENTITY.address);
|
||||||
|
const result = await callToolText(server.client, 'vector_signer_sign', { txCbor: tx });
|
||||||
|
assert.match(result, /^# Signed/);
|
||||||
|
assert.match(result, /Signed CBOR/i);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unwritable audit log withholds a genuinely-produced signature: no signed CBOR anywhere in the response', async () => {
|
||||||
|
const server = await spawnSigner({
|
||||||
|
// Mirrors audit.test.ts's own proven approach: a parent directory that
|
||||||
|
// does not exist makes writeFileSync fail with ENOENT, deterministically
|
||||||
|
// and cross-platform, without needing OS-specific permission tricks.
|
||||||
|
auditLogPath: (dir) => join(dir, 'no-such-subdirectory', 'audit.json'),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const tx = buildInPolicyTxCbor(TEST_IDENTITY.address);
|
||||||
|
const result = await callToolText(server.client, 'vector_signer_sign', { txCbor: tx });
|
||||||
|
|
||||||
|
assert.ok(!/Signed CBOR/i.test(result), 'a signature must never be returned when its audit record failed to write');
|
||||||
|
assert.ok(!/```/.test(result), 'no fenced code block — where signed CBOR would appear — should be present at all');
|
||||||
|
assert.match(result, /REFUSED/, 'the caller must get an unambiguous, non-signed response');
|
||||||
|
assert.match(result, /produced/i, 'the response should be honest that a real signature WAS produced');
|
||||||
|
assert.match(
|
||||||
|
result,
|
||||||
|
/(withheld|not been released)/i,
|
||||||
|
'the response should say the signature is being withheld, not silently dropped'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after a withheld signature, this process still counts it toward committed-today (conservative over-count, never an under-count)', async () => {
|
||||||
|
// AuditLog.append() pushes the entry into its own in-memory array BEFORE
|
||||||
|
// attempting the write (audit.ts's own comment explains why: so THIS
|
||||||
|
// process's accounting does not under-count even when the write fails).
|
||||||
|
// tools.ts has no way to "unpush" that entry, and should not try to -
|
||||||
|
// over-counting a withheld signature against this process's own
|
||||||
|
// remaining daily budget is the safe direction, matching every other
|
||||||
|
// fail-closed choice in this package. Pinned here as a deliberate,
|
||||||
|
// understood consequence rather than an accidental surprise.
|
||||||
|
const server = await spawnSigner({
|
||||||
|
auditLogPath: (dir) => join(dir, 'no-such-subdirectory', 'audit.json'),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const tx = buildInPolicyTxCbor(TEST_IDENTITY.address, 200_000n, 5_000_000n);
|
||||||
|
await callToolText(server.client, 'vector_signer_sign', { txCbor: tx });
|
||||||
|
|
||||||
|
const limits = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||||
|
assert.match(
|
||||||
|
limits,
|
||||||
|
/Committed today:\s*0\.200000 AP3X/,
|
||||||
|
'the fee from the withheld signature is still counted in this process memory, even though it was never released'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a lower-stakes decision: an audit append failure on the REFUSED path', () => {
|
||||||
|
// Funds do not move on a refusal either way, so this is lower stakes than
|
||||||
|
// the signed case above — but a refusal whose own audit record silently
|
||||||
|
// failed to write must not look like a normal, cleanly-recorded refusal to
|
||||||
|
// the caller. Decision made here: still refuse (obviously — nothing about
|
||||||
|
// an audit failure should ever cause a REFUSED decision to become
|
||||||
|
// ALLOWED), still deliver the concrete policy reason, and additionally
|
||||||
|
// disclose that the audit trail for this specific refusal is incomplete.
|
||||||
|
|
||||||
|
test('still refuses with the concrete reason, and additionally flags that the refusal itself could not be recorded', async () => {
|
||||||
|
const server = await spawnSigner({
|
||||||
|
auditLogPath: (dir) => join(dir, 'no-such-subdirectory', 'audit.json'),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// FIXTURE_CBOR is refused for TEST_MNEMONIC unconditionally (~1000 AP3X
|
||||||
|
// net outflow against a 100 AP3X per-transaction limit — see the note
|
||||||
|
// above the brief's own "sign refuses..." test). No funds are at risk
|
||||||
|
// either way; this test is about whether the CALLER is told the truth
|
||||||
|
// about the state of the audit trail.
|
||||||
|
const result = await callToolText(server.client, 'vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||||
|
|
||||||
|
assert.match(result, /REFUSED/);
|
||||||
|
assert.match(
|
||||||
|
result,
|
||||||
|
/per-transaction limit/i,
|
||||||
|
'the concrete policy reason must still reach the caller even when the audit write fails'
|
||||||
|
);
|
||||||
|
assert.ok(!/Signed CBOR/i.test(result));
|
||||||
|
assert.match(result, /audit log/i);
|
||||||
|
assert.match(result, /(could not|NOT).*record/i, 'must disclose that this specific refusal was not durably recorded');
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('committed-today is unaffected by a refusal whether or not the audit write succeeds', async () => {
|
||||||
|
const server = await spawnSigner({
|
||||||
|
auditLogPath: (dir) => join(dir, 'no-such-subdirectory', 'audit.json'),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await callToolText(server.client, 'vector_signer_sign', { txCbor: FIXTURE_CBOR });
|
||||||
|
const limits = await callToolText(server.client, 'vector_signer_get_spend_limits');
|
||||||
|
assert.match(limits, /Committed today:\s*0\.000000 AP3X/);
|
||||||
|
} finally {
|
||||||
|
await stopSigner(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
18
packages/signer/tsconfig.json
Normal file
18
packages/signer/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
// Tests import source with an explicit .ts extension (node --import tsx
|
||||||
|
// resolves it at runtime). Safe here because noEmit is set, and it lets the
|
||||||
|
// tests be typechecked rather than excluded from the gate.
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "test/**/*"]
|
||||||
|
}
|
||||||
20
packages/signer/tsup.config.ts
Normal file
20
packages/signer/tsup.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
// es2022, not es2020 (Task 1's original value): index.ts uses a top-level
|
||||||
|
// `await server.connect(...)` (brief's own given code, Task 7), and
|
||||||
|
// top-level await is an ES2022 language feature — esbuild hard-errors
|
||||||
|
// ("Top-level await is not available in the configured target environment")
|
||||||
|
// rather than attempting an unsafe downlevel transform, unlike most other
|
||||||
|
// modern syntax. Node 22 (this package's engines.node floor) natively
|
||||||
|
// supports it. Scoped to the signer package only; the builder's own
|
||||||
|
// tsup.config.ts is untouched because vector.ts/index.ts never uses
|
||||||
|
// top-level await and has no need for the bump.
|
||||||
|
target: "es2022",
|
||||||
|
outDir: "build",
|
||||||
|
clean: true,
|
||||||
|
splitting: false,
|
||||||
|
banner: { js: "#!/usr/bin/env node" },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user