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>
335 lines
16 KiB
JavaScript
335 lines
16 KiB
JavaScript
#!/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));
|
||
});
|
||
}
|