From 3ee9ace4289d4aee7a8c664611f4a6e907da6059 Mon Sep 17 00:00:00 2001 From: jdjioe5-cpu <297354017+CodingAngel1@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:45:20 +0800 Subject: [PATCH] test(sdk): pin toBaseUnits behaviour at the i128 and NUMERIC(39,0) boundaries --- ROADMAP.md | 9 ++-- packages/sdk/src/__tests__/format.test.ts | 59 +++++++++++++++++++++++ services/indexer/test/ingest.test.ts | 47 ++++++++++++++++++ 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0b2a524..ed0cf8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ | | | |---|---| -| **Last updated** | 2026-10-08 (W-05 resolved; 17 Wave issues seeded with seven new rows; issue format proofed against Drips' maintainer guide; the three pre-format issues triaged — app #16 re-emitted, app #15 and registry #7 closed as superseded; repo-side Changelog lines unioned back into canonical by `scripts/roadmap-merge`; that merge's ADR-link normalization anchored so quoted prose cannot be rewritten) | +| **Last updated** | 2026-10-17 (M2-21 DONE: amount precision boundaries pinned at i128 and NUMERIC(39,0) limits in sdk and indexer) | | **Docs baseline** | v0.3, worldwide scope (`PRD.md`, `ARCHITECTURE.md`, `ARCHITECTURE_ESSENTIALS.md`, `AGENTS.md`, `CLAUDE.md`, `project_structure.md`) | | **Current phase** | Phase 0 (Foundations) → starting Phase 1 (M0 Validate) | | **Readiness** | See [§3](#3-progress-snapshot) | @@ -130,7 +130,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | 0 Foundations | 19 | 16 | 1 | 2 | 0 | 0 | 0 | 84% | | 1 M0 Validate | 17 | 1 | 6 | 10 | 0 | 0 | 0 | 6% | | 2 Contract + registry | 41 | 24 | 3 | 14 | 0 | 0 | 0 | 59% | -| 3 SDK + indexer | 21 | 14 | 3 | 4 | 0 | 0 | 0 | 67% | +| 3 SDK + indexer | 21 | 15 | 3 | 3 | 0 | 0 | 0 | 71% | | 4 App | 27 | 15 | 7 | 5 | 0 | 0 | 0 | 56% | | 5 Testnet pilot | 7 | 0 | 0 | 7 | 0 | 0 | 0 | 0% | | 6 Pre-mainnet features | 10 | 0 | 0 | 10 | 0 | 0 | 0 | 0% | @@ -139,7 +139,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | 9 Conditional ramp | 9 | 0 | 0 | 0 | 0 | 9 | 0 | n/a | | 10 Wave + community | 10 | 5 | 1 | 4 | 0 | 0 | 0 | 50% | | 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a | -| **All** | **211** | **75** | **21** | **90** | **0** | **25** | **0** | **40%** | +| **All** | **211** | **76** | **21** | **89** | **0** | **25** | **0** | **41%** | --- @@ -286,7 +286,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | M2-18 | Indexer container, testnet deployment, managed Postgres, daily backups | sdk | P0 | IN PROGRESS | M2-14 | Running on testnet; restore tested once | | M2-19 | Indexer and list API: store and filter payees by `country` and `local_currency` (display and filter only) | sdk | P0 | DONE | M2-13, M1-29 | `/payees?country=` works; no country logic elsewhere | | M2-20 | Public HTTP reference for the list API (`services/indexer/docs/API.md`) with a route-parity test | sdk | P1 | TODO | M2-12 | All five routes documented with units and chain-vs-indexer labelling; renaming a route without editing the doc fails a test | -| M2-21 | Amount precision boundary pinned at the `i128` and `NUMERIC(39,0)` limits; any SDK-side guard approved on the issue first | sdk | P1 | TODO | M2-07 | Boundary cases tested and as-is behaviour recorded; no money path uses `number` | +| M2-21 | Amount precision boundary pinned at the `i128` and `NUMERIC(39,0)` limits; any SDK-side guard approved on the issue first | sdk | P1 | DONE | M2-07 | Boundary cases tested and as-is behaviour recorded; no money path uses `number` | --- @@ -535,6 +535,7 @@ Newest first. One entry per PR. Required for every contribution (see §2). | Date | PR / ref | Repo | Rows touched | Summary | |---|---|---|---|---| +| 2026-10-17 | `test/amount-precision-boundary` (#36) | sdk | DONE: M2-21 | Pin amount precision boundary at `i128` and `NUMERIC(39,0)` limits: characterise as-is behaviour of `toBaseUnits`/`fromBaseUnits` at `2^127-1`, `2^127`, 39-digit (`NUMERIC(39,0)` max), 40-digit, and `i128::MAX/10^7` boundaries without rounding; verify write path stops on `NUMERIC(39,0)` overflow in Postgres; no `number` type on money paths. 173 tests passing. | | 2026-10-08 | `chore/roadmap-copy-back` (#39) | sdk | no row changes | Copy-back run: `ROADMAP.md` replaced with the merged canonical (`.github` main as of #31) in repo-root link form, and §3 recomputed with `scripts/roadmap-progress`. This is the step `roadmap-merge`'s header requires after every merge and that was skipped between 2026-10-06 and 2026-10-08, which is how the seven copies reached 92 divergent statuses. Verified in this checkout: the file equals canonical byte for byte apart from this entry and the ADR link prefix, `W-05` reads `DONE`, the seven Wave rows are present, duplicate row-ID scan is 0. No row, status, priority, dependency or **Done when** was authored in this repo. | | 2026-10-08 | `fix/roadmap-merge-link-anchor` | org | no row changes | Found while preparing the copy-back. `roadmap-merge`'s read normalization rewrote the docs-rooted ADR link form into the root-relative one on every copy so the two depths compare equal — correct for links, wrong for prose: the union entry above quotes both forms, so reading that line collapsed its own quote and emitted a Changelog entry contradicting itself, in a copy that no longer matched canonical line for line. Fixed by anchoring the replace on the numeric ADR filename that always follows the slash, so only real links are rewritten, and by re-wording the union entry to name the two forms as `docs/adr/` and `adr/` rather than spelling them as link text: **an entry that describes this rewrite must not itself be a string the rewrite can act on.** Verified: the anchored read leaves a quoted form byte-identical where the blanket replace corrupted it; a full `roadmap-merge` over all five copies against `b12d290` exits 0 with **0 lines** differing from canonical, which also confirms the union is complete; the one real link (`ADR-0026`) still normalizes both ways. No row, status, priority, dependency or **Done when** in this diff. | | 2026-10-08 | `chore/roadmap-changelog-union` | org | no row changes | Second `scripts/roadmap-merge` run now that the Wave-format PRs have merged (`.github` #26 and #27, contracts #31, sdk #38, app #43, registry #18). Three-way against `b12d290`, the revision where all five copies were last identical. The merge's **entire** output is four lines: the repo-side `docs/wave-issue-format` Changelog rows that each repo wrote into its own copy and canonical never saw. No row ID, status, priority, dependency or **Done when** moved — the seven new rows went in byte-identically, and `W-05`'s `TODO` → `DONE` stayed on the `org` row in canonical as §12 requires, so the repo copies' deliberately-untouched `TODO` was not merged back over it. §3 verified unchanged by `roadmap-progress`: 211 rows, 75 `DONE`, 40%. **Not in this PR:** the copy-back. Each repo's `ROADMAP.md` must be rewritten from the merged canonical, with the ADR links rewritten for the repo-root location, so all five copies are byte-identical again — that invariant is what makes `--ancestor` meaningful next time, and it needs four repo PRs that can only be cut after this lands. | diff --git a/packages/sdk/src/__tests__/format.test.ts b/packages/sdk/src/__tests__/format.test.ts index a6575d4..4f69d26 100644 --- a/packages/sdk/src/__tests__/format.test.ts +++ b/packages/sdk/src/__tests__/format.test.ts @@ -46,3 +46,62 @@ describe("fromBaseUnits", () => { } }); }); + +describe("amount precision and boundary characterisation (M2-21)", () => { + const I128_MAX = 2n ** 127n - 1n; // 170141183460469231731687303715884105727n (39 digits) + const ONE_ABOVE_I128_MAX = 2n ** 127n; // 170141183460469231731687303715884105728n (39 digits) + const DIGITS_39 = 10n ** 39n - 1n; // 999999999999999999999999999999999999999n (39 digits) + const DIGITS_40 = 10n ** 39n; // 1000000000000000000000000000000000000000n (40 digits) + const I128_MAX_DIV_1E7 = I128_MAX / 10_000_000n; // 17014118346046923173168730371588n + + it("handles i128::MAX boundary: exactly 2^127 - 1 base units", () => { + const decimal = "17014118346046923173168730371588.4105727"; + expect(toBaseUnits(decimal)).toBe(I128_MAX); + expect(fromBaseUnits(I128_MAX)).toBe(decimal); + expect(toBaseUnits(fromBaseUnits(I128_MAX))).toBe(I128_MAX); + }); + + it("characterises one above i128::MAX (2^127): accepted by SDK BigInt, rejected by contract i128 overflow", () => { + const decimal = "17014118346046923173168730371588.4105728"; + expect(toBaseUnits(decimal)).toBe(ONE_ABOVE_I128_MAX); + expect(fromBaseUnits(ONE_ABOVE_I128_MAX)).toBe(decimal); + expect(toBaseUnits(fromBaseUnits(ONE_ABOVE_I128_MAX))).toBe(ONE_ABOVE_I128_MAX); + }); + + it("characterises 39-digit value (NUMERIC(39,0) maximum): accepted by SDK and Postgres column", () => { + const decimal = "99999999999999999999999999999999.9999999"; + expect(toBaseUnits(decimal)).toBe(DIGITS_39); + expect(fromBaseUnits(DIGITS_39)).toBe(decimal); + expect(toBaseUnits(fromBaseUnits(DIGITS_39))).toBe(DIGITS_39); + }); + + it("characterises 40-digit value: accepted by SDK BigInt, rejected by Postgres NUMERIC(39,0)", () => { + const decimal = "100000000000000000000000000000000"; + expect(toBaseUnits(decimal)).toBe(DIGITS_40); + expect(fromBaseUnits(DIGITS_40)).toBe(decimal); + expect(toBaseUnits(fromBaseUnits(DIGITS_40))).toBe(DIGITS_40); + }); + + it("characterises i128::MAX / 10^7 as largest plain integer amount for USDC", () => { + // 17014118346046923173168730371588 * 10^7 <= i128::MAX + const atLimit = toBaseUnits(I128_MAX_DIV_1E7.toString()); + expect(atLimit).toBe(170141183460469231731687303715880000000n); + expect(atLimit <= I128_MAX).toBe(true); + + // One above: 17014118346046923173168730371589 * 10^7 > i128::MAX + const aboveLimit = toBaseUnits((I128_MAX_DIV_1E7 + 1n).toString()); + expect(aboveLimit).toBe(170141183460469231731687303715890000000n); + expect(aboveLimit > I128_MAX).toBe(true); + }); + + it("rejects negative human amounts in toBaseUnits while fromBaseUnits formats negative delta", () => { + expect(() => toBaseUnits("-1")).toThrow(expect.objectContaining({ code: "INVALID_AMOUNT" })); + expect(() => toBaseUnits("-0.1")).toThrow(expect.objectContaining({ code: "INVALID_AMOUNT" })); + expect(fromBaseUnits(-100n)).toBe("-0.00001"); + }); + + it("never rounds fractions longer than asset decimals", () => { + expect(() => toBaseUnits("0.00000001")).toThrow(/never rounded/); + expect(() => toBaseUnits("17014118346046923173168730371588.41057271")).toThrow(/never rounded/); + }); +}); diff --git a/services/indexer/test/ingest.test.ts b/services/indexer/test/ingest.test.ts index 655fb5d..edcb309 100644 --- a/services/indexer/test/ingest.test.ts +++ b/services/indexer/test/ingest.test.ts @@ -215,4 +215,51 @@ describe("ingest: stops instead of skipping", () => { expect(await snapshot(db)).toEqual({ chainEvents: [], locks: [], tranches: [], payees: [] }); expect(await db.select().from(indexerCursor)).toHaveLength(0); }); + + it("stops on write path when amount exceeds NUMERIC(39,0) column precision", async () => { + const { db } = await freshDb(); + const d39 = 10n ** 39n - 1n; + const d40 = 10n ** 39n; + + // 39 digits succeeds within NUMERIC(39,0) + await db.insert(locks).values({ + id: 999n, + sender: "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM", + payeeId: KE, + payout: "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + total: d39, + refHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + expiresAt: new Date(1800000000 * 1000), + state: "Open", + createdAt: new Date(), + createdTx: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }); + + const [inserted] = await db.select().from(locks).where(eq(locks.id, 999n)); + expect(inserted?.total).toBe(d39); + + // 40 digits fails on write path due to Postgres numeric precision + let caught: unknown; + try { + await db.insert(locks).values({ + id: 1000n, + sender: "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM", + payeeId: KE, + payout: "GCLMHF7LAR34TSELDNEREYPTNE4K7MMESPS62WXE7ZDLPWIJUF3MC5QM", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + total: d40, + refHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + expiresAt: new Date(1800000000 * 1000), + state: "Open", + createdAt: new Date(), + createdTx: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }); + } catch (e) { + caught = e; + } + expect(caught).toBeDefined(); + const causeMessage = String((caught as { cause?: { message?: string } })?.cause?.message ?? ""); + expect(causeMessage).toMatch(/less than 10\^39|numeric field overflow/i); + }); });