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.
80 lines
3.7 KiB
TypeScript
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');
|
|
});
|
|
});
|