deviant/scripts/correspondent-redeem-e2e.mjs
walerikus 1926b8b8e2 feat(cross-chain): correspondent REDEEM leg (BCH → origin, single-chain)
Closes the round-trip. Burn the IOU on BCH via UnifiedUsdtReserve.withdraw(chain)
(drops reserve[chain], require reserve>=amount — draining to 0 is by design) → the
operator releases the real USDC/USDT from the source vault back to the user's own
address. Single-chain; the reserve-depleted case (external Rabby-style bridge) stays
a later session.

Service: evm/tron.release() (operator-gated vault.release); server /redeem/quote +
/redeem/confirm (verifies the covenant-enforced reserve drop, idempotent, then
releases); state.redeems; bch.controllerInfo. UI: connect/correspondent-burn.ts
builds+signs the withdraw() burn in-browser with the user's own BCH key
(self-custodial; bundles UnifiedUsdtReserve.json); CrossChainDeposit gains a
Deposit/Withdraw toggle (copy: mint=lock / release=burn, never bridge).

Validated 2/2 on real Sepolia + Nile (correspondent-redeem-e2e.mjs): USDC 110->115,
USDT 10978->10983, reserve->0, release txs on-chain. tsc clean; both UI directions
render-verified in the running app. Snapshot 0200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:44:26 +02:00

