#!/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; });