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.
This commit is contained in:
FilipBl4gojevic
2026-07-28 12:23:12 +02:00
parent 2a8623d22a
commit 65a3c00994
2 changed files with 220 additions and 0 deletions

View File

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

View File

@@ -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>): 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);
});
});