Merge branch 'main' into feat/builder-auth-and-submit

This commit is contained in:
dcejtlin-apex
2026-07-29 10:11:31 +02:00
committed by GitHub
26 changed files with 4819 additions and 2 deletions

View File

@@ -45,6 +45,9 @@ jobs:
- name: Auth gating and rate-limit integration tests
run: npm run test:integration:auth
- name: Signer smoke test
run: npm run test:smoke:signer
env:
NODE_OPTIONS: --max-old-space-size=12288

1
.gitignore vendored
View File

@@ -38,6 +38,7 @@ mnemonic.txt
# Runtime state (contains transaction history)
vector-audit-log.json
vector-signer-audit-log.json
# Incremental typecheck cache (npm run typecheck uses --incremental)
*.tsbuildinfo

View File

@@ -32,6 +32,21 @@ claude mcp add --transport sse vector-mcp https://mcp.vector.mainnet.apexfusion.
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
- **Wallet management** - derive addresses from mnemonic, query balances and UTxOs
@@ -215,6 +230,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.
```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
```

20
package-lock.json generated
View File

@@ -42,6 +42,10 @@
"resolved": "packages/shared",
"link": true
},
"node_modules/@apexfusion/vector-mcp-signer": {
"resolved": "packages/signer",
"link": true
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.20",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.20.tgz",
@@ -7893,6 +7897,22 @@
"@noble/hashes": "^1.7.1",
"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": "*"
}
}
}
}

View File

@@ -7,14 +7,16 @@
"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",
"clean": "rm -rf packages/*/build",
"build": "npm run build --workspace=@apexfusion/vector-mcp-builder",
"typecheck": "tsc --noEmit -p packages/shared && tsc --noEmit -p packages/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 && tsc --noEmit -p packages/signer",
"start": "node packages/builder/build/index.js",
"test": "npm run test:unit",
"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: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:auth": "npm run build && node --import tsx --test packages/builder/test/integration/auth-gating.test.ts packages/builder/test/integration/rate-limit-gating.test.ts"
"test:integration:signer": "node --import tsx --test packages/signer/test/integration/*.test.ts"
},
"devDependencies": {
"@types/node": "^22.13.1",

205
packages/signer/README.md Normal file
View 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`.

View 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": "*"
}
}

View 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();
}
}

View 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;
}

View 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,
};
}

View 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());

View 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 };
}
}

View 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 };
}

View 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() };
}

View 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);
}

View 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);
});
});

View 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 ')}`);
});
});

View 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));
});
});

View 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);
});
});
});

View 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');
});
});

View 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/
);
});
});

View 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');
});
});

View 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));
}
});
});

View 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);
}
});
});

View 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/**/*"]
}

View 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" },
});