From 65a3c00994cd8b3eba3a7dfe24d4042f44e6583f Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:23:12 +0200 Subject: [PATCH] feat(signer): spend policy over true net outflow Net outflow is every output that does not return to us, plus the fee - more accurate than summing declared amounts, and computable without chain access. Refuses when no own address is known, since change cannot then be identified. --- packages/signer/src/policy.ts | 92 ++++++++++++++++++++ packages/signer/test/policy.test.ts | 128 ++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 packages/signer/src/policy.ts create mode 100644 packages/signer/test/policy.test.ts diff --git a/packages/signer/src/policy.ts b/packages/signer/src/policy.ts new file mode 100644 index 0000000..837e8f1 --- /dev/null +++ b/packages/signer/src/policy.ts @@ -0,0 +1,92 @@ +// 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); +} + +/** + * 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(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 }; +} + +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. + if (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 }; +} diff --git a/packages/signer/test/policy.test.ts b/packages/signer/test/policy.test.ts new file mode 100644 index 0000000..16caa0d --- /dev/null +++ b/packages/signer/test/policy.test.ts @@ -0,0 +1,128 @@ +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 LIMITS: SpendLimits = { perTxLovelace: 100_000_000n, dailyLovelace: 500_000_000n }; + +function tx(partial: Partial): 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); + }); +});