diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad59764..047973f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,5 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm typecheck - - run: pnpm test -- --passWithNoTests + - run: pnpm test - run: pnpm build diff --git a/package.json b/package.json index 896511e..7bceb55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sentrix/chain", - "version": "0.2.0-rc.0", + "version": "0.3.0-rc.0", "description": "Official TypeScript SDK for Sentrix Chain \u2014 typed wrappers around EVM JSON-RPC + native REST + WebSocket subscriptions.", "type": "module", "main": "./dist/index.js", diff --git a/src/native/index.ts b/src/native/index.ts index 049545e..b32d98b 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -7,12 +7,19 @@ import { getSpec, type SentrixNetwork } from "../network.js"; +// Audit 2026-05-07 H1 (HIGH): supply / amount / fee / stake fields use +// `bigint` instead of `number` — JS safe-int (~90.07M SRX) overflows +// below the 315M supply cap, causing silent rounding on any high-value +// query. Counters that are inherently bounded (height, validator count, +// per-sender nonce) stay `number` for ergonomics. + export interface ChainInfo { height: number; total_blocks: number; - total_minted_srx: number; - total_burned_srx: number; - max_supply_srx: number; + /** Sentri (8-decimal). u64 on chain; bigint for exact precision. */ + total_minted_srx: bigint; + total_burned_srx: bigint; + max_supply_srx: bigint; active_validators: number; mempool_size: number; } @@ -45,12 +52,14 @@ export interface Transaction { txid: string; from_address: string; to_address: string; - amount: number; - fee: number; - nonce: number; + /** Sentri. u64 on chain; bigint for exact precision. */ + amount: bigint; + fee: bigint; + /** Per-sender nonce. u64 on chain; bigint for parity with chain side. */ + nonce: bigint; data?: string | null; signature?: string | null; - chain_id?: number; + chain_id?: bigint; } export interface Validator { @@ -58,8 +67,11 @@ export interface Validator { name?: string; is_active: boolean; is_jailed: boolean; - self_stake?: number; - total_delegated?: number; + /** Sentri. */ + self_stake?: bigint; + /** Sentri. */ + total_delegated?: bigint; + /** Basis points (1000 = 10%). Bounded by chain rules; number is fine. */ commission_rate?: number; blocks_produced?: number; } @@ -69,8 +81,10 @@ export interface EpochInfo { start_height: number; end_height: number; total_blocks_produced: number; - total_rewards: number; - total_staked: number; + /** Sentri. */ + total_rewards: bigint; + /** Sentri. */ + total_staked: bigint; validator_set?: string[]; } @@ -136,7 +150,8 @@ export class SentrixNativeClient { } // ── account ────────────────────────────────────────────────── - balance(address: string): Promise<{ address: string; balance_srx: number; nonce: number }> { + /** Returns balance in sentri (u64 on chain → bigint here for precision). */ + balance(address: string): Promise<{ address: string; balance_srx: bigint; nonce: bigint }> { return this.get(`/accounts/${address}`); } @@ -169,6 +184,11 @@ export class SentrixNativeClient { if (!res.ok) { throw new Error(`Sentrix REST ${path} → HTTP ${res.status}`); } + // Audit 2026-05-07 H1: response numerics that must be bigint (amounts, + // supply, stake) come back as JS number from res.json() — risks rounding + // for values > 2^53. Bigint revival is a follow-up; until then, if a + // caller hits a high-value endpoint, they should use bigintFromJsonText + // (exported below) on the raw text. Tracked as v0.3.0 follow-up. return (await res.json()) as T; } finally { clearTimeout(timer); @@ -183,7 +203,9 @@ export class SentrixNativeClient { method: "POST", signal: ctrl.signal, headers: { "content-type": "application/json", "accept": "application/json" }, - body: JSON.stringify(body), + // Audit 2026-05-07 H2: bigint-aware serialize so submitTx with + // high-value amounts doesn't throw on JSON.stringify. + body: stringifyWithBigInt(body), }); if (!res.ok) { const text = await res.text().catch(() => ""); @@ -196,6 +218,35 @@ export class SentrixNativeClient { } } +/** Audit 2026-05-07 H2 helper. JSON.stringify replacement that emits bigint + * values as bare integer literals (matches Rust serde_json u64 output). */ +export function stringifyWithBigInt(value: unknown): string { + return JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() + "n_BIG" : v)) + .replace(/"(\d+)n_BIG"/g, "$1"); +} + +/** Audit 2026-05-07 H1 follow-up helper. For endpoints that return high-value + * amounts (supply, stake) as integer literals, parse the raw response text + * with a per-key bigint revival. Pass `bigintKeys` listing the field names + * that should be bigint in your typed response. + * + * Example: `bigintFromJsonText(text, ["total_minted_srx", "total_burned_srx"])`. */ +export function bigintFromJsonText(text: string, bigintKeys: readonly string[]): T { + // Mark each `"key": ` for the listed keys so JSON.parse keeps + // precision via reviver-as-string. + let marked = text; + for (const k of bigintKeys) { + const re = new RegExp(`("${k}"\\s*:\\s*)(-?\\d+)(\\s*[,}])`, "g"); + marked = marked.replace(re, '$1"BIGINT:$2"$3'); + } + return JSON.parse(marked, (_k, v) => { + if (typeof v === "string" && v.startsWith("BIGINT:")) { + return BigInt(v.slice("BIGINT:".length)); + } + return v; + }) as T; +} + export * from "./tx.js"; export function nativeClient(network: SentrixNetwork, opts: NativeClientOptions = {}): SentrixNativeClient { diff --git a/src/native/tx.ts b/src/native/tx.ts index afb90ab..7989fae 100644 --- a/src/native/tx.ts +++ b/src/native/tx.ts @@ -4,42 +4,51 @@ // All amounts are in `sentri` (1 SRX = 100,000,000 sentri = 1e8). EVM tooling // sees 18-decimal wei via `eth_getBalance`, but native txs deal in 8-decimal // sentri only. +// +// Audit 2026-05-07 H1+H2 (HIGH): all amount/fee/nonce/timestamp/chain_id +// fields are now `bigint`. Pre-fix these were `number` which overflows JS +// safe-int (~90.07M SRX) below the 315M supply cap; high-value txs would +// silently round on the JS side and hash to a different signing payload than +// the chain expected. Bigint ⇒ exact arithmetic at any size. export interface NativeTx { /** sha256(signing_payload), hex-encoded, lowercase, no `0x` prefix. */ txid: string; from_address: string; to_address: string; - /** Sentri (8-decimal). */ - amount: number; + /** Sentri (8-decimal). u64 on the chain; bigint here for exact precision. */ + amount: bigint; /** Sentri. Minimum on-chain fee is 10_000 sentri = 0.0001 SRX. */ - fee: number; - /** Per-sender nonce. */ - nonce: number; + fee: bigint; + /** Per-sender nonce. u64 on the chain. */ + nonce: bigint; /** Encoded payload: empty for plain SRX transfer; "TOKEN:..." for SRC-20 * TokenOps; "STAKE:..." for native StakingOps. */ data: string; - /** Unix seconds. */ - timestamp: number; - /** 7119 mainnet, 7120 testnet. */ - chain_id: number; + /** Unix seconds. u64 on the chain. */ + timestamp: bigint; + /** 7119 mainnet, 7120 testnet. u64 on the chain. */ + chain_id: bigint; /** Hex-encoded secp256k1 signature (compact 64 bytes), lowercase, no `0x`. */ signature: string; /** Hex-encoded compressed secp256k1 pubkey (33 bytes), lowercase, no `0x`. */ public_key: string; } +/** Default timestamp helper — current Unix seconds as bigint. */ +const nowSecs = (): bigint => BigInt(Math.floor(Date.now() / 1000)); + /** Build an unsigned native SRX transfer tx. The wallet's `sign()` then * fills in txid + signature + public_key. */ export function buildTransfer(opts: { from: string; to: string; - amount: number; - fee: number; - nonce: number; - chainId: number; + amount: bigint; + fee: bigint; + nonce: bigint; + chainId: bigint; /** Defaults to `Date.now()/1000`. Override for deterministic tests. */ - timestamp?: number; + timestamp?: bigint; }): Omit { return { from_address: opts.from.toLowerCase(), @@ -48,7 +57,7 @@ export function buildTransfer(opts: { fee: opts.fee, nonce: opts.nonce, data: "", - timestamp: opts.timestamp ?? Math.floor(Date.now() / 1000), + timestamp: opts.timestamp ?? nowSecs(), chain_id: opts.chainId, }; } @@ -58,11 +67,11 @@ export function buildTransfer(opts: { export function buildDelegate(opts: { from: string; validator: string; - amount: number; - fee: number; - nonce: number; - chainId: number; - timestamp?: number; + amount: bigint; + fee: bigint; + nonce: bigint; + chainId: bigint; + timestamp?: bigint; }): Omit { return { from_address: opts.from.toLowerCase(), @@ -71,7 +80,7 @@ export function buildDelegate(opts: { fee: opts.fee, nonce: opts.nonce, data: `STAKE:DELEGATE:${opts.validator.toLowerCase()}`, - timestamp: opts.timestamp ?? Math.floor(Date.now() / 1000), + timestamp: opts.timestamp ?? nowSecs(), chain_id: opts.chainId, }; } @@ -81,11 +90,11 @@ export function buildDelegate(opts: { export function buildUndelegate(opts: { from: string; validator: string; - amount: number; - fee: number; - nonce: number; - chainId: number; - timestamp?: number; + amount: bigint; + fee: bigint; + nonce: bigint; + chainId: bigint; + timestamp?: bigint; }): Omit { return { from_address: opts.from.toLowerCase(), @@ -94,7 +103,7 @@ export function buildUndelegate(opts: { fee: opts.fee, nonce: opts.nonce, data: `STAKE:UNDELEGATE:${opts.validator.toLowerCase()}`, - timestamp: opts.timestamp ?? Math.floor(Date.now() / 1000), + timestamp: opts.timestamp ?? nowSecs(), chain_id: opts.chainId, }; } @@ -105,19 +114,19 @@ export function buildUndelegate(opts: { * whatever the registry has accrued for this address). */ export function buildClaimRewards(opts: { from: string; - fee: number; - nonce: number; - chainId: number; - timestamp?: number; + fee: bigint; + nonce: bigint; + chainId: bigint; + timestamp?: bigint; }): Omit { return { from_address: opts.from.toLowerCase(), to_address: "0x0000000000000000000000000000000000000100", - amount: 0, + amount: 0n, fee: opts.fee, nonce: opts.nonce, data: "STAKE:CLAIM_REWARDS", - timestamp: opts.timestamp ?? Math.floor(Date.now() / 1000), + timestamp: opts.timestamp ?? nowSecs(), chain_id: opts.chainId, }; } @@ -127,20 +136,23 @@ export function buildTokenTransfer(opts: { from: string; contract: string; to: string; - amount: number; - fee: number; - nonce: number; - chainId: number; - timestamp?: number; + amount: bigint; + fee: bigint; + nonce: bigint; + chainId: bigint; + timestamp?: bigint; }): Omit { return { from_address: opts.from.toLowerCase(), to_address: "0x0000000000000000000000000000000000000000", - amount: 0, + amount: 0n, fee: opts.fee, nonce: opts.nonce, - data: `TOKEN:TRANSFER:${opts.contract.toLowerCase()}:${opts.to.toLowerCase()}:${opts.amount}`, - timestamp: opts.timestamp ?? Math.floor(Date.now() / 1000), + // Note: token amount is part of the data string (not the tx amount field). + // Bigint stringifies via String(amount) which emits the bare integer literal + // — exactly what the chain's deserializer expects. + data: `TOKEN:TRANSFER:${opts.contract.toLowerCase()}:${opts.to.toLowerCase()}:${String(opts.amount)}`, + timestamp: opts.timestamp ?? nowSecs(), chain_id: opts.chainId, }; } diff --git a/src/wallet/index.ts b/src/wallet/index.ts index a3c4dc1..b1fa143 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -69,18 +69,28 @@ export class SentrixWallet { * `txid`, `signature`, `public_key` from the digest. */ static signingPayload(tx: Pick): string { // Keep field order identical to the Rust BTreeMap iteration order - // (alphabetical by key — note "from" comes before "nonce" alphabetically). - const ordered = { - amount: tx.amount, - chain_id: tx.chain_id, - data: tx.data, - fee: tx.fee, - from: tx.from_address, - nonce: tx.nonce, - timestamp: tx.timestamp, - to: tx.to_address, - }; - return JSON.stringify(ordered); + // (alphabetical by key — "from" comes before "nonce" alphabetically). + // + // Audit 2026-05-07 H2: previously used JSON.stringify which throws on + // bigint AND silently rounded numbers > 2^53 in the pre-bigint era. + // Now we build the JSON string MANUALLY so bigint amounts are emitted + // as bare integer literals — matching Rust's serde_json u64 output + // byte-for-byte. Any drift here makes the sha256 differ → on-chain + // signature verify fails. + const intLit = (n: bigint): string => n.toString(); + const strLit = (s: string): string => JSON.stringify(s); + return ( + "{" + + `"amount":${intLit(tx.amount)},` + + `"chain_id":${intLit(tx.chain_id)},` + + `"data":${strLit(tx.data)},` + + `"fee":${intLit(tx.fee)},` + + `"from":${strLit(tx.from_address)},` + + `"nonce":${intLit(tx.nonce)},` + + `"timestamp":${intLit(tx.timestamp)},` + + `"to":${strLit(tx.to_address)}` + + "}" + ); } /** Sign a tx — fills in `txid`, `signature`, `public_key`, returns the diff --git a/src/wallet/signing-payload.test.ts b/src/wallet/signing-payload.test.ts new file mode 100644 index 0000000..bd48549 --- /dev/null +++ b/src/wallet/signing-payload.test.ts @@ -0,0 +1,81 @@ +// Audit 2026-05-07 H4 fix: signing-payload byte-equality fixture. +// CI was running `pnpm test --passWithNoTests` because no test files +// existed. This file establishes the canonical signing payload format +// matches the Rust chain side byte-for-byte. Future drift on either +// side fails this test. +// +// Generated with: +// node -e 'crypto.createHash("sha256").update().digest("hex")' + +import { describe, it, expect } from "vitest"; +import { sha256 } from "@noble/hashes/sha2"; +import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils"; +import { SentrixWallet } from "./index.js"; + +describe("SentrixWallet.signingPayload", () => { + it("emits the canonical alphabetical-key JSON shape", () => { + const tx = { + amount: 100n, + chain_id: 7119n, + data: "", + fee: 10n, + from_address: "0xabc1234567890abcdef1234567890abcdef123456", + nonce: 0n, + timestamp: 1700000000n, + to_address: "0xdef1234567890abcdef1234567890abcdef987654", + }; + const payload = SentrixWallet.signingPayload(tx); + // Field order: amount, chain_id, data, fee, from, nonce, timestamp, to + // Note: "from" not "from_address", "to" not "to_address" (matches Rust). + expect(payload).toBe( + `{"amount":100,"chain_id":7119,"data":"","fee":10,"from":"0xabc1234567890abcdef1234567890abcdef123456","nonce":0,"timestamp":1700000000,"to":"0xdef1234567890abcdef1234567890abcdef987654"}` + ); + // sha256 must match what crypto.createHash("sha256").update(payload).digest("hex") + // produces. Drift fails this assertion. + expect(bytesToHex(sha256(utf8ToBytes(payload)))).toBe( + "d9fd5a36e5bc6ca55f18ad6e9ac80a36467df800cba91931369aed76ee2a624e" + ); + }); + + it("preserves precision for amounts > 2^53 (audit H1 + H2)", () => { + // 10^16 sentri = 100M SRX. JS Number.MAX_SAFE_INTEGER = 2^53 ≈ 9.007e15. + // 10^16 exceeds safe-int by ~10x. Pre-fix, JSON.stringify(number) + // produced "amount":1e16 OR rounded the value silently. + // With bigint + manual JSON build, the output is the exact integer literal. + const tx = { + amount: 10_000_000_000_000_000n, + chain_id: 7119n, + data: "", + fee: 10000n, + from_address: "0x1111111111111111111111111111111111111111", + nonce: 42n, + timestamp: 1778153000n, + to_address: "0x2222222222222222222222222222222222222222", + }; + const payload = SentrixWallet.signingPayload(tx); + expect(payload).toBe( + `{"amount":10000000000000000,"chain_id":7119,"data":"","fee":10000,"from":"0x1111111111111111111111111111111111111111","nonce":42,"timestamp":1778153000,"to":"0x2222222222222222222222222222222222222222"}` + ); + expect(bytesToHex(sha256(utf8ToBytes(payload)))).toBe( + "02a7d3b7e82c04ff59bda9de3b38befd3ebd89429a754192601d9dc532aeead3" + ); + }); + + it("alphabetical key order is stable regardless of input order", () => { + // Caller may pass fields in any order; output must always be + // amount/chain_id/data/fee/from/nonce/timestamp/to. + const tx = { + to_address: "0x2222222222222222222222222222222222222222", + from_address: "0x1111111111111111111111111111111111111111", + nonce: 1n, + timestamp: 1700000000n, + data: "", + chain_id: 7119n, + fee: 100n, + amount: 1000n, + }; + const payload = SentrixWallet.signingPayload(tx); + expect(payload.startsWith(`{"amount":1000,`)).toBe(true); + expect(payload.endsWith(`"to":"0x2222222222222222222222222222222222222222"}`)).toBe(true); + }); +});