Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,34 @@ jobs:
fi
echo "No known malicious packages found."

backend:
# Issue #1378: single source of truth for stroop scale, rounding mode,
# display precision and pro-rata allocation strategy. `scripts/gen-money.ts`
# derives `contracts/money/src/policy.rs`,
# `backend/src/money/policy.generated.ts` and
# `frontend/lib/money/policy.generated.ts` from `money-policy.json`; this
# job fails the build the moment any of those three generated files would
# differ from what's committed, so the layers can never drift back apart.
money-policy:
needs: supply-chain-audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
cache-dependency-path: scripts/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: scripts
- name: Check generated money-policy artifacts are up to date
run: npx ts-node gen-money.ts --check
working-directory: scripts

backend:
needs: [supply-chain-audit, money-policy]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
Expand Down Expand Up @@ -182,7 +207,7 @@ jobs:
PGPASSWORD: pgpass

frontend:
needs: supply-chain-audit
needs: [supply-chain-audit, money-policy]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -276,10 +301,11 @@ jobs:
run: node scripts/check-env-docs.mjs

contracts:
needs: money-policy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache dependencies
Expand Down
84 changes: 84 additions & 0 deletions backend/migrations/1802000000000_money_stroops_integer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Issue #1378: unify decimal precision and rounding into one cross-layer
* money policy (stroops, PostgreSQL NUMERIC, display).
*
* Every column that stores a settlement amount holds raw on-chain stroops
* (integer, no fractional component — the on-chain `i128` amounts decoded by
* eventIndexer.ts are already whole stroops). Retyping to `NUMERIC(38,0)`
* with an explicit `CHECK (value = trunc(value))`:
*
* - documents that these columns are integer stroop counts, not scaled
* decimal currency (matching `contracts/money`'s `STROOP_SCALE` and
* `backend/src/money/decimal.ts`'s bigint-only arithmetic), and
* - makes it impossible for a future write path to silently store a
* fractional/scaled value (e.g. `NUMERIC(20,6)`, which would drop the
* 7th decimal place of a stroop amount) without failing loudly.
*
* `NUMERIC(38,0)` comfortably holds an `i128` (max ~1.7e38) at zero scale.
*
* `contract_events.amount` is the live money column written by
* eventIndexer.ts (as a raw stroop string) and read by defaultChecker.ts /
* the reconciliation helpers in `backend/src/money`. `loan_history` is a
* legacy/seed-only mirror table (see `backend/src/seed/index.ts`); it is
* retyped for the same integrity guarantee even though nothing in the
* request path currently reads it for settlement decisions.
*/

/** @type {import('node-pg-migrate').ColumnDefinitions | undefined} */
export const shorthands = undefined;

const MONEY_COLUMNS = [
{ table: 'contract_events', column: 'amount', constraint: 'contract_events_amount_is_stroops' },
{
table: 'loan_history',
column: 'principal_amount',
constraint: 'loan_history_principal_amount_is_stroops',
},
{
table: 'loan_history',
column: 'principal_paid',
constraint: 'loan_history_principal_paid_is_stroops',
},
{
table: 'loan_history',
column: 'interest_paid',
constraint: 'loan_history_interest_paid_is_stroops',
},
{
table: 'loan_history',
column: 'accrued_interest',
constraint: 'loan_history_accrued_interest_is_stroops',
},
];

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {void}
*/
export const up = (pgm) => {
for (const { table, column, constraint } of MONEY_COLUMNS) {
// Round any pre-existing fractional values down to whole stroops before
// the CHECK is added, so historical rows (if any ever slipped in with a
// fractional value) don't block the migration.
pgm.sql(
`UPDATE "${table}" SET "${column}" = trunc("${column}") WHERE "${column}" IS NOT NULL;`,
);

pgm.alterColumn(table, column, { type: 'numeric(38,0)' });

pgm.addConstraint(table, constraint, {
check: `"${column}" IS NULL OR "${column}" = trunc("${column}")`,
});
}
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {void}
*/
export const down = (pgm) => {
for (const { table, column, constraint } of [...MONEY_COLUMNS].reverse()) {
pgm.dropConstraint(table, constraint);
pgm.alterColumn(table, column, { type: 'numeric' });
}
};
140 changes: 140 additions & 0 deletions backend/src/__tests__/decimal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
RoundingMode,
roundDiv,
toStroops,
fromStroops,
splitProRata,
STROOP_SCALE,
MoneyError,
} from '../money/decimal.js';

describe('money/decimal roundDiv', () => {
// These fixtures are transcribed 1:1 from `contracts/money/src/lib.rs`'s
// `round_div_*` unit tests so the two implementations are verified against
// the exact same table, not just "similar" behavior.
it('floor', () => {
expect(roundDiv(7n, 2n, RoundingMode.Floor)).toBe(3n);
expect(roundDiv(-7n, 2n, RoundingMode.Floor)).toBe(-4n);
expect(roundDiv(6n, 2n, RoundingMode.Floor)).toBe(3n);
});

it('ceil', () => {
expect(roundDiv(7n, 2n, RoundingMode.Ceil)).toBe(4n);
expect(roundDiv(-7n, 2n, RoundingMode.Ceil)).toBe(-3n);
expect(roundDiv(6n, 2n, RoundingMode.Ceil)).toBe(3n);
});

it('half up', () => {
expect(roundDiv(5n, 2n, RoundingMode.HalfUp)).toBe(3n); // 2.5 -> 3
expect(roundDiv(-5n, 2n, RoundingMode.HalfUp)).toBe(-3n);
expect(roundDiv(7n, 2n, RoundingMode.HalfUp)).toBe(4n); // 3.5 -> 4
expect(roundDiv(1n, 4n, RoundingMode.HalfUp)).toBe(0n); // 0.25 -> 0
});

it("half even (banker's rounding)", () => {
expect(roundDiv(5n, 2n, RoundingMode.HalfEven)).toBe(2n); // 2.5 -> 2 (even)
expect(roundDiv(7n, 2n, RoundingMode.HalfEven)).toBe(4n); // 3.5 -> 4 (even)
expect(roundDiv(9n, 2n, RoundingMode.HalfEven)).toBe(4n); // 4.5 -> 4 (even)
expect(roundDiv(3n, 2n, RoundingMode.HalfEven)).toBe(2n); // 1.5 -> 2 (even)
expect(roundDiv(-5n, 2n, RoundingMode.HalfEven)).toBe(-2n);
});

it('throws on division by zero', () => {
expect(() => roundDiv(5n, 0n, RoundingMode.HalfEven)).toThrow(MoneyError);
});
});

describe('money/decimal toStroops / fromStroops round trip', () => {
it('converts whole and fractional amounts at full stroop precision', () => {
expect(toStroops('1')).toBe(10_000_000n);
expect(toStroops('0.0000001')).toBe(1n);
expect(toStroops('12.5')).toBe(125_000_000n);
expect(toStroops('-3.1400000')).toBe(-31_400_000n);
});

it('fromStroops is the exact inverse of toStroops at settlement precision', () => {
const cases = ['0', '1', '0.0000001', '12.5000000', '9999999.9999999', '-42.4200000'];
for (const c of cases) {
const stroops = toStroops(c);
expect(toStroops(fromStroops(stroops))).toBe(stroops);
}
});

it('rounds excess precision using the configured mode rather than truncating', () => {
// 0.00000015 has 8 fractional digits (one more than STROOP_DECIMALS);
// half-even on the last digit rounds 1.5 -> 2.
expect(toStroops('0.00000015', RoundingMode.HalfEven)).toBe(2n);
expect(toStroops('0.00000025', RoundingMode.HalfEven)).toBe(2n);
});

it('rejects malformed input', () => {
expect(() => toStroops('abc')).toThrow(MoneyError);
expect(() => toStroops('')).toThrow(MoneyError);
});
});

describe('money/decimal splitProRata', () => {
it('sums exactly to the total for representative cases', () => {
const cases: Array<[bigint, bigint[]]> = [
[100n, [1n, 1n, 1n]],
[101n, [1n, 1n, 1n]],
[1_000_000_007n, [3n, 5n, 7n, 11n]],
[7n, [1n, 1n, 1n, 1n, 1n, 1n, 1n]],
[0n, [1n, 2n, 3n]],
[1n, [1n]],
[10_000_000n, [333n, 333n, 334n]],
];
for (const [total, weights] of cases) {
const parts = splitProRata(total, weights);
expect(parts.length).toBe(weights.length);
expect(parts.reduce((a, b) => a + b, 0n)).toBe(total);
}
});

it('randomized property test: parts always sum exactly to the total', () => {
// Deterministic xorshift32 PRNG (no external dependency) seeded so the
// run is reproducible; report this seed/case count in the PR.
let state = 0x1378_1378 >>> 0;
const seed = state;
const next = (): number => {
state ^= state << 13;
state >>>= 0;
state ^= state >>> 17;
state ^= state << 5;
state >>>= 0;
return state;
};

const CASE_COUNT = 5_000;
for (let i = 0; i < CASE_COUNT; i += 1) {
const n = 1 + (next() % 12);
const total = BigInt(next() % 1_000_000_000);
const weights: bigint[] = [];
for (let j = 0; j < n; j += 1) {
weights.push(BigInt(next() % 1_000_000));
}
if (weights.every((w) => w === 0n)) {
continue;
}
const parts = splitProRata(total, weights);
const sum = parts.reduce((a, b) => a + b, 0n);
expect(sum).toBe(total);
for (const p of parts) {
expect(p >= 0n).toBe(true);
}
}
// Recorded for the PR description: seed 0x13781378, 5000 cases.
expect(seed).toBe(0x1378_1378);
});

it('throws when a nonzero total cannot be allocated (all weights zero)', () => {
expect(() => splitProRata(100n, [0n, 0n, 0n])).toThrow(MoneyError);
expect(splitProRata(0n, [0n, 0n, 0n])).toEqual([0n, 0n, 0n]);
});
});

describe('money/decimal STROOP_SCALE', () => {
it('matches the policy (10^7)', () => {
expect(STROOP_SCALE).toBe(10_000_000n);
});
});
69 changes: 69 additions & 0 deletions backend/src/__tests__/reconcileLoanStroops.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { jest } from '@jest/globals';

const mockQuery = jest.fn();

jest.unstable_mockModule('../db/connection.js', () => ({
query: mockQuery,
getClient: jest.fn(),
closePool: jest.fn(),
withTransaction: jest.fn(),
}));

const { reconcileLoanStroops } = await import('../services/defaultChecker.js');
const { toStroops } = await import('../money/decimal.js');

describe('reconcileLoanStroops', () => {
beforeEach(() => {
mockQuery.mockReset();
});

it('sums approved principal and repayments in exact stroops with zero drift once settled', async () => {
const principal = toStroops('1000');
// Two partial repayments that together exactly cover the principal —
// this is the "dust reconciliation" invariant: owed == paid to the
// stroop once a loan is fully repaid.
const first = toStroops('333.3333333');
const second = principal - first;

mockQuery.mockResolvedValueOnce({
rows: [
{ event_type: 'LoanApproved', amount: principal.toString() },
{ event_type: 'LoanRepaid', amount: first.toString() },
{ event_type: 'LoanRepaid', amount: second.toString() },
],
});

const result = await reconcileLoanStroops(42);

expect(result.owedStroops).toBe(principal);
expect(result.paidStroops).toBe(principal);
expect(result.driftStroops).toBe(0n);
expect(result.owedDisplay).toBe(result.paidDisplay);
});

it('reports the exact outstanding drift for a partially repaid loan', async () => {
const principal = toStroops('500');
const paid = toStroops('120.5000001');

mockQuery.mockResolvedValueOnce({
rows: [
{ event_type: 'LoanApproved', amount: principal.toString() },
{ event_type: 'LoanRepaid', amount: paid.toString() },
],
});

const result = await reconcileLoanStroops(7);

expect(result.driftStroops).toBe(principal - paid);
});

it('is a no-op (zero owed, zero paid) for an unknown loan id', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });

const result = await reconcileLoanStroops(999);

expect(result.owedStroops).toBe(0n);
expect(result.paidStroops).toBe(0n);
expect(result.driftStroops).toBe(0n);
});
});
Loading
Loading