Files
apex-mcp-server/packages/signer/test/integration/roundtrip.test.ts
FilipBl4gojevic b4038aa1f1 docs(signer): document configuration, semantics, and known limitations
Includes an opt-in testnet round-trip test (keyless build -> decode -> policy
-> sign, never submits), excluded from CI because it needs a funded wallet.
Run against the real funded testnet wallet: pass 3, fail 0, nothing submitted.

Verified every README claim against the code as implemented, not just the
brief text, and corrected several:
- VECTOR_SIGNER_AUDIT_LOG_PATH's default resolves against the package's own
  install location (repo root), not the process's cwd as originally stated --
  and that default filename is not yet covered by .gitignore.
- An audit-log write failure blocks ALL signing, not just accounting:
  audit.append() throws by design and vector_signer_sign withholds an
  already-produced signature rather than release one with no durable record.
- Documented the #private-field defense against accidental key leakage via
  JSON.stringify/console.log, and the read-only/sign tool split that keeps
  the signing key out of scope for get_address/decode/get_spend_limits.
- Documented that pairing this signer with the CURRENT hosted builder does
  not yet remove the custody exposure it's designed to fix: no builder tool
  is keyless yet, and there is no submit_transaction tool to hand a produced
  signature to. That's the next stage of the migration, not shipped here.

Env-var cross-check (brief's Step 6, both directions) passes clean.
2026-07-28 17:44:36 +02:00

80 lines
3.7 KiB
TypeScript

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