deviant/scripts/asm-sequencer/server.mjs
Local Dev a014f6636f feat(asm-sequencer): settings-UI toggle in both surfaces (wallet committed; Potidaea drafted)
Beta wallet (shells/app/src/screens/SettingsScreen.tsx)
  New "ASM sequencer relay" group between Network and Token definitions.
  URL input + live reachability probe (useEffect fires on endpoint change,
  shows checking → Reachable / Unreachable). Persists via localStorage key
  asm-sequencer.endpoint through the sequencerRelay module. Default empty =
  trustless tier-4 retry stays in charge.

Server (scripts/asm-sequencer/server.mjs)
  Permissive CORS (Access-Control-Allow-Origin: *, methods GET/POST/OPTIONS,
  content-type header, 10 min max-age) + explicit OPTIONS preflight handler.
  Production deploys tighten this at the reverse proxy; the dev daemon needs
  CORS so any wallet/dApp origin can probe /health and POST /claim.

Potidaea (design/potidaea/{app.html, src/main.ts})
  Drafted locally but NOT committed — those files carry the DEA asset-side
  session's uncommitted network-toggle work on the same lines. My wiring
  (wireSequencerSettings + Section 4 render in paintWalletMenu) will rebase
  cleanly once that session commits. Browser-verified against the live
  chipnet daemon: ✓ reachable chip, saved to LS, menu stays open while
  typing, ⚠ unreachable chip for 127.0.0.1:9999.

Snapshot 0280 documents the arc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-02 20:40:59 +02:00

