Skip to content
markpaper

src/transport/contracts.ts

v0.2.0 · 3.4 KB

Download file
// 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,
  };
}
All files