knownAddresses validated the trimmed value but stored the original, so an own address configured with an incidental leading or trailing space passed the fail-closed gate yet could never match its own outputs - every change output was then counted as outflow and normal transactions were refused. Also pins prefix-match, trim-before-check, and case-sensitivity, none of which any test previously required.
548 lines
24 KiB
TypeScript
548 lines
24 KiB
TypeScript
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 FOREIGN2 = 'addr1foreign_second';
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// --- Adversarial pass (beyond the brief) -----------------------------------
|
|
//
|
|
// The brief's 13 tests are a floor. Everything below was added after they
|
|
// passed, specifically hunting for the failure shape this module is uniquely
|
|
// exposed to: a wrong comparison or a missed exclusion that does not throw,
|
|
// it just quietly returns `allowed: true` when it should not.
|
|
|
|
describe('evaluate — ownAddresses containing only blank entries', () => {
|
|
// A realistic real-world source of this: `''.split(',')` in JS yields `['']`,
|
|
// not `[]`. If whatever populates ownAddresses from config/env ever hits that
|
|
// empty-string case, the array is non-empty but contains no usable address.
|
|
// `ownAddresses.length === 0` alone does not catch this: the array has
|
|
// length 1. Every real output then fails to match the blank entry, so it is
|
|
// over-counted as outflow rather than silently permitted — the safe
|
|
// direction — but the explicit, intentional fail-closed path (and its
|
|
// message) is bypassed, and if the (over-counted) outflow happens to still
|
|
// land under the configured limits, the transaction is signed even though
|
|
// no real own address was ever known. That is the same condition the
|
|
// empty-array check exists to refuse; it should refuse here too.
|
|
test('a single blank entry is treated as no known own address (fails closed)', () => {
|
|
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('a blank entry alongside a real own address does not weaken matching', () => {
|
|
// Regression guard for the fix above: filtering blanks must not touch a
|
|
// list that also contains a genuine address.
|
|
const r = computeOutflow(
|
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
|
['', OWN]
|
|
);
|
|
assert.equal(r.netOutflowLovelace, 200_000n, 'OWN must still be recognised as change');
|
|
});
|
|
});
|
|
|
|
describe('evaluate — ownAddresses entries built from invisible Unicode characters', () => {
|
|
// `.trim()` alone is not enough to detect "not a real address": it strips
|
|
// the ECMAScript WhiteSpace set (which includes NBSP U+00A0 and BOM
|
|
// U+FEFF) but not every invisible character. U+200B ZERO WIDTH SPACE and
|
|
// U+2060 WORD JOINER are Unicode category Cf (format), not Zs (space
|
|
// separator) or part of ECMAScript's WhiteSpace production, so trimming
|
|
// either one leaves a non-empty string — a trim-then-check-length filter
|
|
// alone would let either slip past exactly the way '' used to. (U+00A0 and
|
|
// U+FEFF, by contrast, ARE stripped by .trim() and were never actually a
|
|
// gap here — verified directly before writing these tests: only U+200B and
|
|
// U+2060 reproduce it.) A denylist of "invisible" codepoints is not the
|
|
// fix: there are dozens of Cf/Cc codepoints and more get added, so this is
|
|
// a positive check instead — an entry only counts as a known own address
|
|
// if, after trimming, it starts with the 'addr' prefix every
|
|
// Cardano/Vector payment address actually uses. Nothing built purely from
|
|
// invisible characters can satisfy that.
|
|
// Built via fromCharCode rather than a literal escape so the codepoint is
|
|
// unambiguous in source and cannot be misrendered by an editor or diff tool.
|
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
|
const WORD_JOINER = String.fromCharCode(0x2060);
|
|
|
|
test('a single ZERO WIDTH SPACE (U+200B) entry is treated as no known own address', () => {
|
|
const d = evaluate(
|
|
tx({ outputs: [{ address: FOREIGN, lovelace: 5_000_000n, assets: [] }] }),
|
|
[ZERO_WIDTH_SPACE], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /own address/i);
|
|
});
|
|
|
|
test('a single WORD JOINER (U+2060) entry is treated as no known own address', () => {
|
|
const d = evaluate(
|
|
tx({ outputs: [{ address: FOREIGN, lovelace: 5_000_000n, assets: [] }] }),
|
|
[WORD_JOINER], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /own address/i);
|
|
});
|
|
|
|
test('a genuine addr1... address is still accepted (the positive check does not over-reject)', () => {
|
|
const r = computeOutflow(
|
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
|
[OWN]
|
|
);
|
|
assert.equal(r.netOutflowLovelace, 200_000n, 'a real own address must still be recognised as change');
|
|
});
|
|
});
|
|
|
|
describe('evaluate — knownAddresses must store the trimmed value, not just check it', () => {
|
|
// Real bug, found reviewing the invisible-character fix, present since that
|
|
// fix's very first version: the filter checked `address.trim().startsWith(...)`
|
|
// but Array.prototype.filter always returns the ORIGINAL element on a match,
|
|
// never a transformed one. So an entry that only qualified after trimming
|
|
// was kept *with its padding intact*, then compared by exact string
|
|
// equality against real output addresses (always clean, never padded) —
|
|
// which can never match. The address passes the fail-closed gate (it
|
|
// "looks like" a known address) but is then functionally invisible to
|
|
// computeOutflow's classification, so every one of its own change outputs
|
|
// gets counted as outflow instead of being excluded. Since change is
|
|
// usually the dominant value in a UTxO transaction, a single incidental
|
|
// leading or trailing space on a configured own address would refuse
|
|
// nearly every real transaction at a wildly inflated outflow figure.
|
|
test('a leading space on a real own address no longer hides its change output (regression test for the fix)', () => {
|
|
const d = evaluate(
|
|
tx({ outputs: [
|
|
{ address: FOREIGN, lovelace: 3_000_000n, assets: [] },
|
|
{ address: OWN, lovelace: 996_000_000n, assets: [] },
|
|
] }),
|
|
[' ' + OWN], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, true);
|
|
assert.equal(d.netOutflowLovelace, 3_200_000n, 'only the genuine foreign spend + fee, not the misclassified change');
|
|
});
|
|
|
|
test('a trailing space on a real own address no longer hides its change output', () => {
|
|
const d = evaluate(
|
|
tx({ outputs: [
|
|
{ address: FOREIGN, lovelace: 3_000_000n, assets: [] },
|
|
{ address: OWN, lovelace: 996_000_000n, assets: [] },
|
|
] }),
|
|
[OWN + ' '], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, true);
|
|
assert.equal(d.netOutflowLovelace, 3_200_000n);
|
|
});
|
|
|
|
test('residual: a trailing invisible character still hides the change output, since .trim() does not strip it — same safe direction', () => {
|
|
// Documented, deliberate residual (see the comment on knownAddresses in
|
|
// policy.ts): fixing this specific case would mean reaching for the
|
|
// denylist-of-invisible-codepoints approach the positive check exists to
|
|
// avoid. Pinned here so it stays a known, bounded gap rather than an
|
|
// implicit assumption — and because it fails in the safe direction
|
|
// (refuses a legitimate transaction) rather than the dangerous one.
|
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
|
const d = evaluate(
|
|
tx({ outputs: [
|
|
{ address: FOREIGN, lovelace: 3_000_000n, assets: [] },
|
|
{ address: OWN, lovelace: 996_000_000n, assets: [] },
|
|
] }),
|
|
[OWN + ZERO_WIDTH_SPACE], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, false);
|
|
assert.equal(d.netOutflowLovelace, 999_200_000n, 'the change is still misclassified as outflow — known, bounded, safe-direction residual');
|
|
});
|
|
|
|
test('an entry merely containing "addr" without starting with it is not a known own address (prefix, not substring)', () => {
|
|
const d = evaluate(
|
|
tx({ outputs: [{ address: FOREIGN, lovelace: 5_000_000n, assets: [] }] }),
|
|
['xxxaddrxxx'], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /own address/i);
|
|
});
|
|
|
|
test('a case-variant entry does not match a real own address (pins the existing case-sensitivity decision)', () => {
|
|
const CASE_VARIANT = 'ADDR1own'; // OWN is 'addr1own'
|
|
const d = evaluate(
|
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
|
[CASE_VARIANT], LIMITS, 0n
|
|
);
|
|
// knownAddresses([CASE_VARIANT]) is empty (case-sensitive prefix check
|
|
// fails), so this hits the fail-closed "no own address" path rather than
|
|
// the over-counting path — either way, OWN must not be recognised as
|
|
// change, which is the property this test pins.
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /own address/i);
|
|
});
|
|
});
|
|
|
|
describe('computeOutflow — the filter applies at the Set-construction site itself, not only in evaluate()', () => {
|
|
// evaluate()'s fail-closed guard and computeOutflow()'s own Set both call
|
|
// knownAddresses(), but nothing above exercises them independently: every
|
|
// prior blank/invisible-entry test either goes through evaluate() (whose
|
|
// guard would refuse before computeOutflow's classification could matter)
|
|
// or pairs a blank ownAddresses entry with a *real* output address (which
|
|
// would pass either way, filtered or not). That leaves a real gap: if a
|
|
// future edit filtered ownAddresses in evaluate()'s guard but left
|
|
// computeOutflow's `new Set(ownAddresses)` unfiltered, nothing here would
|
|
// catch it, because a blank entry in an otherwise-unfiltered Set only
|
|
// matters when something can actually match it — i.e. when an output
|
|
// address is *also* blank. decode.ts can never produce that: every output
|
|
// address comes from `o.address().to_bech32()`, which throws before an
|
|
// empty or blank address could ever reach a DecodedOutput (see decode.ts's
|
|
// per-output try/catch). So this is unreachable through the real pipeline
|
|
// — but computeOutflow is an exported pure function with its own contract,
|
|
// and that contract must hold regardless of what the current pipeline
|
|
// happens to be able to construct.
|
|
test('a blank output address is counted as outflow, not matched as change via a blank ownAddresses entry', () => {
|
|
const r = computeOutflow(
|
|
tx({ outputs: [{ address: '', lovelace: 5_000_000n, assets: [] }] }),
|
|
['']
|
|
);
|
|
assert.equal(
|
|
r.netOutflowLovelace,
|
|
5_200_000n,
|
|
'a blank output address must never be classified as change, even against a blank ownAddresses entry'
|
|
);
|
|
});
|
|
|
|
test('an output address built from an invisible character is counted as outflow, not matched as change', () => {
|
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
|
const r = computeOutflow(
|
|
tx({ outputs: [{ address: ZERO_WIDTH_SPACE, lovelace: 5_000_000n, assets: [] }] }),
|
|
[ZERO_WIDTH_SPACE]
|
|
);
|
|
assert.equal(r.netOutflowLovelace, 5_200_000n);
|
|
});
|
|
});
|
|
|
|
describe('evaluate — exact limit boundaries, one lovelace either side', () => {
|
|
test('one lovelace under the per-transaction limit is allowed', () => {
|
|
const d = evaluate(
|
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 99_999_999n, assets: [] }] }),
|
|
[OWN], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, true);
|
|
});
|
|
|
|
test('one lovelace over the per-transaction limit is refused', () => {
|
|
const d = evaluate(
|
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 100_000_001n, assets: [] }] }),
|
|
[OWN], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /per-transaction limit/i);
|
|
});
|
|
|
|
test('a daily total landing exactly on the limit is allowed', () => {
|
|
const d = evaluate(
|
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 50_000_000n, assets: [] }] }),
|
|
[OWN], LIMITS, 450_000_000n // 450M + 50M == 500M dailyLovelace, exactly
|
|
);
|
|
assert.equal(d.allowed, true);
|
|
});
|
|
|
|
test('one lovelace over the daily limit is refused', () => {
|
|
const d = evaluate(
|
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 50_000_000n, assets: [] }] }),
|
|
[OWN], LIMITS, 450_000_001n // sum == dailyLovelace + 1
|
|
);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /daily/i);
|
|
});
|
|
|
|
test('one lovelace under the daily limit is allowed', () => {
|
|
const d = evaluate(
|
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 50_000_000n, assets: [] }] }),
|
|
[OWN], LIMITS, 449_999_999n // sum == dailyLovelace - 1
|
|
);
|
|
assert.equal(d.allowed, true);
|
|
});
|
|
});
|
|
|
|
describe('evaluate — many small foreign outputs must sum, not spot-check', () => {
|
|
test('34 outputs of 3 AP3X each breach a 100 AP3X per-transaction limit on their sum', () => {
|
|
// No single output is anywhere near the limit (3_000_000n each); only the
|
|
// total is. A check that inspected outputs individually rather than
|
|
// summing would wrongly allow this.
|
|
const outputs = Array.from({ length: 34 }, () => ({ address: FOREIGN, lovelace: 3_000_000n, assets: [] }));
|
|
const d = evaluate(tx({ outputs }), [OWN], LIMITS, 0n);
|
|
assert.equal(d.netOutflowLovelace, 102_200_000n); // 34 * 3,000,000 + 200,000 fee
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /per-transaction limit/i);
|
|
});
|
|
});
|
|
|
|
describe('computeOutflow — aggregation correctness', () => {
|
|
test('sums repeated outputs to the same foreign address rather than deduplicating', () => {
|
|
const r = computeOutflow(
|
|
tx({ outputs: [
|
|
{ address: FOREIGN, lovelace: 1_000_000n, assets: [] },
|
|
{ address: FOREIGN, lovelace: 2_000_000n, assets: [] }, // same address again
|
|
{ address: OWN, lovelace: 50_000_000n, assets: [] }, // change, excluded
|
|
{ address: FOREIGN2, lovelace: 3_000_000n, assets: [] }, // a different foreign address
|
|
] }),
|
|
[OWN]
|
|
);
|
|
assert.equal(r.netOutflowLovelace, 6_200_000n); // 1M + 2M + 3M + 200K fee; 50M change excluded
|
|
});
|
|
|
|
test('duplicate entries in ownAddresses do not change classification', () => {
|
|
const r = computeOutflow(
|
|
tx({ outputs: [
|
|
{ address: OWN, lovelace: 5_000_000n, assets: [] },
|
|
{ address: OTHER_OWN, lovelace: 3_000_000n, assets: [] },
|
|
{ address: FOREIGN, lovelace: 2_000_000n, assets: [] },
|
|
] }),
|
|
[OWN, OWN, OTHER_OWN, OTHER_OWN]
|
|
);
|
|
assert.equal(r.netOutflowLovelace, 2_200_000n); // only the foreign output + fee
|
|
});
|
|
|
|
test('an own address supplied in a different case does not match — over-counts as outflow, the safe direction', () => {
|
|
// Bech32 addresses are canonically lowercase and decode.ts only ever emits
|
|
// that canonical form, so this should not arise in practice. Pinning it
|
|
// anyway: a mismatch here fails toward refusing too much, never toward
|
|
// missing real outflow, which is the direction that matters.
|
|
const r = computeOutflow(
|
|
tx({ outputs: [{ address: OWN, lovelace: 5_000_000n, assets: [] }] }),
|
|
['ADDR1OWN']
|
|
);
|
|
assert.equal(r.netOutflowLovelace, 5_200_000n, 'differently-cased own address must not be recognised as change');
|
|
});
|
|
|
|
test('records every distinct asset on a single foreign output, not just the first', () => {
|
|
const r = computeOutflow(
|
|
tx({ outputs: [{
|
|
address: FOREIGN,
|
|
lovelace: 2_000_000n,
|
|
assets: [
|
|
{ unit: 'policy1nft', quantity: 1n },
|
|
{ unit: 'policy2token', quantity: 500n },
|
|
],
|
|
}] }),
|
|
[OWN]
|
|
);
|
|
assert.deepStrictEqual(r.assetMovements, [
|
|
{ unit: 'policy1nft', quantity: 1n, toAddress: FOREIGN },
|
|
{ unit: 'policy2token', quantity: 500n, toAddress: FOREIGN },
|
|
]);
|
|
});
|
|
|
|
test('aggregates assets across multiple foreign outputs rather than keeping only the last', () => {
|
|
const r = computeOutflow(
|
|
tx({ outputs: [
|
|
{ address: FOREIGN, lovelace: 2_000_000n, assets: [{ unit: 'nftA', quantity: 1n }] },
|
|
{ address: FOREIGN2, lovelace: 3_000_000n, assets: [{ unit: 'nftB', quantity: 1n }] },
|
|
] }),
|
|
[OWN]
|
|
);
|
|
assert.deepStrictEqual(r.assetMovements, [
|
|
{ unit: 'nftA', quantity: 1n, toAddress: FOREIGN },
|
|
{ unit: 'nftB', quantity: 1n, toAddress: FOREIGN2 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('evaluate — check ordering is pinned, not incidental', () => {
|
|
test('a transaction breaching both limits at once reports the per-transaction reason first', () => {
|
|
const d = evaluate(
|
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 250_000_000n, assets: [] }] }),
|
|
[OWN], LIMITS, 400_000_000n // also breaches the daily limit (250M + 400M > 500M)
|
|
);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /per-transaction limit/i);
|
|
assert.doesNotMatch(d.reason!, /daily/i);
|
|
});
|
|
|
|
test('an empty ownAddresses list reports the own-address reason before any limit is even considered', () => {
|
|
const d = evaluate(
|
|
tx({ fee: 0n, outputs: [{ address: FOREIGN, lovelace: 250_000_000n, assets: [] }] }),
|
|
[], LIMITS, 400_000_000n // this transaction would also fail both limit checks
|
|
);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /own address/i);
|
|
});
|
|
});
|
|
|
|
describe('evaluate — zero limits and an already-over-budget day', () => {
|
|
test('zero limits still allow a genuinely zero-value transaction', () => {
|
|
const zeroLimits: SpendLimits = { perTxLovelace: 0n, dailyLovelace: 0n };
|
|
const d = evaluate(tx({ fee: 0n, outputs: [] }), [OWN], zeroLimits, 0n);
|
|
assert.equal(d.netOutflowLovelace, 0n);
|
|
assert.equal(d.allowed, true);
|
|
});
|
|
|
|
test('zero limits refuse even the smallest positive outflow', () => {
|
|
const zeroLimits: SpendLimits = { perTxLovelace: 0n, dailyLovelace: 0n };
|
|
const d = evaluate(tx({ fee: 1n, outputs: [] }), [OWN], zeroLimits, 0n);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /per-transaction limit/i);
|
|
});
|
|
|
|
test('an already over-budget day clamps the reported remaining budget to zero, not negative', () => {
|
|
// alreadySpentTodayLovelace (600M) already exceeds dailyLovelace (500M) —
|
|
// should not happen if the audit log is accounted correctly, but the
|
|
// module must still behave sanely (and keep refusing) if it ever does.
|
|
const d = evaluate(tx({ outputs: [] }), [OWN], LIMITS, 600_000_000n);
|
|
assert.equal(d.allowed, false);
|
|
assert.match(d.reason!, /daily/i);
|
|
assert.match(d.reason!, /remaining daily budget of 0\.000000 AP3X/);
|
|
});
|
|
});
|
|
|
|
describe('policy — known gap: SpendLimits governs lovelace only, not native asset value', () => {
|
|
test('a transaction moving a native asset to a foreign address is allowed if lovelace stays under both limits', () => {
|
|
// computeOutflow tracks assetMovements, but evaluate() only compares
|
|
// netOutflowLovelace against limits — it never inspects assetMovements.
|
|
// A transaction that sends an NFT or a large token balance out while
|
|
// keeping the lovelace side minimal (e.g. just the ~2 AP3X min-UTxO
|
|
// deposit) passes both limit checks untouched. SpendLimits has no
|
|
// asset-value dimension to check against, so this is not a coding
|
|
// mistake relative to the given interface — it is a scope boundary of
|
|
// this policy, pinned here so it is visible rather than silently assumed.
|
|
const d = evaluate(
|
|
tx({ outputs: [{ address: FOREIGN, lovelace: 2_000_000n, assets: [{ unit: 'preciousNFT', quantity: 1n }] }] }),
|
|
[OWN], LIMITS, 0n
|
|
);
|
|
assert.equal(d.allowed, true);
|
|
assert.deepStrictEqual(d.assetMovements, [{ unit: 'preciousNFT', quantity: 1n, toAddress: FOREIGN }]);
|
|
});
|
|
});
|
|
|
|
describe('evaluate — bigint precision at magnitudes beyond float safety', () => {
|
|
test('the bigint outflow figure stays exact past Number.MAX_SAFE_INTEGER, even though the human-readable message may round', () => {
|
|
// ada() formats via Number() for the reason string only; the allow/deny
|
|
// decision itself is decided entirely in bigint arithmetic and must never
|
|
// lose precision, no matter how large the values get. Not reachable at
|
|
// this system's real scale (limits and balances run to hundreds of AP3X,
|
|
// far below 2^53 lovelace) — tested anyway because the decision-critical
|
|
// field must hold regardless of realistic bounds.
|
|
const HUGE_FEE = 9_007_199_254_740_993n; // Number.MAX_SAFE_INTEGER + 2
|
|
const zeroLimits: SpendLimits = { perTxLovelace: 0n, dailyLovelace: 0n };
|
|
const d = evaluate(tx({ fee: HUGE_FEE, outputs: [] }), [OWN], zeroLimits, 0n);
|
|
assert.equal(d.allowed, false);
|
|
assert.equal(d.netOutflowLovelace, HUGE_FEE, 'the bigint decision figure must never lose precision');
|
|
});
|
|
});
|