335 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* ASM sequencer service (tier-2 from Parameters/ASM_Native/ASM-single-asset-throughput.md §2a).
*
* Trust model: **coordinator-funded relay.**
* - The service holds a chipnet coordinator WIF (env COORD, default wallet D).
* - Users POST /claim with `{ ticker, amountUnits, recipientCashaddr }` and get their
* backed asset delivered. The coordinator pays the BCH backing + miner fee; the covenant
* enforces the swap math either way.
* - The relay CAN delay or reorder (liveness role). It CANNOT mint, forge, or steal — the
* covenant's claim() authorizes only the tokenised backing at rate × amount.
* - A production build would flip to user-funded via SIGHASH_ANYONECANPAY on the payer input,
* so the coordinator only advances the reservoir — that's the next iteration.
*
* Turns N simultaneous arrivals into an ordered chain (§2a proved 5/5 on chipnet; §4a proved
* 9,222 sequential swaps on a single reservoir at 68.5/sec). Naïve parallel is 1/N; this is N/N.
*
* Endpoints:
* GET /health → snapshot of tips + queue state (no mutex, cheap)
* POST /claim → body {ticker, amountUnits, recipientCashaddr}; returns {txid, order}
* coordinator-funded path — the relay pays the BCH backing
* GET /tip → user-funded path: returns the reservoir tip + token metadata the
* client needs to build its own claim tx locally
* POST /submit → user-funded path: body {signedTxHex, tipTxid, tipVout, amountUnits};
* daemon validates tip, broadcasts, advances chain. Returns 409 stale
* with currentTip if the chain advanced between /tip and /submit.
* POST /admin/reload-tip → force re-fetch tips from node (recovery from external reservoir advance)
*
* Env:
* PORT=8710 HTTP port
* COORD=h chipnet wallet handle for the coordinator (loads .chipnet-keys-<handle>.json)
* TOKEN=asm2 token state file basename (loads ChipnetTests/.chipnet-<name>-token.json)
* MAX_CHAIN_DEPTH=200 fail-fast when unconfirmed-ancestors get near BCH mempool's limit
*
* Chipnet only. Not a production daemon — no auth, no rate limits, no persistence.
* Restart resets in-memory chain state; first request after restart re-fetches from the node.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createServer } from 'node:http';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { decodeCashAddress, encodeCashAddress } from '@bitauth/libauth';
import { SequencerState } from './lib/state.mjs';
const ROOT = join(import.meta.dirname, '..', '..');
const PORT = Number(process.env.PORT ?? '8710');
const COORD_HANDLE = (process.env.COORD ?? 'h').toLowerCase();
const TOKEN_NAME = (process.env.TOKEN ?? 'asm2').toLowerCase();
const MAX_CHAIN_DEPTH = Number(process.env.MAX_CHAIN_DEPTH ?? '200');
const ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/DeviantOpenMintFT.json'), 'utf8'));
const TOK = JSON.parse(readFileSync(join(ROOT, 'ChipnetTests', `.chipnet-${TOKEN_NAME}-token.json`), 'utf8'));
const KEY_PATH = join(ROOT, COORD_HANDLE === 'a' ? '.chipnet-keys.json' : `.chipnet-keys-${COORD_HANDLE}.json`);
const COORD_WIF = JSON.parse(readFileSync(KEY_PATH, 'utf8')).wif;
const MINER = 3000n;
const RATE = BigInt(TOK.rateSats);
const FEE_BPS = BigInt(TOK.founderFeeBps);
const DUST = 546n;
const CONTRACT_DUST = 1000n; // reservoir sat value we replicate onto the chain
const hexToBin = (h) => { const o = new Uint8Array(h.length / 2); for (let i = 0; i < o.length; i++) o[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return o; };
const rev = (h) => { const d = hexToBin(h); const o = new Uint8Array(d.length); for (let i = 0; i < d.length; i++) o[i] = d[d.length - 1 - i]; return o; };
const p2pkhLB = (pkh) => { const o = new Uint8Array(25); o[0] = 0x76; o[1] = 0xa9; o[2] = 0x14; o.set(pkh, 3); o[23] = 0x88; o[24] = 0xac; return o; };
function pkhFromCashaddr(addr) {
const d = decodeCashAddress(addr);
if (typeof d === 'string') throw new Error(`bad cashaddr: ${addr}`);
return d.payload;
}
/** Convert a P2PKH cashaddr → the CashToken-aware form (`p2pkhWithTokens`).
* cashscript rejects `addOutput({to: <p2pkh addr>, token: {...}})` — the
* recipient must be token-aware. We accept either shape from the client and
* encode the token-aware version here. */
function tokenaddrFromCashaddr(addr) {
const d = decodeCashAddress(addr);
if (typeof d === 'string') throw new Error(`bad cashaddr: ${addr}`);
if (d.type === 'p2pkhWithTokens' || d.type === 'p2shWithTokens') return addr;
const tokenType = d.type === 'p2pkh' ? 'p2pkhWithTokens'
: d.type === 'p2sh' || d.type === 'p2sh20' || d.type === 'p2sh32' ? 'p2shWithTokens'
: null;
if (!tokenType) throw new Error(`unsupported cashaddr type: ${d.type}`);
return encodeCashAddress({ prefix: d.prefix, type: tokenType, payload: d.payload }).address;
}
// ── one-time setup: connect provider + contract + coordinator wallet ────────
const provider = new ElectrumNetworkProvider('chipnet');
const contract = new Contract(
ARTIFACT,
[rev(TOK.categoryId), RATE, FEE_BPS, hexToBin(TOK.founderLockingBytecodeHex)],
{ provider },
);
const coord = await TestNetWallet.fromWIF(COORD_WIF);
const coordSig = new SignatureTemplate(coord.privateKey);
console.log(`[asm-seq] coordinator ${COORD_HANDLE.toUpperCase()} ${coord.cashaddr}`);
console.log(`[asm-seq] token ${TOK.name} category ${TOK.categoryId.slice(0, 16)}…`);
console.log(`[asm-seq] contract token-addr ${contract.tokenAddress}`);
async function fetchInitialState() {
const resUtxos = await provider.getUtxos(contract.tokenAddress);
const reservoir = resUtxos.find((x) =>
x.token?.nft?.capability === 'minting'
&& x.token?.category === TOK.categoryId
&& (x.token?.amount ?? 0n) > 0n,
);
if (!reservoir) throw new Error('reservoir UTXO not found on-chain');
const bchUtxos = await provider.getUtxos(coord.cashaddr);
const coordUtxo = bchUtxos.filter((x) => !x.token).sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis)))[0];
if (!coordUtxo) throw new Error('coordinator has no BCH UTXOs');
return { reservoir, coordUtxo };
}
const initial = await fetchInitialState();
const state = new SequencerState(TOK, initial.coordUtxo, initial.reservoir);
console.log(`[asm-seq] initial reservoir ${initial.reservoir.txid.slice(0, 12)}…:${initial.reservoir.vout} FT ${initial.reservoir.token.amount}`);
console.log(`[asm-seq] initial coord BCH ${initial.coordUtxo.txid.slice(0, 12)}…:${initial.coordUtxo.vout} sats ${initial.coordUtxo.satoshis}`);
// ── the tx builder (single source of truth for the chain step) ─────────────
async function buildAndBroadcastClaim({ amountUnits, recipientCashaddr }) {
const gross = amountUnits * RATE;
const fee = (gross * FEE_BPS) / 10_000n;
const change = BigInt(state.coordUtxo.satoshis) - gross - fee - MINER;
if (change < DUST) throw new Error(`coordinator out of BCH: change ${change} < ${DUST}`);
if (state.chainDepth >= MAX_CHAIN_DEPTH) {
throw new Error(`chain depth ${state.chainDepth} >= max ${MAX_CHAIN_DEPTH} — wait for block confirmation`);
}
const recipLB = p2pkhLB(pkhFromCashaddr(recipientCashaddr));
const remaining = state.reservoir.token.amount - amountUnits;
if (remaining < 0n) throw new Error(`insufficient FT supply: reservoir has ${state.reservoir.token.amount}, requested ${amountUnits}`);
const tb = new TransactionBuilder({ provider });
tb.addInput(state.reservoir, contract.unlock.claim(amountUnits, recipLB));
tb.addInput(state.coordUtxo, coordSig.unlockP2PKH());
tb.addOutput({
to: contract.tokenAddress,
amount: CONTRACT_DUST,
token: {
amount: remaining,
category: TOK.categoryId,
nft: { capability: 'minting', commitment: state.reservoir.token.nft?.commitment ?? '' },
},
});
tb.addOutput({ to: tokenaddrFromCashaddr(recipientCashaddr), amount: gross, token: { amount: amountUnits, category: TOK.categoryId } });
if (fee > 0n) tb.addOutput({ to: hexToBin(TOK.founderLockingBytecodeHex), amount: fee });
tb.addOutput({ to: coord.cashaddr, amount: change });
const sent = await tb.send();
const txid = typeof sent === 'string' ? sent : (sent?.txid ?? sent?.txId);
if (!txid) throw new Error('broadcast returned no txid');
const newReservoir = {
txid,
vout: 0,
satoshis: CONTRACT_DUST,
token: {
amount: remaining,
category: TOK.categoryId,
nft: { capability: 'minting', commitment: state.reservoir.token.nft?.commitment ?? '' },
},
};
// Coordinator change: vout 3 when fee > 0, vout 2 when fee == 0 (no founder output).
const changeVout = fee > 0n ? 3 : 2;
const newCoordUtxo = { txid, vout: changeVout, satoshis: change };
return { txid, newReservoir, newCoordUtxo, gross, fee };
}
// ── HTTP surface ────────────────────────────────────────────────────────────
async function readBody(req) {
const chunks = [];
for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks).toString('utf8');
return raw ? JSON.parse(raw) : {};
}
// Permissive CORS for a dev relay — any wallet/dApp origin can probe + claim.
// A production deploy behind nginx/caddy can tighten this via the reverse
// proxy (Access-Control-Allow-Origin) rather than touching the server.
const CORS_HEADERS = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '600',
};
function send(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data), ...CORS_HEADERS });
res.end(data);
}
const server = createServer(async (req, res) => {
// CORS preflight — Chrome fires OPTIONS before any non-simple POST.
if (req.method === 'OPTIONS') {
res.writeHead(204, CORS_HEADERS);
res.end();
return;
}
try {
if (req.method === 'GET' && req.url === '/health') {
send(res, 200, { ok: true, ...state.snapshot() });
return;
}
if (req.method === 'GET' && req.url.startsWith('/tip')) {
// User-funded path: the client fetches the current chain tip, builds its own
// tx against it locally, signs its own BCH input, and posts the signed hex to
// /submit. The covenant input needs no sig (claim() unlock = amount + recipLB).
send(res, 200, {
ok: true,
category: TOK.categoryId,
ticker: TOK.name,
rateSats: String(RATE),
founderFeeBps: String(FEE_BPS),
founderLockingBytecodeHex: TOK.founderLockingBytecodeHex,
contractTokenAddress: contract.tokenAddress,
reservoir: {
txid: state.reservoir.txid,
vout: state.reservoir.vout,
satoshis: String(state.reservoir.satoshis),
token: {
amount: String(state.reservoir.token.amount),
category: TOK.categoryId,
nft: { capability: 'minting', commitment: state.reservoir.token.nft?.commitment ?? '' },
},
},
chainDepth: state.chainDepth,
});
return;
}
if (req.method === 'POST' && req.url === '/submit') {
// User-funded submit — the daemon only orders + broadcasts, holds no funds.
// Body: {signedTxHex, tipTxid, tipVout, amountUnits}.
const body = await readBody(req);
const { signedTxHex, tipTxid, tipVout, amountUnits } = body;
if (!signedTxHex || !tipTxid || tipVout == null || !amountUnits) {
send(res, 400, { ok: false, error: 'missing signedTxHex / tipTxid / tipVout / amountUnits' });
return;
}
const amt = BigInt(amountUnits);
const result = await state.withLock(async () => {
const before = { reservoir: state.reservoir.txid, vout: state.reservoir.vout, depth: state.chainDepth };
if (state.reservoir.txid !== tipTxid || state.reservoir.vout !== tipVout) {
return {
ok: false, order: null, before,
error: 'stale tip — rebuild against the current tip',
currentTip: { txid: state.reservoir.txid, vout: state.reservoir.vout },
};
}
if (state.chainDepth >= MAX_CHAIN_DEPTH) {
return { ok: false, order: null, before, error: `chain depth ${state.chainDepth} >= max ${MAX_CHAIN_DEPTH}` };
}
if (amt > state.reservoir.token.amount) {
return { ok: false, order: null, before, error: `insufficient FT supply: reservoir has ${state.reservoir.token.amount}` };
}
try {
const txid = await provider.sendRawTransaction(signedTxHex);
const remaining = state.reservoir.token.amount - amt;
const newReservoir = {
txid,
vout: 0,
satoshis: CONTRACT_DUST,
token: {
amount: remaining,
category: TOK.categoryId,
nft: { capability: 'minting', commitment: state.reservoir.token.nft?.commitment ?? '' },
},
};
state.advance({ newReservoir, newCoordUtxo: state.coordUtxo }); // user-funded: coord utxo unchanged
return { ok: true, order: state.totalServed, before, txid };
} catch (e) {
state.markError(e);
return { ok: false, order: null, before, error: e?.message ?? String(e) };
}
});
send(res, result.ok ? 200 : (result.error === 'stale tip — rebuild against the current tip' ? 409 : 500), result);
return;
}
if (req.method === 'POST' && req.url === '/admin/reload-tip') {
const fresh = await fetchInitialState();
state.reservoir = fresh.reservoir;
state.coordUtxo = fresh.coordUtxo;
state.chainDepth = 0;
state.chainStartedAt = Date.now();
state.lastError = null;
send(res, 200, { ok: true, ...state.snapshot() });
return;
}
if (req.method === 'POST' && req.url === '/claim') {
const body = await readBody(req);
const { ticker, amountUnits, recipientCashaddr } = body;
if (ticker && ticker !== TOK.name) {
send(res, 400, { ok: false, error: `this sequencer serves ${TOK.name}, not ${ticker}` });
return;
}
if (!amountUnits || !recipientCashaddr) {
send(res, 400, { ok: false, error: 'missing amountUnits or recipientCashaddr' });
return;
}
const amt = BigInt(amountUnits);
const result = await state.withLock(async () => {
const before = { reservoir: state.reservoir.txid, vout: state.reservoir.vout, depth: state.chainDepth };
try {
const r = await buildAndBroadcastClaim({ amountUnits: amt, recipientCashaddr });
state.advance({ newReservoir: r.newReservoir, newCoordUtxo: r.newCoordUtxo });
return { ok: true, order: state.totalServed, before, txid: r.txid, gross: String(r.gross), fee: String(r.fee) };
} catch (e) {
state.markError(e);
return { ok: false, order: null, before, error: e?.message ?? String(e) };
}
});
send(res, result.ok ? 200 : 500, result);
return;
}
send(res, 404, { ok: false, error: 'not found' });
} catch (e) {
send(res, 500, { ok: false, error: e?.message ?? String(e) });
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[asm-seq] listening on http://127.0.0.1:${PORT}`);
console.log(`[asm-seq] GET /health`);
console.log(`[asm-seq] POST /claim {ticker, amountUnits, recipientCashaddr}`);
console.log(`[asm-seq] POST /admin/reload-tip`);
});
// Clean shutdown
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
console.log(`[asm-seq] ${sig} — served ${state.totalServed} claims across chain depth ${state.chainDepth}`);
server.close(() => process.exit(0));
});
}