125 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
/**
* correspondent-redeem-e2e.mjs — the REDEEM leg (BCH → origin chain), single-chain,
* through the correspondent service. Closes the round-trip started by the deposit e2e.
*
* 1. POST /redeem/quote {ledger, amount, sourceRecipient} → controller/category + baseline
* 2. USER burns the IOU on BCH: UnifiedUsdtReserve.withdraw(chainIndex, amount) drops
* reserve[chain] (require reserve>=amount — the single-chain boundary) and absorbs the
* IOU back into the controller. Self-custodial: the user signs their own IOU input.
* 3. POST /redeem/confirm {redeemId, bchTxHash} → the operator verifies the reserve drop
* and releases the real asset from the source vault to the user's source address.
* 4. assert the source-chain balance is restored (USDC 110→115, USDT 10978→10983).
*
* Prereqs: the service running + a prior deposit (so the user holds the IOU and the
* vault holds the lock). Run: node --import tsx scripts/correspondent-redeem-e2e.mjs --yes
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { JsonRpcProvider, Contract as EthContract, formatUnits } from 'ethers';
import { TronWeb } from 'tronweb';
const ROOT = join(import.meta.dirname, '..');
const API = process.env.CORR_API || 'http://localhost:8788';
const ART = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/UnifiedUsdtReserve.json'), 'utf8'));
const AMOUNT = process.env.CORR_AMOUNT || '5.00';
const DUST = 1000n, MF = 2500n;
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 binToHex = (b) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('');
const txidLE = (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 u64le = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; };
const buildLedger = (c) => { const o = new Uint8Array(32); for (let i = 0; i < 4; i++) o.set(u64le(c[i]), i * 8); return binToHex(o); };
function parseLedger(hex) { const b = hexToBin(hex); const c = []; for (let i = 0; i < 4; i++) { const dv = new DataView(b.buffer, i * 8, 8); c.push(dv.getBigUint64(0, true)); } return c; }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const divider = (c = '=') => console.log(c.repeat(80));
const step = (t) => { console.log(''); divider(); console.log(t); divider(); };
const txidOf = (s) => (typeof s === 'string' ? s : s?.txid ?? s?.txId);
function pickBch(u, need) { const p = []; let t = 0n; for (const x of u) { p.push(x); t += BigInt(x.satoshis); if (t >= need) break; } return { p, t }; }
async function api(path, body) {
const r = await fetch(`${API}${path}`, body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : {});
const j = await r.json();
if (!r.ok) throw new Error(`${path} -> ${r.status}: ${j.error || JSON.stringify(j)}`);
return j;
}
async function main() {
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes to broadcast on chipnet + Sepolia/Nile.'); return; }
const ALICE = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-b.json'), 'utf8')).wif);
const provider = new ElectrumNetworkProvider('chipnet');
const sigA = new SignatureTemplate(ALICE.privateKey);
const ethKey = JSON.parse(readFileSync(join(ROOT, '.sepolia-keys.json'), 'utf8'));
const tronKey = JSON.parse(readFileSync(join(ROOT, '.tron-nile-keys.json'), 'utf8'));
const ftUtxos = async (cat) => (await provider.getUtxos(ALICE.cashaddr)).filter((x) => x.token?.category === cat && !x.token?.nft);
async function pickBchFor(need) { const u = (await provider.getUtxos(ALICE.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis)); const { p, t } = pickBch(u, need); if (t < need) throw new Error(`user low BCH ${t}<${need}`); return { p, t }; }
// source-chain balance readers
const ethProvider = new JsonRpcProvider(process.env.SEPOLIA_RPC || 'https://ethereum-sepolia-rpc.publicnode.com');
const usdc = new EthContract('0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', ['function balanceOf(address) view returns (uint256)'], ethProvider);
const tronWeb = new TronWeb({ fullHost: process.env.TRON_NILE_RPC || 'https://nile.trongrid.io', privateKey: tronKey.privateKey });
const TRC20_BAL_ABI = [{ constant: true, inputs: [{ name: 'a', type: 'address' }], name: 'balanceOf', outputs: [{ type: 'uint256' }], stateMutability: 'view', type: 'function' }];
const usdtTron = await tronWeb.contract(TRC20_BAL_ABI, 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf');
const usdcBal = async () => BigInt(await usdc.balanceOf(ethKey.address));
const usdtBal = async () => BigInt(await usdtTron.balanceOf(tronKey.address).call());
const targets = [
{ ledger: 'USDC (Ethereum)', unified: 'USDC', chainIndex: 1, sourceRecipient: ethKey.address, read: usdcBal, dp: 6, label: 'Sepolia USDC' },
{ ledger: 'USDT (Tron)', unified: 'USDT', chainIndex: 0, sourceRecipient: tronKey.address, read: usdtBal, dp: 6, label: 'Nile USDT' },
];
const results = [];
for (const T of targets) {
step(`REDEEM ${AMOUNT} ${T.ledger} → release on ${T.label} to ${T.sourceRecipient}`);
const before = await T.read();
console.log(` source balance before: ${formatUnits(before, T.dp)}`);
// 1. quote
const q = await api('/redeem/quote', { ledger: T.ledger, amount: AMOUNT, sourceRecipient: T.sourceRecipient });
const amount = BigInt(q.amountAtomic);
console.log(` redeemId ${q.redeemId} · reserve[${T.label.split(' ')[1] || ''}] before ${q.reserveBefore} · controller ${q.controller.slice(0, 24)}…`);
// 2. USER burns the IOU on BCH via withdraw(chainIndex, amount)
const contract = new Contract(ART, [txidLE(q.category), hexToBin(q.oraclePk)], { provider });
const res = (await provider.getUtxos(contract.tokenAddress)).find((x) => x.token?.nft?.capability === 'mutable' && x.token?.category === q.category);
if (!res) throw new Error('controller UTXO missing');
const led = parseLedger(res.token.nft.commitment);
const after = [...led]; after[T.chainIndex] -= amount; const newLedger = buildLedger(after);
const iou = (await ftUtxos(q.category)).sort((a, b) => Number(BigInt(b.token.amount) - BigInt(a.token.amount)));
const burn = []; let acc = 0n; for (const x of iou) { burn.push(x); acc += BigInt(x.token.amount); if (acc >= amount) break; }
if (acc < amount) throw new Error(`user holds ${acc} < ${amount} ${T.unified}-IOU (deposit first)`);
const rem = acc - amount;
const { p, t } = await pickBchFor(DUST + MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(res, contract.unlock.withdraw(BigInt(T.chainIndex), amount));
for (const x of burn) tb.addInput(x, sigA.unlockP2PKH());
for (const x of p) tb.addInput(x, sigA.unlockP2PKH());
tb.addOutput({ to: contract.tokenAddress, amount: BigInt(res.satoshis), token: { amount: BigInt(res.token.amount) + amount, category: q.category, nft: { capability: 'mutable', commitment: newLedger } } }); // controller absorbs the burned IOU
if (rem > 0n) tb.addOutput({ to: ALICE.tokenaddr, amount: DUST, token: { amount: rem, category: q.category } });
tb.addOutput({ to: ALICE.cashaddr, amount: t - (rem > 0n ? DUST : 0n) - MF }); // controller sats cancel (in==out); fee from user BCH
const bchTxHash = txidOf(await tb.send());
console.log(` burned on BCH: ${bchTxHash} — reserve[${T.chainIndex}] ${led[T.chainIndex]}→${after[T.chainIndex]}`);
await sleep(9000);
// 3. confirm → operator releases on the source chain
const c = await api('/redeem/confirm', { redeemId: q.redeemId, bchTxHash });
console.log(` released on ${T.label}: ${c.sourceTx}`);
await sleep(T.unified === 'USDT' ? 8000 : 4000);
// 4. verify source balance restored
const afterBal = await T.read();
console.log(` source balance after: ${formatUnits(afterBal, T.dp)} (Δ +${formatUnits(afterBal - before, T.dp)})`);
results.push([`${T.label}: burn on BCH → release ${AMOUNT} back on source (Δ == +${AMOUNT})`, afterBal - before === amount]);
}
step('RESULT — single-chain redeem (BCH → origin), through the correspondent service');
let ok = true; for (const [l, p] of results) { console.log(` ${p ? 'PASS' : 'FAIL'} ${l}`); if (!p) ok = false; }
divider();
console.log(ok
? `REDEEM VALIDATED end-to-end: the user burned the IOU on BCH (UnifiedUsdtReserve.withdraw dropped\nreserve[chain], covenant-enforced require reserve>=amount) → the correspondent operator released the\nreal USDC/USDT from the source vault back to the user's own address. The round-trip closes: deposit\n(lock→mint) then redeem (burn→release), mint==lock throughout, single-chain. The reserve-depleted\ncase (route the shortfall via an external Rabby-style bridge) is the later B session.`
: 'SOME CHECKS FAILED.');
if (!ok) process.exitCode = 1;
}
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });