Build B Flavor 2 (the credit/debit lock-and-mint), all 7 checks PASS on real coins across both chains: - B3 lock+mint: Alice locks 5 real USDT in TronUSDTVault; bridge oracle attests; reused DeviantWrapMintIOU mints exactly 5,000,000 tUSDt (covenant-enforced 1:1) - B5a over-mint: replaying the attestation is rejected (outpoint-bound) - B4 redeem: return tUSDt on BCH -> operator releases the USDT on Tron - B5b reconcile: sum(tUSDt circulating) == USDT locked at both states (5:5, 0:0) Upgrades chipnet-btc-iou-demo (simulated lock) to a REAL Tron lock with on-chain cross-chain reconciliation. Vault uses balance-delta moves (Tether gotcha). v1 witness = single oracle/operator (trust-minimized); B6 = bonded M-of-N. contracts/tron/TronUSDTVault.sol scripts/tron-bch-lock-mint.mjs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
15 KiB
JavaScript
219 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* tron-bch-lock-mint.mjs — Build B milestones B3/B4/B5: the lock-and-mint IOU
|
|
* (Flavor 2, the credit/debit model). USDT stays dollar-denominated as τUSDt on BCH.
|
|
*
|
|
* Unlike chipnet-btc-iou-demo.mjs (which SIMULATES the lock), here the lock is REAL on
|
|
* Tron Nile (TronUSDTVault), and reconciliation reads BOTH chains.
|
|
*
|
|
* B3 lock+mint : Alice locks real USDT in the Tron vault → bridge oracle attests it
|
|
* → DeviantWrapMintIOU mints exactly that many τUSDt to Alice on BCH.
|
|
* B5a over-mint: replaying the same oracle attestation is REJECTED (outpoint-bound).
|
|
* B4 redeem : Alice returns τUSDt to the bridge on BCH → operator releases the
|
|
* matching USDT from the Tron vault.
|
|
* B5b reconcile: Σ τUSDt circulating (BCH) == USDT locked (Tron), at both states.
|
|
*
|
|
* v1 witness = a single oracle/operator (the bridge) — trust-MINIMIZED, not trustless.
|
|
* B6 swaps it for bonded M-of-N. Run: node --import tsx scripts/tron-bch-lock-mint.mjs --yes
|
|
*/
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { TestNetWallet } from 'mainnet-js';
|
|
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
|
|
import { secp256k1, sha256 as laSha256 } from '@bitauth/libauth';
|
|
import solc from 'solc';
|
|
import { TronWeb } from 'tronweb';
|
|
|
|
const ROOT = join(import.meta.dirname, '..');
|
|
const WRAP_ART = join(ROOT, 'contracts/dist/DeviantWrapMintIOU.json');
|
|
const KEYS = { b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json'), e: join(ROOT, '.chipnet-keys-e.json') };
|
|
const NILE = 'https://nile.trongrid.io';
|
|
const USDT = 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf';
|
|
const LOCK = 5_000_000; // 5 USDT (6 dec) → 5_000_000 τUSDt units (1:1 atomic)
|
|
const SUPPLY = 100_000_000n; // τUSDt mint reservoir
|
|
const DUST = 1000n, MF = 2500n;
|
|
const CALL_FEE = 400_000_000, DEPLOY_FEE = 1_500_000_000;
|
|
|
|
// ---- BCH helpers (from chipnet-btc-iou-demo.mjs) ----
|
|
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 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 p2pkh = (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; };
|
|
const u32le = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(n), true); return b; };
|
|
const u64le = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; };
|
|
const oracleSign = (priv, msg) => { const s = secp256k1.signMessageHashSchnorr(priv, laSha256.hash(msg)); if (typeof s === 'string') throw new Error(s); return s; };
|
|
const mintMessage = (lb, amt, resTxid, resVout) => { const m = new Uint8Array(69); m.set(lb, 0); m.set(u64le(amt), 25); m.set(txidLE(resTxid), 33); m.set(u32le(resVout), 65); return m; };
|
|
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);
|
|
const hexOf = (u) => Buffer.from(u).toString('hex');
|
|
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 }; }
|
|
|
|
// ---- Tron helpers ----
|
|
let tronWeb;
|
|
function compileVault() {
|
|
const out = JSON.parse(solc.compile(JSON.stringify({
|
|
language: 'Solidity',
|
|
sources: { 'TronUSDTVault.sol': { content: readFileSync(join(ROOT, 'contracts/tron/TronUSDTVault.sol'), 'utf8') } },
|
|
settings: { evmVersion: 'istanbul', optimizer: { enabled: true, runs: 200 }, outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object'] } } },
|
|
})));
|
|
const errs = (out.errors || []).filter((e) => e.severity === 'error');
|
|
if (errs.length) { errs.forEach((e) => console.error(e.formattedMessage)); throw new Error('solc failed'); }
|
|
return { abi: out.contracts['TronUSDTVault.sol'].TronUSDTVault.abi, bytecode: out.contracts['TronUSDTVault.sol'].TronUSDTVault.evm.bytecode.object };
|
|
}
|
|
async function tronSend(contract, method, args, label, feeLimit = CALL_FEE) {
|
|
const txid = await contract[method](...args).send({ feeLimit, callValue: 0 });
|
|
let info;
|
|
for (let i = 0; i < 20; i++) { await sleep(3000); info = await tronWeb.trx.getTransactionInfo(txid); if (info && info.receipt) break; }
|
|
const result = info?.receipt?.result || (info?.blockNumber > 0 ? 'SUCCESS' : 'PENDING');
|
|
console.log(` ${label}: ${result} ${txid.slice(0, 12)}…`);
|
|
if (result !== 'SUCCESS') throw new Error(`${label} -> ${result}`);
|
|
return txid;
|
|
}
|
|
|
|
async function main() {
|
|
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes to broadcast on Nile + chipnet.'); return; }
|
|
const art = JSON.parse(readFileSync(WRAP_ART, 'utf8'));
|
|
|
|
// oracle key (create if missing — it only signs, needs no funds)
|
|
if (!existsSync(KEYS.e)) { const w = await TestNetWallet.newRandom(); writeFileSync(KEYS.e, JSON.stringify({ wif: w.privateKeyWif }, null, 2)); console.log('generated oracle key .chipnet-keys-e.json'); }
|
|
|
|
// Tron: Alice locks; operator (= Alice's Tron key here, i.e. the bridge) releases
|
|
const tronKey = JSON.parse(readFileSync(join(ROOT, '.tron-nile-keys.json'), 'utf8'));
|
|
tronWeb = new TronWeb({ fullHost: NILE, privateKey: tronKey.privateKey });
|
|
const ALICE_TRON = tronKey.address;
|
|
const usdtC = await tronWeb.contract().at(USDT);
|
|
const usdtBal = async (a) => Number(await usdtC.balanceOf(a).call());
|
|
|
|
// BCH: Alice=b (IOU user), C=c (operator/funder/bridge sink), E=e (oracle)
|
|
const ALICE = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.b, 'utf8')).wif);
|
|
const C = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c, 'utf8')).wif);
|
|
const E = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.e, 'utf8')).wif);
|
|
const provider = new ElectrumNetworkProvider('chipnet');
|
|
const sigC = new SignatureTemplate(C.privateKey);
|
|
const sigA = new SignatureTemplate(ALICE.privateKey);
|
|
const wrap = (cat) => new Contract(art, [txidLE(cat), E.publicKey], { provider });
|
|
const ftBal = async (w, cat) => (await provider.getUtxos(w.cashaddr)).filter((x) => x.token?.category === cat && !x.token?.nft).reduce((s, x) => s + BigInt(x.token.amount), 0n);
|
|
async function pick(w, need) { const u = (await provider.getUtxos(w.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(`low BCH ${t}<${need}`); return { p, t }; }
|
|
async function eligible(w) {
|
|
let u = await w.getUtxos(); let e = u.filter((x) => x.vout === 0 && !x.token); if (e.length) return e[0];
|
|
const bad = u.filter((x) => !(x.vout === 0 && !x.token));
|
|
await w.send([{ cashaddr: w.cashaddr, value: 2000n, unit: 'sat' }], bad.length ? { utxoIds: bad } : undefined); await sleep(12000);
|
|
u = await w.getUtxos(); e = u.filter((x) => x.vout === 0 && !x.token); if (!e.length) throw new Error('splitter failed'); return e[0];
|
|
}
|
|
|
|
console.log(`Alice TRON ${ALICE_TRON} | BCH ${ALICE.cashaddr}`);
|
|
console.log(`bridge BCH operator/sink ${C.cashaddr} · oracle ${E.cashaddr}`);
|
|
|
|
// ---- deploy the Tron vault + genesis the τUSDt reservoir on BCH ----
|
|
step('Setup — deploy TronUSDTVault on Nile + genesis τUSDt reservoir on chipnet');
|
|
const { abi, bytecode } = compileVault();
|
|
const vaultInst = await tronWeb.contract().new({ abi, bytecode, feeLimit: DEPLOY_FEE, callValue: 0, parameters: [USDT, ALICE_TRON] });
|
|
const VAULT = tronWeb.address.fromHex(vaultInst.address);
|
|
const vault = await tronWeb.contract(abi, VAULT);
|
|
console.log(` vault ${VAULT} (token ${USDT}, operator ${ALICE_TRON})`);
|
|
|
|
const u0 = await eligible(C); const cat = u0.txid; const w = wrap(cat);
|
|
{
|
|
const need = DUST + MF + 1000n, uSat = BigInt(u0.satoshis);
|
|
const all = (await provider.getUtxos(C.cashaddr)).filter((x) => !x.token && !(x.txid === u0.txid && x.vout === u0.vout)).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
|
|
const { p, t } = pickBch(all, need > uSat ? need - uSat : 0n); const total = uSat + t;
|
|
const tb = new TransactionBuilder({ provider });
|
|
tb.addInput(u0, sigC.unlockP2PKH()); for (const x of p) tb.addInput(x, sigC.unlockP2PKH());
|
|
tb.addOutput({ to: w.tokenAddress, amount: DUST, token: { amount: SUPPLY, category: cat, nft: { capability: 'minting', commitment: '' } } });
|
|
tb.addOutput({ to: C.cashaddr, amount: total - DUST - MF });
|
|
console.log(` reservoir ${txidOf(await tb.send())} · category ${cat.slice(0, 16)}…`); await sleep(9000);
|
|
}
|
|
const reservoir = async () => (await provider.getUtxos(w.tokenAddress)).find((x) => x.token?.nft?.capability === 'minting' && x.token?.category === cat);
|
|
|
|
// ============ B3 — real lock on Tron → mint τUSDt on BCH ============
|
|
step(`B3 — Alice locks ${LOCK / 1e6} USDT in the Tron vault → mint ${LOCK} τUSDt on BCH`);
|
|
const aliceUsdtStart = await usdtBal(ALICE_TRON);
|
|
await tronSend(usdtC, 'approve', [VAULT, LOCK], 'approve USDT');
|
|
const bchRecipient = '0x' + hexOf(ALICE.publicKeyHash) + '0'.repeat(24); // record only
|
|
await tronSend(vault, 'lock', [LOCK, bchRecipient], 'lock USDT');
|
|
const vaultLocked1 = Number(await vault.lockedBalance().call());
|
|
console.log(` vault holds ${vaultLocked1 / 1e6} USDT (real, on-chain)`);
|
|
|
|
let oSig, msg; // kept for the B5a replay test
|
|
{
|
|
const res = await reservoir(); if (!res) throw new Error('reservoir missing');
|
|
msg = mintMessage(p2pkh(ALICE.publicKeyHash), LOCK, res.txid, res.vout);
|
|
oSig = oracleSign(E.privateKey, msg);
|
|
const { p, t } = await pick(C, DUST + MF + 1000n);
|
|
const tb = new TransactionBuilder({ provider });
|
|
tb.addInput(res, w.unlock.mint(oSig, msg));
|
|
for (const x of p) tb.addInput(x, sigC.unlockP2PKH());
|
|
tb.addOutput({ to: w.tokenAddress, amount: BigInt(res.satoshis), token: { amount: SUPPLY - BigInt(LOCK), category: cat, nft: { capability: 'minting', commitment: '' } } });
|
|
tb.addOutput({ to: ALICE.tokenaddr, amount: DUST, token: { amount: BigInt(LOCK), category: cat } });
|
|
tb.addOutput({ to: C.cashaddr, amount: t - DUST - MF });
|
|
console.log(` minted ${txidOf(await tb.send())} — Alice holds ${LOCK} τUSDt`); await sleep(9000);
|
|
}
|
|
const aliceTusdt1 = await ftBal(ALICE, cat);
|
|
|
|
// reconciliation point 1 (after mint)
|
|
const res1 = await reservoir();
|
|
const mintedOut1 = SUPPLY - BigInt(res1.token.amount);
|
|
const bridgeHeld1 = await ftBal(C, cat);
|
|
const circulating1 = mintedOut1 - bridgeHeld1;
|
|
console.log(` reconcile #1: locked ${vaultLocked1} USDT-atomic == circulating ${circulating1} τUSDt → ${BigInt(vaultLocked1) === circulating1 ? 'OK' : 'MISMATCH'}`);
|
|
|
|
// ============ B5a — replay the same attestation → must be REJECTED ============
|
|
step('B5a — replay the SAME oracle attestation (double-mint) → expect REJECTED');
|
|
let replayRejected = false, replayMsg = '';
|
|
try {
|
|
const res = await reservoir(); // reservoir moved in B3; old msg is bound to the OLD outpoint
|
|
const { p, t } = await pick(C, DUST + MF + 1000n);
|
|
const tb = new TransactionBuilder({ provider });
|
|
tb.addInput(res, w.unlock.mint(oSig, msg)); // reuse B3's sig+msg
|
|
for (const x of p) tb.addInput(x, sigC.unlockP2PKH());
|
|
tb.addOutput({ to: w.tokenAddress, amount: BigInt(res.satoshis), token: { amount: SUPPLY - BigInt(LOCK) * 2n, category: cat, nft: { capability: 'minting', commitment: '' } } });
|
|
tb.addOutput({ to: ALICE.tokenaddr, amount: DUST, token: { amount: BigInt(LOCK), category: cat } });
|
|
tb.addOutput({ to: C.cashaddr, amount: t - DUST - MF });
|
|
await tb.send();
|
|
replayMsg = 'UNEXPECTEDLY ACCEPTED';
|
|
} catch (e) { replayRejected = true; replayMsg = String(e.message).split('\n')[0].slice(0, 80); }
|
|
console.log(` replay: ${replayRejected ? 'REJECTED' : 'NOT rejected'} — ${replayMsg}`);
|
|
|
|
// ============ B4 — redeem: return τUSDt on BCH → release USDT on Tron ============
|
|
step(`B4 — Alice returns ${LOCK} τUSDt to the bridge on BCH → operator releases ${LOCK / 1e6} USDT on Tron`);
|
|
{
|
|
const ft = (await provider.getUtxos(ALICE.cashaddr)).find((x) => x.token?.category === cat && !x.token?.nft && BigInt(x.token.amount) >= BigInt(LOCK));
|
|
const { p, t } = await pick(ALICE, DUST + MF);
|
|
const tb = new TransactionBuilder({ provider });
|
|
tb.addInput(ft, sigA.unlockP2PKH()); for (const x of p) tb.addInput(x, sigA.unlockP2PKH());
|
|
tb.addOutput({ to: C.tokenaddr, amount: DUST, token: { amount: BigInt(LOCK), category: cat } }); // return to bridge = burn from circulation
|
|
tb.addOutput({ to: ALICE.cashaddr, amount: t + BigInt(ft.satoshis) - DUST - MF });
|
|
console.log(` τUSDt returned to bridge ${txidOf(await tb.send())}`); await sleep(9000);
|
|
}
|
|
await tronSend(vault, 'release', [1, ALICE_TRON, LOCK], 'release USDT to Alice');
|
|
const vaultLocked2 = Number(await vault.lockedBalance().call());
|
|
const aliceUsdtEnd = await usdtBal(ALICE_TRON);
|
|
|
|
// reconciliation point 2 (after redeem)
|
|
const res2 = await reservoir();
|
|
const mintedOut2 = SUPPLY - BigInt(res2.token.amount);
|
|
const bridgeHeld2 = await ftBal(C, cat);
|
|
const circulating2 = mintedOut2 - bridgeHeld2;
|
|
console.log(` reconcile #2: locked ${vaultLocked2} USDT-atomic == circulating ${circulating2} τUSDt → ${BigInt(vaultLocked2) === circulating2 ? 'OK' : 'MISMATCH'}`);
|
|
|
|
// ---- result ----
|
|
step('RESULT — lock-and-mint τUSDt (B3/B4/B5)');
|
|
const checks = [
|
|
[`B3 lock: vault holds ${LOCK} real USDT-atomic after lock`, vaultLocked1 === LOCK],
|
|
[`B3 mint: Alice holds ${LOCK} τUSDt (covenant-enforced 1:1)`, aliceTusdt1 === BigInt(LOCK)],
|
|
['B5b reconcile #1: τUSDt circulating == USDT locked (5:5)', BigInt(vaultLocked1) === circulating1 && circulating1 === BigInt(LOCK)],
|
|
['B5a over-mint: replaying the attestation was rejected', replayRejected],
|
|
['B4 redeem: vault released the USDT (locked back to 0)', vaultLocked2 === 0],
|
|
['B5b reconcile #2: τUSDt circulating == USDT locked (0:0)', BigInt(vaultLocked2) === circulating2 && circulating2 === 0n],
|
|
['B4 round-trip: Alice net USDT ≈ start (only gas/fees spent)', Math.abs(aliceUsdtEnd - aliceUsdtStart) <= 0],
|
|
];
|
|
let ok = true; for (const [l, p] of checks) { console.log(` ${p ? 'PASS' : 'FAIL'} ${l}`); if (!p) ok = false; }
|
|
divider();
|
|
console.log(ok
|
|
? `B3/B4/B5 VALIDATED — real lock-and-mint τUSDt. Alice locked real USDT on Tron; the\nbridge oracle attested it; DeviantWrapMintIOU minted exactly that many τUSDt on BCH;\nreplaying the attestation was rejected (outpoint-bound); redeem returned the τUSDt and\nreleased the USDT; and Σ minted == Σ locked at every step (publicly reconcilable).\nv1 trust = the oracle/operator (trust-MINIMIZED). B6 = bonded M-of-N trust floor.`
|
|
: 'SOME CHECKS FAILED.');
|
|
console.log(`\nvault @ ${VAULT} · τUSDt category ${cat.slice(0, 16)}… · USDT @ ${USDT}`);
|
|
if (!ok) process.exitCode = 1;
|
|
}
|
|
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });
|