src/transport/contracts.ts
v0.2.0 · 3.4 KB
// Network preflight: the `contracts` query gives `chain_id` and `endpoint_addr`. Before the first execute
// the chain id must equal the one the process signs with; a mismatch is a refusal to start, not a
// warning. Signing with a wrong chain id would merely fail, but a gateway URL pointing at the OTHER
// network with a matching-looking config could execute testnet-intended orders on mainnet.
import { normalizeAddress } from '../signing/subaccount.js';
import type { Address } from '../signing/types.js';
import { NadoNetworkMismatchError } from './errors.js';
import type { QueryRequester } from './types.js';
/** Verified identity of the gateway. */
export interface VerifiedContracts {
readonly chainId: number;
/** Lower-case `endpoint_addr`: `verifyingContract` for every execute except `place_order`. */
readonly endpointAddr: Address;
}
/**
* Parses the `data` of `{type:'contracts'}` fail-closed.
*
* @throws Error when `chain_id` is not a positive integer or `endpoint_addr` is not an address.
*/
export function parseContracts(data: unknown): VerifiedContracts {
const raw = (data ?? {}) as { chain_id?: unknown; endpoint_addr?: unknown };
const chainId = typeof raw.chain_id === 'string' ? Number(raw.chain_id) : raw.chain_id;
if (typeof chainId !== 'number' || !Number.isSafeInteger(chainId) || chainId <= 0) {
throw new Error(`contracts query returned an unusable chain_id (${String(raw.chain_id)})`);
}
let endpointAddr: Address;
try {
endpointAddr = normalizeAddress(String(raw.endpoint_addr ?? ''));
} catch {
throw new Error('contracts query returned an unusable endpoint_addr');
}
return { chainId, endpointAddr };
}
/**
* Runs the `contracts` query once and checks the chain id.
*
* @throws NadoNetworkMismatchError when the gateway reports a different chain id.
*/
export async function verifyNetwork(
query: QueryRequester,
expectedChainId: number,
opts: { url?: string } = {},
): Promise<VerifiedContracts> {
const verified = parseContracts(await query({ type: 'contracts' }, { label: 'contracts' }));
if (verified.chainId !== expectedChainId) {
throw new NadoNetworkMismatchError({ expectedChainId, reportedChainId: verified.chainId, url: opts.url });
}
return verified;
}
/** A cached, single-flight network verifier. */
export interface NetworkVerifier {
/** Resolves with the verified identity; every execute should await it first. Cached after the first success. */
get(): Promise<VerifiedContracts>;
/** The identity when already verified, else `null`. */
peek(): VerifiedContracts | null;
}
/**
* Verifies the gateway identity once per process (single-flight on the promise). Until it resolves no
* execute may be signed; a failure (network or mismatch) is NOT cached, so the next call retries.
*/
export function createNetworkVerifier(
query: QueryRequester,
expectedChainId: number,
opts: { url?: string } = {},
): NetworkVerifier {
let verified: VerifiedContracts | null = null;
let inflight: Promise<VerifiedContracts> | null = null;
return {
get: () => {
if (verified) return Promise.resolve(verified);
if (inflight) return inflight;
inflight = verifyNetwork(query, expectedChainId, opts)
.then((v) => {
verified = v;
return v;
})
.finally(() => {
inflight = null;
});
return inflight;
},
peek: () => verified,
};
}