Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ jobs:

- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm test -- --passWithNoTests
- run: pnpm test
- run: pnpm build
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
77 changes: 64 additions & 13 deletions src/native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -45,21 +52,26 @@ 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 {
address: string;
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;
}
Expand All @@ -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[];
}

Expand Down Expand Up @@ -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}`);
}

Expand Down Expand Up @@ -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);
Expand All @@ -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(() => "");
Expand All @@ -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<T>(text: string, bigintKeys: readonly string[]): T {
// Mark each `"key": <int>` 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 {
Expand Down
94 changes: 53 additions & 41 deletions src/native/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeTx, "txid" | "signature" | "public_key"> {
return {
from_address: opts.from.toLowerCase(),
Expand All @@ -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,
};
}
Expand All @@ -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<NativeTx, "txid" | "signature" | "public_key"> {
return {
from_address: opts.from.toLowerCase(),
Expand All @@ -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,
};
}
Expand All @@ -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<NativeTx, "txid" | "signature" | "public_key"> {
return {
from_address: opts.from.toLowerCase(),
Expand All @@ -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,
};
}
Expand All @@ -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<NativeTx, "txid" | "signature" | "public_key"> {
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,
};
}
Expand All @@ -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<NativeTx, "txid" | "signature" | "public_key"> {
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,
};
}
34 changes: 22 additions & 12 deletions src/wallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,28 @@ export class SentrixWallet {
* `txid`, `signature`, `public_key` from the digest. */
static signingPayload(tx: Pick<NativeTx, "amount" | "chain_id" | "data" | "fee" | "from_address" | "nonce" | "timestamp" | "to_address">): 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
Expand Down
Loading
Loading