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:
92
packages/signer/src/policy.ts
Normal file
92
packages/signer/src/policy.ts
Normal 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 };
|
||||
}
|
||||
128
packages/signer/test/policy.test.ts
Normal file
128
packages/signer/test/policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user