From b677894d92cd39a9444bec93b09226d775145dfd Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Wed, 29 Jul 2026 02:26:23 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat!:=20v3=20wire=20format=20=E2=80=94=20c?= =?UTF-8?q?hain=5Fid=20in=20signing,=20bigint=20amounts,=20tx=20verificati?= =?UTF-8?q?on,=20Burn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signTransaction now RLP-encodes chain_id at index 2 in both the hash preimage (4-item list) and the full signed payload (8-item list), matching clutch-node's Plan A wire format byte-for-byte. - Add verifyUnsignedTransaction(unsignedTx, expected): pure, exported. signTransaction calls it when given `expected`, closing the blind-signing hole where a compromised hub could alter the fare, swap the chain_id, or hand back a mismatched tx type. chain_id is checked with strict equality (not presence-only) against a client-pinned value, never the hub's own chainInfo response. The hub-injected referrer cannot yet be verified (needs a future signed-quote flow) so it is returned in VerifiedTx.referrer for the caller to display pre-sign as an interim mitigation. - Add Burn (RLP tag 7) support: encodeFunctionCall case, and createUnsignedBurn mutation wrapper. - Auth challenge is now chain-bound: clutch-auth:{chainId}:{publicKey}: {timestamp}. Verified byte-for-byte against clutch-hub-api's own Rust test fixtures in auth.rs. - Add getAuthHeaders() for callers that need this SDK's JWT outside its own GraphQL calls. - Add formatUsd(microUsd: bigint): string for integer-only $X.XX display. - Add explicit .js extensions to relative imports in src/index.ts and src/sdk.ts so dist/ is loadable under Node ESM (tcs emits extensionless specifiers otherwise, which ERR_MODULE_NOT_FOUNDs without bundler resolution). BREAKING CHANGE: signTransaction's hash preimage and signed payload both gained chain_id (inserted after nonce; everything after it shifts by one index). fare/amount/balance public types moved from number to bigint; the corresponding GraphQL mutation variables changed from Int to String. buildAuthChallengeMessage/authChallengeHashHex/ signAuthChallenge gained a required leading chainId parameter and the auth challenge string format changed — no fallback to the old two-field format. Requires clutch-node treasury-break and a hub-api build with chainInfo/createUnsignedBurn. The orchestrator REST client described in the task brief was deliberately not built: it targets a payment-orchestrator service that does not exist yet. Co-Authored-By: Claude Fable 5 --- src/index.ts | 8 +- src/sdk.ts | 328 ++++++++++++++++++++++++++++++++++++++++------- src/types.ts | 35 ++++- test_wire_v3.mjs | 73 +++++++++++ 4 files changed, 387 insertions(+), 57 deletions(-) create mode 100644 test_wire_v3.mjs diff --git a/src/index.ts b/src/index.ts index 00dc6fd..081a7a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -export * from './types'; -export * from './sdk'; +export * from './types.js'; +export * from './sdk.js'; export { hubGraphqlWsUrl, RIDE_REQUEST_GQL_FIELDS, @@ -7,5 +7,5 @@ export { ACTIVE_TRIP_GQL_FIELDS, RECENT_TRIP_GQL_FIELDS, createHubSubscriptionClient, -} from './subscriptions'; -export type { SubscriptionHandlers } from './subscriptions'; \ No newline at end of file +} from './subscriptions.js'; +export type { SubscriptionHandlers } from './subscriptions.js'; \ No newline at end of file diff --git a/src/sdk.ts b/src/sdk.ts index 547fc19..0ce0f94 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -12,13 +12,14 @@ import { RIDE_OFFER_GQL_FIELDS, RIDE_REQUEST_GQL_FIELDS, type SubscriptionHandlers, -} from './subscriptions'; +} from './subscriptions.js'; import { AvailableRideRequest, AvailableRideOffer, AvailableActiveTrip, AvailableCompletedTrip, AvailableRecentTrip, + BurnArgs, FaucetResponse, MapBounds, RideRequestArgs, @@ -28,7 +29,7 @@ import { RideCancelArgs, RideRequestCancelArgs, Signature, -} from './types'; +} from './types.js'; /** Strip 0x/0X prefix - hex parsers (e.g. @noble/secp256k1) do not accept it. Exported for consumers. */ export function stripHexPrefix(hex: string): string { @@ -94,11 +95,13 @@ export const AUTH_CHALLENGE_PREFIX = 'clutch-auth'; /** * Canonical auth challenge message for `generateToken`. Must match clutch-hub-api - * (`hub::auth::build_auth_challenge_message`) byte-for-byte: the exact `publicKey` string - * sent as the mutation argument and the timestamp in decimal unix seconds. + * (`hub::auth::build_auth_challenge_message`) byte-for-byte: `clutch-auth:{chainId}:{publicKey}:{timestamp}`. + * `chainId` binds the signed challenge to this hub's chain — without it, a challenge captured + * on one chain would authenticate the same key on any other Clutch hub within the clock-skew + * window. Breaking change from the pre-treasury (chainId-less) format; no fallback. */ -export function buildAuthChallengeMessage(publicKey: string, timestamp: number): string { - return `${AUTH_CHALLENGE_PREFIX}:${publicKey}:${timestamp}`; +export function buildAuthChallengeMessage(chainId: number, publicKey: string, timestamp: number): string { + return `${AUTH_CHALLENGE_PREFIX}:${chainId}:${publicKey}:${timestamp}`; } /** @@ -106,8 +109,8 @@ export function buildAuthChallengeMessage(publicKey: string, timestamp: number): * The signature is then computed over the UTF-8 bytes of this hex string (see `signHashHex`), * the same convention used for transaction hashes. */ -export function authChallengeHashHex(publicKey: string, timestamp: number): string { - const message = buildAuthChallengeMessage(publicKey, timestamp); +export function authChallengeHashHex(chainId: number, publicKey: string, timestamp: number): string { + const message = buildAuthChallengeMessage(chainId, publicKey, timestamp); return Buffer.from(keccak_256(Buffer.from(message, 'utf8'))).toString('hex'); } @@ -135,11 +138,12 @@ async function signHashHex(hashHex: string, privateKey: string): Promise { - return signHashHex(authChallengeHashHex(publicKey, timestamp), privateKey); + return signHashHex(authChallengeHashHex(chainId, publicKey, timestamp), privateKey); } type SharedGraphqlWsEntry = { client: Client; refcount: number }; @@ -166,7 +170,8 @@ function sharedGraphqlWsCacheKey(baseURL: string, publicKey: string): string { */ async function ensureTokenInCacheForPublicKey( publicKey: string, - apiClient: AxiosInstance + apiClient: AxiosInstance, + chainId: number ): Promise { const now = Date.now(); const bufferTime = 30000; @@ -199,7 +204,7 @@ async function ensureTokenInCacheForPublicKey( const requestPromise: Promise = (async () => { const timestamp = Math.floor(Date.now() / 1000); - const signature = await signAuthChallenge(publicKey, timestamp, privateKey); + const signature = await signAuthChallenge(chainId, publicKey, timestamp, privateKey); const response = await apiClient.post<{ data?: unknown; errors?: { message: string }[] }>( '/graphql', { @@ -241,6 +246,145 @@ export interface UnsignedTransaction { data: any; from: string; nonce: number; + /** u64 on the wire; kept as `number` here since real chain ids fit well under 2^53. */ + chain_id: number; +} + +/** + * Expectations `signTransaction` verifies an unsigned blob against before signing it — see + * `verifyUnsignedTransaction`. The hub is untrusted in this design (that's the entire point of + * client-side signing), so a caller who knows what it asked for should say so and have the SDK + * check the hub's answer instead of signing it blind. + */ +export interface ExpectedTx { + type: 'RideRequest' | 'RideOffer' | 'RidePay' | 'RideAcceptance' | 'RideCancel' | 'RideRequestCancel' | 'Burn'; + /** The wallet's own address/pk form; `signTransaction` fills this in automatically. */ + from?: string; + /** Pinned CLIENT-side (app config, e.g. 2077) — never sourced from the hub's own `chainInfo`. */ + chainId?: number; + /** RideRequest/RideOffer/RidePay. */ + fare?: bigint; + /** Burn. */ + amount?: bigint; + /** The acceptance/offer/request hash the caller itself passed in. */ + refTxHash?: string; + /** Burn. */ + redemptionRef?: string; +} + +/** + * Result of a passing `verifyUnsignedTransaction` check. + */ +export interface VerifiedTx { + /** + * The referrer the hub injected into this transaction, if any. The hub picks the referrer + * server-side and there is currently no signed-quote flow to pin it client-side, so this + * value CANNOT be verified — it is surfaced only so a caller can display it to the user + * before they sign. Displaying it is the interim mitigation, not a fix; full referrer + * pinning needs the signed-quote flow (a later plan). + */ + referrer: string | null; +} + +/** Reads `arguments.` falling back to `arguments.`, matching `encodeFunctionCall`'s tolerance for either shape. */ +function readArg(argsData: any, snakeKey: string, camelKey: string): unknown { + return argsData?.[snakeKey] ?? argsData?.[camelKey]; +} + +/** + * The reference-hash field each transaction type carries, if any (mirrors the cases in + * `encodeFunctionCall`). Returns `undefined` for types with no single reference hash + * (RideRequest, Burn). + */ +function refHashFromArgs(type: ExpectedTx['type'], argsData: any): string | undefined { + switch (type) { + case 'RideOffer': + return readArg(argsData, 'ride_request_transaction_hash', 'rideRequestTxHash') as string | undefined; + case 'RideAcceptance': + return readArg(argsData, 'ride_offer_transaction_hash', 'rideOfferTxHash') as string | undefined; + case 'RidePay': + return readArg(argsData, 'ride_acceptance_transaction_hash', 'rideAcceptanceTxHash') as string | undefined; + case 'RideCancel': + return readArg(argsData, 'ride_acceptance_transaction_hash', 'rideAcceptanceTxHash') as string | undefined; + case 'RideRequestCancel': + return readArg(argsData, 'ride_request_transaction_hash', 'rideRequestTxHash') as string | undefined; + default: + return undefined; + } +} + +/** + * Verify an unsigned-transaction blob from the hub against what the caller actually asked for, + * before it gets signed. Pure and side-effect-free. + * + * WHY THIS EXISTS: the hub is the untrusted party in this design — the private key never + * leaves the client precisely because the hub is not trusted — yet without this check a + * compromised hub can alter the fare, swap the referrer, or hand back a different chain's id, + * and the SDK would sign whatever it was given. This closes the blind-signing hole for every + * field it's possible to pin client-side. It does NOT close the referrer hole (see + * `VerifiedTx.referrer`). + * + * Any mismatch throws `Error('unsigned tx does not match request: ')` naming the + * offending field. + */ +export function verifyUnsignedTransaction( + unsignedTx: UnsignedTransaction, + expected: ExpectedTx +): VerifiedTx { + const fail = (field: string): never => { + throw new Error(`unsigned tx does not match request: ${field}`); + }; + + if (expected.from !== undefined && stripHexPrefix(unsignedTx.from).toLowerCase() !== stripHexPrefix(expected.from).toLowerCase()) { + fail('from'); + } + + // Presence-only checking would let a compromised hub hand back a different chain's id and + // defeat the exact replay protection chain_id was added to close — so this is a strict + // equality check whenever the caller pinned a chainId, not merely "is chain_id present". + if (expected.chainId !== undefined && Number(unsignedTx.chain_id) !== expected.chainId) { + fail('chain_id'); + } + + const type = (unsignedTx.data?.function_call_type ?? unsignedTx.data?.type) as ExpectedTx['type'] | undefined; + if (type !== expected.type) { + fail('function_call_type'); + } + + const argsData = unsignedTx.data?.arguments ?? unsignedTx.data ?? {}; + + if (expected.fare !== undefined) { + const fareRaw = readArg(argsData, 'fare', 'fare'); + if (fareRaw === undefined || BigInt(fareRaw as string | number | bigint) !== expected.fare) { + fail('fare'); + } + } + + if (expected.amount !== undefined) { + const amountRaw = readArg(argsData, 'amount', 'amount'); + if (amountRaw === undefined || BigInt(amountRaw as string | number | bigint) !== expected.amount) { + fail('amount'); + } + } + + if (expected.refTxHash !== undefined) { + const actualRef = refHashFromArgs(expected.type, argsData); + if (actualRef === undefined || normalizeTxHashForRlp(String(actualRef)) !== normalizeTxHashForRlp(expected.refTxHash)) { + fail('refTxHash'); + } + } + + if (expected.redemptionRef !== undefined) { + const actualRefRaw = readArg(argsData, 'redemption_ref', 'redemptionRef'); + const actualRef = actualRefRaw != null && String(actualRefRaw).length > 0 ? String(actualRefRaw) : ''; + if (actualRef !== expected.redemptionRef) { + fail('redemptionRef'); + } + } + + const referrerRaw = argsData?.referrer; + const referrer = referrerRaw != null && String(referrerRaw).length > 0 ? String(referrerRaw) : null; + return { referrer }; } /** @@ -252,6 +396,9 @@ export class ClutchHubSdk { private publicKey: string; private token: string | null = null; private tokenExpireTime: number = 0; + private chainId: number; + /** Whether the caller actually passed a `chainId` (vs. the 0 default) — see `signTransaction`. */ + private chainIdConfigured: boolean; /** * @param apiUrl Hub API base URL. @@ -259,10 +406,19 @@ export class ClutchHubSdk { * @param privateKey Optional wallet private key, required to obtain JWTs: `generateToken` * demands a signed proof-of-key-ownership challenge. May also be provided later via * {@link setPrivateKey}. Never sent to the API — only used for local signing. + * @param chainId This chain's id (e.g. 2077 for the app's own config), used for the + * chain-bound auth challenge and as the default `expected.chainId` pin in + * {@link signTransaction}'s `verifyUnsignedTransaction` check. Get this from app config, + * never from the hub's own `chainInfo` response — asking the untrusted party what chain + * it is defeats the check chain_id exists to provide. If omitted, `signTransaction` still + * verifies every other `expected` field but skips the chain_id pin (nothing was pinned to + * check against) rather than failing every real transaction against a phantom "chain 0". */ - constructor(apiUrl: string, publicKey: string, privateKey?: string) { + constructor(apiUrl: string, publicKey: string, privateKey?: string, chainId?: number) { this.apiClient = axios.create({ baseURL: apiUrl }); this.publicKey = publicKey; + this.chainId = chainId ?? 0; + this.chainIdConfigured = chainId !== undefined; if (privateKey) { globalPrivateKeys.set(publicKey, privateKey); } @@ -325,11 +481,12 @@ export class ClutchHubSdk { if (!entry) { const pk = this.publicKey; const apiClient = this.apiClient; + const chainId = this.chainId; const client = createHubSubscriptionClient({ url: hubGraphqlWsUrl(base), connectionParams: async () => { try { - await ensureTokenInCacheForPublicKey(pk, apiClient); + await ensureTokenInCacheForPublicKey(pk, apiClient, chainId); } catch { /* public list subscriptions work without JWT */ } @@ -401,11 +558,21 @@ export class ClutchHubSdk { } private async ensureAuth(): Promise { - const entry = await ensureTokenInCacheForPublicKey(this.publicKey, this.apiClient); + const entry = await ensureTokenInCacheForPublicKey(this.publicKey, this.apiClient, this.chainId); this.token = entry.token; this.tokenExpireTime = entry.expireTimeMs; } + /** + * Public wrapper around `ensureAuth`: resolves (fetching if needed) a valid JWT for this + * wallet and returns it as an `Authorization: Bearer ` header ready to attach to a + * hand-rolled request (e.g. an orchestrator REST client that reuses this SDK's auth). + */ + public async getAuthHeaders(): Promise> { + await this.ensureAuth(); + return { ...this.authHeaders }; + } + /** * Fetches an unsigned ride request transaction from the GraphQL API. */ @@ -421,7 +588,7 @@ export class ClutchHubSdk { const query = ` mutation CreateUnsignedRideRequest( $pickupLatitude: Float!, $pickupLongitude: Float!, - $dropoffLatitude: Float!, $dropoffLongitude: Float!, $fare: Int! + $dropoffLatitude: Float!, $dropoffLongitude: Float!, $fare: String! ) { createUnsignedRideRequest( pickupLatitude: $pickupLatitude, @@ -437,7 +604,7 @@ export class ClutchHubSdk { pickupLongitude: pickupLng, dropoffLatitude: dropoffLat, dropoffLongitude: dropoffLng, - fare: args.fare, + fare: args.fare.toString(), }; const result = await this.executeGraphQL<{ createUnsignedRideRequest: UnsignedTransaction @@ -455,7 +622,7 @@ export class ClutchHubSdk { await this.ensureAuth(); const query = ` mutation CreateUnsignedRideOffer( - $rideRequestTransactionHash: String!, $fare: Int! + $rideRequestTransactionHash: String!, $fare: String! ) { createUnsignedRideOffer( rideRequestTransactionHash: $rideRequestTransactionHash, @@ -465,7 +632,7 @@ export class ClutchHubSdk { `; const variables = { rideRequestTransactionHash: args.rideRequestTxHash, - fare: args.fare, + fare: args.fare.toString(), }; const result = await this.executeGraphQL<{ createUnsignedRideOffer: UnsignedTransaction @@ -503,7 +670,7 @@ export class ClutchHubSdk { const query = ` mutation CreateUnsignedRidePay( $rideAcceptanceTransactionHash: String!, - $fare: Int! + $fare: String! ) { createUnsignedRidePay( rideAcceptanceTransactionHash: $rideAcceptanceTransactionHash, @@ -513,7 +680,7 @@ export class ClutchHubSdk { `; const variables = { rideAcceptanceTransactionHash: args.rideAcceptanceTxHash, - fare: args.fare, + fare: args.fare.toString(), }; const result = await this.executeGraphQL<{ createUnsignedRidePay: UnsignedTransaction; @@ -561,22 +728,59 @@ export class ClutchHubSdk { return result.createUnsignedRideRequestCancel; } + /** + * Fetches an unsigned Burn transaction. Burns `amount` CLT from the caller's balance, + * optionally tagged with a treasury `redemptionRef` (hex(keccak256(intent_id))). + */ + public async createUnsignedBurn(args: BurnArgs): Promise { + await this.ensureAuth(); + const query = ` + mutation CreateUnsignedBurn($amount: String!, $redemptionRef: String) { + createUnsignedBurn(amount: $amount, redemptionRef: $redemptionRef) + } + `; + const variables = { + amount: args.amount.toString(), + redemptionRef: args.redemptionRef ?? null, + }; + const result = await this.executeGraphQL<{ + createUnsignedBurn: UnsignedTransaction; + }>(query, variables); + return result.createUnsignedBurn; + } + /** * Signs a transaction and returns the signature and raw RLP-encoded payload. */ public async signTransaction( unsignedTx: UnsignedTransaction, - privateKey: string + privateKey: string, + expected?: ExpectedTx ): Promise { + if (expected) { + // Inject the checks the caller gets "for free": its own address form, and this SDK + // instance's pinned chainId IF the constructor was actually given one — an unconfigured + // chainId means nothing was pinned, so there's nothing to check (as opposed to silently + // enforcing a phantom "chain 0" against every real chain_id). Mismatch throws before + // anything is signed. + verifyUnsignedTransaction(unsignedTx, { + ...expected, + from: expected.from ?? this.publicKey, + chainId: expected.chainId ?? (this.chainIdConfigured ? this.chainId : undefined), + }); + } + // Encode the function call into a nested array for RLP const callDataArray = this.encodeFunctionCall(unsignedTx.data); - // RLP-encode unsigned transaction [from, nonce, data] - // Ensure from field is properly encoded as string (remove 0x prefix for consistency) + // RLP-encode unsigned transaction [from (no 0x), nonce, chain_id, data] — node Plan A + // format. chain_id sits between nonce and data in BOTH the hash preimage and the full + // signed payload below; the node's `calculate_hash` computes this exact 4-item list. const fromForUnsigned = stripHexPrefix(unsignedTx.from); const unsignedPayload = rlp.encode([ fromForUnsigned, unsignedTx.nonce, + unsignedTx.chain_id, callDataArray ]); const hashBytes = keccak_256(unsignedPayload); @@ -587,12 +791,14 @@ export class ClutchHubSdk { const rNo0x = stripHexPrefix(signature.r); const sNo0x = stripHexPrefix(signature.s); - // RLP-encode full signed transaction to match Rust: [from, nonce, r, s, v, hash, data] - // Ensure from field is properly encoded as string (remove 0x prefix for consistency) + // RLP-encode full signed transaction to match Rust: [from, nonce, chain_id, r, s, v, hash, data] + // — chain_id inserted after nonce, same index as the unsigned preimage; everything after + // it shifts by one versus the pre-treasury 7-item wire format. const fromNo0x = stripHexPrefix(unsignedTx.from); const fullPayload = rlp.encode([ fromNo0x, unsignedTx.nonce, + unsignedTx.chain_id, rNo0x, sNo0x, signature.v, @@ -758,9 +964,9 @@ export class ClutchHubSdk { } `; const result = await this.executeGraphQL<{ - listRideRequests: AvailableRideRequest[]; + listRideRequests: (Omit & { fare: string })[]; }>(query, { bounds: bounds ?? null }); - return result.listRideRequests; + return result.listRideRequests.map((r) => ({ ...r, fare: BigInt(r.fare) })); } /** @@ -780,9 +986,9 @@ export class ClutchHubSdk { } `; const result = await this.executeGraphQL<{ - listRideOffers: AvailableRideOffer[]; + listRideOffers: (Omit & { fare: string })[]; }>(query, { rideRequestTxHash }); - return result.listRideOffers; + return result.listRideOffers.map((r) => ({ ...r, fare: BigInt(r.fare) })); } /** @@ -809,12 +1015,12 @@ export class ClutchHubSdk { } `; const result = await this.executeGraphQL<{ - listActiveTrips: AvailableActiveTrip[]; + listActiveTrips: (Omit & { fare: string; farePaid: string })[]; }>(query, { driverAddress: options?.driverAddress ?? null, passengerAddress: options?.passengerAddress ?? null, }); - return result.listActiveTrips; + return result.listActiveTrips.map((r) => ({ ...r, fare: BigInt(r.fare), farePaid: BigInt(r.farePaid) })); } /** @@ -841,12 +1047,12 @@ export class ClutchHubSdk { } `; const result = await this.executeGraphQL<{ - listCompletedTrips: AvailableCompletedTrip[]; + listCompletedTrips: (Omit & { fare: string; farePaid: string })[]; }>(query, { driverAddress: options?.driverAddress ?? null, passengerAddress: options?.passengerAddress ?? null, }); - return result.listCompletedTrips; + return result.listCompletedTrips.map((r) => ({ ...r, fare: BigInt(r.fare), farePaid: BigInt(r.farePaid) })); } /** @@ -865,18 +1071,18 @@ export class ClutchHubSdk { } `; const result = await this.executeGraphQL<{ - listRecentTrips: AvailableRecentTrip[]; + listRecentTrips: (Omit & { fare: string; farePaid: string })[]; }>(query, { driverAddress: options?.driverAddress ?? null, passengerAddress: options?.passengerAddress ?? null, }); - return result.listRecentTrips; + return result.listRecentTrips.map((r) => ({ ...r, fare: BigInt(r.fare), farePaid: BigInt(r.farePaid) })); } /** * Fetches the current account balance for a public key. */ - public async getAccountBalance(publicKey?: string): Promise { + public async getAccountBalance(publicKey?: string): Promise { await this.ensureAuth(); const query = ` query AccountBalance($publicKey: String) { @@ -884,9 +1090,9 @@ export class ClutchHubSdk { } `; const result = await this.executeGraphQL<{ - accountBalance: number; + accountBalance: string; }>(query, { publicKey: publicKey ?? this.publicKey }); - return result.accountBalance; + return BigInt(result.accountBalance); } /** @@ -895,7 +1101,7 @@ export class ClutchHubSdk { */ public subscribeAccountBalance( options: { publicKey?: string } | undefined, - handlers: SubscriptionHandlers + handlers: SubscriptionHandlers ): () => void { const { client, release } = this.acquireGraphqlWsClient(); const query = ` @@ -912,9 +1118,16 @@ export class ClutchHubSdk { next: (res) => { const value = (res.data as { accountBalanceUpdated?: number | string | null | undefined }) ?.accountBalanceUpdated; - const asNumber = typeof value === 'number' ? value : Number(value); - if (Number.isFinite(asNumber)) { - handlers.onData(asNumber); + // BigInt(value) throws on null/undefined/non-integer input rather than yielding NaN + // (Number's failure mode), so the same "silently skip a bad payload" guard needs a + // try/catch instead of Number.isFinite. + if (value == null) { + return; + } + try { + handlers.onData(BigInt(value)); + } catch { + /* malformed balance payload — skip, same as the old Number.isFinite guard */ } }, error: (err) => handlers.onError?.(err as Error), @@ -974,7 +1187,7 @@ export class ClutchHubSdk { const args = [ [pickupLatBits, pickupLngBits], [dropoffLatBits, dropoffLngBits], - fare, + BigInt(fare), referrerForRlp, ]; // Return the array: [tag, arguments] @@ -983,7 +1196,7 @@ export class ClutchHubSdk { case 'RideOffer': { const argsData = data.arguments || data; const rideRequestTxHash = argsData.ride_request_transaction_hash ?? argsData.rideRequestTxHash ?? ''; - const fare = argsData.fare ?? 0; + const fare = BigInt(argsData.fare ?? 0); const referrerRaw = argsData.referrer; const referrerForRlp = referrerRaw != null && String(referrerRaw).length > 0 @@ -1002,7 +1215,7 @@ export class ClutchHubSdk { const argsData = data.arguments || data; const rideAcceptanceTxHash = argsData.ride_acceptance_transaction_hash ?? argsData.rideAcceptanceTxHash ?? ''; - const fare = argsData.fare ?? 0; + const fare = BigInt(argsData.fare ?? 0); const args = [normalizeTxHashForRlp(String(rideAcceptanceTxHash)), fare]; return [4, args]; } @@ -1020,6 +1233,13 @@ export class ClutchHubSdk { const args = [normalizeTxHashForRlp(String(rideRequestTxHash))]; return [8, args]; } + case 'Burn': { + const argsData = data.arguments || data; + const amount = BigInt(argsData.amount ?? 0); + const refRaw = argsData.redemption_ref ?? argsData.redemptionRef; + const refForRlp = refRaw != null && String(refRaw).length > 0 ? String(refRaw) : ''; + return [7, [amount, refForRlp]]; + } default: throw new Error(`Unsupported FunctionCall type: ${type}`); } @@ -1038,4 +1258,20 @@ export class ClutchHubSdk { const low = BigInt(ClutchHubSdk.floatView.getUint32(4, false)); return (high << BigInt(32)) | low; } -} \ No newline at end of file +} + +/** + * Formats CLT base units (micro-USD, at the 1 USD = 1,000,000 CLT peg) as a `$`-prefixed + * decimal string for display — integer math only, never floats, since a float division would + * reintroduce the precision loss bigint amounts exist to avoid. Cents are floored (truncated), + * matching how the treasury peg treats CLT as an integer. + */ +export function formatUsd(microUsd: bigint): string { + const negative = microUsd < 0n; + const abs = negative ? -microUsd : microUsd; + const cents = abs / 10000n; // 1,000,000 microUsd = 1 USD = 100 cents + const dollars = cents / 100n; + const remainderCents = cents % 100n; + const sign = negative ? '-' : ''; + return `${sign}$${dollars.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${remainderCents.toString().padStart(2, '0')}`; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 3a84e8a..be3e5e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,12 +11,13 @@ export interface Coordinates { export interface RideRequestArgs { pickup: Coordinates; dropoff: Coordinates; - fare: number; + /** CLT base units (1 USD = 1,000,000 CLT). bigint because GraphQL/JSON `number` loses precision above 2^53. */ + fare: bigint; } export interface RideOfferArgs { rideRequestTxHash: string; - fare: number; + fare: bigint; } export interface RideAcceptanceArgs { @@ -25,7 +26,7 @@ export interface RideAcceptanceArgs { export interface RidePayArgs { rideAcceptanceTxHash: string; - fare: number; + fare: bigint; } export interface RideCancelArgs { @@ -36,6 +37,26 @@ export interface RideRequestCancelArgs { rideRequestTxHash: string; } +export interface BurnArgs { + /** CLT base units to burn. */ + amount: bigint; + /** hex(keccak256(intent_id)) for treasury redemptions; omit for a plain burn. */ + redemptionRef?: string; +} + +/** + * Genesis-committed consensus parameters (hub `chainInfo` query). Every numeric field is a + * `String` on the wire — `total_supply` is the one value that can exceed 2^53, and one rule + * for every field here is cheaper to remember than a per-field exception. + */ +export interface ChainInfo { + chainId: bigint; + isTestnet: boolean; + txFee: bigint; + totalSupply: bigint; + mintAuthority: string; +} + /** Response from POST /faucet when the Hub API faucet is enabled (test networks). */ export interface FaucetResponse { ok: boolean; @@ -57,7 +78,7 @@ export interface AvailableRideRequest { txHash: string; pickupLocation: Coordinates; dropoffLocation: Coordinates; - fare: number; + fare: bigint; passengerAddress: string; } @@ -65,7 +86,7 @@ export interface AvailableRideRequest { export interface AvailableRideOffer { txHash: string; rideRequestTxHash: string; - fare: number; + fare: bigint; driverAddress: string; } @@ -76,9 +97,9 @@ export interface AvailableActiveTrip { rideRequestTxHash: string; pickupLocation: Coordinates; dropoffLocation: Coordinates; - fare: number; + fare: bigint; /** Amount already paid to the driver (partial payments). */ - farePaid: number; + farePaid: bigint; driverAddress: string; passengerAddress: string; } diff --git a/test_wire_v3.mjs b/test_wire_v3.mjs new file mode 100644 index 0000000..c291da9 --- /dev/null +++ b/test_wire_v3.mjs @@ -0,0 +1,73 @@ +// Run: npm run build && node test_wire_v3.mjs +import { ClutchHubSdk, verifyUnsignedTransaction, formatUsd } from './dist/index.js'; +import assert from 'node:assert'; +import * as rlp from 'rlp'; + +const sdk = new ClutchHubSdk('http://unused', '0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20'); +const unsigned = { + from: '0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20', + nonce: 1, + chain_id: 2077, + data: { function_call_type: 'Burn', arguments: { amount: '5000000', redemption_ref: 'a'.repeat(64) } }, +}; +const signed = await sdk.signTransaction( + unsigned, + '0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509', + { type: 'Burn', amount: 5000000n, redemptionRef: 'a'.repeat(64) }, +); +const decoded = rlp.decode(Buffer.from(signed.rawTransaction.slice(2), 'hex')); +assert.equal(decoded.length, 8, '8-item signed tx'); +assert.equal(BigInt('0x' + Buffer.from(decoded[2]).toString('hex')), 2077n, 'chain_id at index 2'); +// rlp.decode is recursive — decoded[7] is ALREADY the nested [tagBuf, argsArray]. +const [tag] = decoded[7]; +assert.equal(Buffer.from(tag)[0], 7, 'Burn tag 7'); + +// chain_id must be the minimal big-endian encoding of 2077 (0x82 0x08 0x1d), not merely +// round-trip-equal after decode. Byte-compare the RLP-encoded chain_id item directly. +const chainIdEncoded = rlp.encode(2077); +assert.equal(Buffer.from(chainIdEncoded).toString('hex'), '82081d', 'chain_id RLP bytes are minimal big-endian'); + +// Burn call data round-trips its optional redemption_ref, including the empty-string case +// (None/absent encodes as '' — the same convention the referrer fields already use). +const [, burnArgs] = decoded[7]; +const [amountBuf, refBuf] = burnArgs; +assert.equal(BigInt('0x' + Buffer.from(amountBuf).toString('hex')), 5000000n, 'Burn amount round-trips'); +assert.equal(Buffer.from(refBuf).toString('utf8'), 'a'.repeat(64), 'Burn redemption_ref round-trips'); + +const unsignedNoRef = { + from: '0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20', + nonce: 2, + chain_id: 2077, + data: { function_call_type: 'Burn', arguments: { amount: '1000', redemption_ref: '' } }, +}; +const signedNoRef = await sdk.signTransaction( + unsignedNoRef, + '0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509', + { type: 'Burn', amount: 1000n, redemptionRef: '' }, +); +const decodedNoRef = rlp.decode(Buffer.from(signedNoRef.rawTransaction.slice(2), 'hex')); +const [, burnArgsNoRef] = decodedNoRef[7]; +const [, refBufNoRef] = burnArgsNoRef; +assert.equal(Buffer.from(refBufNoRef).length, 0, 'absent redemption_ref round-trips as empty string'); + +// verification catches a tampered fare +assert.throws(() => + verifyUnsignedTransaction( + { ...unsigned, data: { function_call_type: 'Burn', arguments: { amount: '6000000', redemption_ref: 'a'.repeat(64) } } }, + { type: 'Burn', amount: 5000000n }, + ), +); + +// presence-only chain_id checking would defeat the replay protection chain_id exists for — +// a mismatched (but present) chain_id must also throw. +assert.throws(() => + verifyUnsignedTransaction( + { ...unsigned, chain_id: 1 }, + { type: 'Burn', amount: 5000000n, chainId: 2077 }, + ), +); + +assert.equal(formatUsd(5000000n), '$5.00'); +assert.equal(formatUsd(1n), '$0.00'); +assert.equal(formatUsd(123456789n), '$123.45'); +console.log('wire v3 self-check OK'); From 353a5f278bb2192cdfa4dbed9c4d850cd1a06042 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Wed, 29 Jul 2026 02:29:31 +0400 Subject: [PATCH 2/2] fix!: fail closed when verifying an unsigned tx with no pinned chainId signTransaction(.., expected) previously ran every check EXCEPT the chain_id pin when no chainId was configured, on the reasoning that nothing had been pinned. That is the worst available outcome: the caller believes the transaction was validated while the one check that stops a cross-chain replay quietly did not run - the same hole chain_id was added to close. It now throws, naming the constructor argument. Nearly unreachable in practice, since ensureAuth already needs the real chainId for the chain-bound challenge, so an unconfigured SDK cannot get a token at all; this converts that confusing downstream auth failure into a precise message. The self-check now pins the behaviour, and its own SDK construction was the first caller the change caught. BREAKING CHANGE: verifying an unsigned transaction now requires a chainId pinned via the ClutchHubSdk constructor or expected.chainId. Co-Authored-By: Claude Fable 5 --- src/sdk.ts | 24 +++++++++++++++++++----- test_wire_v3.mjs | 22 +++++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/sdk.ts b/src/sdk.ts index 0ce0f94..edddaad 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -759,14 +759,28 @@ export class ClutchHubSdk { ): Promise { if (expected) { // Inject the checks the caller gets "for free": its own address form, and this SDK - // instance's pinned chainId IF the constructor was actually given one — an unconfigured - // chainId means nothing was pinned, so there's nothing to check (as opposed to silently - // enforcing a phantom "chain 0" against every real chain_id). Mismatch throws before - // anything is signed. + // instance's pinned chainId. Mismatch throws before anything is signed. + // + // Fail closed when no chainId is pinned. Verifying everything *except* the chain is the + // worst available outcome: the caller believes the transaction was validated while the + // one check that stops a cross-chain replay quietly did not run. Enforcing a phantom + // "chain 0" would be equally wrong, so demand the pin rather than guess. + // + // Nearly unreachable in practice — `ensureAuth` needs the real chainId for the + // chain-bound challenge, so an unconfigured SDK cannot obtain a token at all. This turns + // that confusing downstream auth failure into a precise message at the right place. + const pinnedChainId = + expected.chainId ?? (this.chainIdConfigured ? this.chainId : undefined); + if (pinnedChainId === undefined) { + throw new Error( + 'cannot verify an unsigned transaction without a pinned chainId: pass chainId to the ' + + 'ClutchHubSdk constructor (from your own app config, never from the hub) or set expected.chainId' + ); + } verifyUnsignedTransaction(unsignedTx, { ...expected, from: expected.from ?? this.publicKey, - chainId: expected.chainId ?? (this.chainIdConfigured ? this.chainId : undefined), + chainId: pinnedChainId, }); } diff --git a/test_wire_v3.mjs b/test_wire_v3.mjs index c291da9..edcfc56 100644 --- a/test_wire_v3.mjs +++ b/test_wire_v3.mjs @@ -3,7 +3,16 @@ import { ClutchHubSdk, verifyUnsignedTransaction, formatUsd } from './dist/index import assert from 'node:assert'; import * as rlp from 'rlp'; -const sdk = new ClutchHubSdk('http://unused', '0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20'); +// chainId is pinned here from "app config", the way a real caller must supply it — never read +// back from the hub, which is the untrusted party the verification exists to defend against. +// Omitting it makes `signTransaction(.., expected)` throw rather than skip the replay check. +const CHAIN_ID = 2077; +const sdk = new ClutchHubSdk( + 'http://unused', + '0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20', + undefined, + CHAIN_ID, +); const unsigned = { from: '0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20', nonce: 1, @@ -67,6 +76,17 @@ assert.throws(() => ), ); +// Verification must FAIL CLOSED when no chain is pinned. Verifying everything except the chain +// would leave the caller believing the tx was checked while the replay guard silently sat out. +const unpinned = new ClutchHubSdk('http://unused', '0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20'); +await assert.rejects( + () => unpinned.signTransaction(unsigned, '0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509', { + type: 'Burn', amount: 5000000n, + }), + /pinned chainId/, + 'signing with expectations but no pinned chainId must throw, not skip the chain check', +); + assert.equal(formatUsd(5000000n), '$5.00'); assert.equal(formatUsd(1n), '$0.00'); assert.equal(formatUsd(123456789n), '$123.45');