Skip to content
markpaper

src/signing/subaccount.ts

v0.2.0 · 3.8 KB

Download file
// Subaccount (bytes32), signer (bytes32) and product verifying-contract encodings.
//
// `sender` is NOT an address: it is 20 bytes of the MASTER account address + up to 12 ASCII bytes of the
// subaccount name, zero-padded on the right. `default` is the subaccount the UI creates on deposit. A
// different name is a DIFFERENT account, and it looks like a perfectly valid empty one
// (`subaccount_info.exists === false`, no error anywhere) - print the name and its bytes32 at startup.

import type { Address, Hex } from './types.js';

const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
const NAME_RE = /^[\x21-\x7e]{0,12}$/;
const BYTES32_RE = /^0x[0-9a-f]{64}$/;

/** Zero address: `link_signer` with it revokes the linked signer; `linked_signer` returns it when none is linked. */
export const ZERO_ADDRESS: Address = `0x${'0'.repeat(40)}`;

/**
 * Lower-cases and validates a 20-byte hex address.
 *
 * @throws TypeError when the value is not `0x` + 40 hex characters.
 */
export function normalizeAddress(address: string): Address {
  if (typeof address !== 'string') throw new TypeError('address must be a string');
  const a = address.trim().toLowerCase();
  if (!ADDRESS_RE.test(a)) throw new TypeError(`bad address "${address}"`);
  return a as Address;
}

/** True for a well-formed subaccount name: 0-12 printable ASCII characters (no spaces). */
export function isValidSubaccountName(name: string): boolean {
  return typeof name === 'string' && NAME_RE.test(name);
}

/**
 * bytes32 subaccount = 20-byte address + up to 12 ASCII bytes of the name, zero-padded. `'default'` is the
 * UI subaccount. The documentation vector matched this encoding (checked 2026-07-24).
 *
 * @throws TypeError on a malformed address or name.
 */
export function subaccountBytes32(address: string, name = 'default'): Hex {
  const addr = normalizeAddress(address);
  if (!isValidSubaccountName(name)) throw new TypeError(`bad subaccount name "${name}" (1-12 printable ASCII)`);
  let nameHex = '';
  for (const ch of name) nameHex += ch.charCodeAt(0).toString(16).padStart(2, '0');
  return `${addr}${nameHex.padEnd(24, '0')}` as Hex;
}

/** bytes32 `signer` field of `LinkSigner`: 20-byte address + 12 zero bytes (only the address is significant). */
export function signerBytes32(address: string): Hex {
  return subaccountBytes32(address, '');
}

/** Decodes a bytes32 subaccount back into `{ address, name }` (name = ASCII bytes up to the first zero). */
export function parseSubaccountBytes32(value: string): { address: Address; name: string } {
  if (typeof value !== 'string') throw new TypeError('subaccount must be a string');
  const v = value.trim().toLowerCase();
  if (!BYTES32_RE.test(v)) throw new TypeError(`bad subaccount bytes32 "${value}"`);
  const address = v.slice(0, 42) as Address;
  let name = '';
  for (let i = 42; i < 66; i += 2) {
    const byte = Number.parseInt(v.slice(i, i + 2), 16);
    if (byte === 0) break;
    name += String.fromCharCode(byte);
  }
  return { address, name };
}

/**
 * Verifying contract of `place_order`: the product id as a 20-byte address with leading zeros
 * (product 18 -> `0x` + 36 zeros + `12`). A signature under another product id does not verify.
 *
 * @throws RangeError when the id is not a non-negative safe integer.
 */
export function productVerifyingContract(productId: number): Address {
  if (!Number.isSafeInteger(productId) || productId < 0) throw new RangeError(`bad productId ${String(productId)}`);
  return `0x${productId.toString(16).padStart(40, '0')}` as Address;
}

/** True for `0x` + 64 hex characters (order digests, bytes32 fields). */
export function isBytes32(value: unknown): value is Hex {
  return typeof value === 'string' && BYTES32_RE.test(value.toLowerCase());
}

/** True when the address is the zero address (no linked signer / revoke). */
export function isZeroAddress(address: string): boolean {
  return normalizeAddress(address) === ZERO_ADDRESS;
}
All files