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>
125 lines
9.2 KiB
JavaScript
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; });
|