B6: TronUSDTVaultMofN replaces the single operator with an M-of-N signer set. release() requires `threshold` distinct ecrecover sigs over (redeemId, to, amount, vault), ordered ascending for dedup. Validated 2-of-3, all 4 PASS: - 1 sig -> REVERT below threshold; non-signer -> REVERT not a signer - 2 valid sigs -> SUCCESS (vault drained); replay -> REVERT redeem used Tron enforces the THRESHOLD; the BCH half (slashable bond + equivocation fraud proof) is already validated as M3-bond (BondedSignerSlash.cash). Together = tBTC-v2-style trust floor; the v1 single-operator bridge becomes trust-minimized. Build B COMPLETE: B1 HTLC, B2 trustless swap, B3/B4/B5 lock-and-mint+reconcile, B6 trust floor — both flavors of the first interchain hub rail, on real coins. contracts/tron/TronUSDTVaultMofN.sol scripts/tron-vault-mofn.mjs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
7.6 KiB
JavaScript
133 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* tron-vault-mofn.mjs — Build B milestone B6: the bonded M-of-N trust floor (Tron side).
|
|
*
|
|
* Replaces the single operator with an M-of-N signer set on the Tron vault
|
|
* (TronUSDTVaultMofN). release() requires `threshold` distinct ECDSA sigs (ecrecover)
|
|
* over (redeemId, to, amount, vault). Validates 2-of-3:
|
|
* - 1 sig -> REVERT (below threshold)
|
|
* - non-signer sig -> REVERT (not a signer)
|
|
* - 2 valid sigs -> SUCCESS (USDT released)
|
|
* - replay same call -> REVERT (redeem used)
|
|
*
|
|
* The BCH counterpart — each signer's slashable bond + equivocation fraud proof — is
|
|
* already validated as M3-bond (contracts/BondedSignerSlash.cash +
|
|
* scripts/chipnet-bonded-signer-test.mjs). Tron enforces the THRESHOLD; BCH enforces
|
|
* the PUNISHMENT. Together = the tBTC-v2-style trust floor.
|
|
*
|
|
* Run: node scripts/tron-vault-mofn.mjs
|
|
*/
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { randomBytes } from 'node:crypto';
|
|
import solc from 'solc';
|
|
import { TronWeb } from 'tronweb';
|
|
import { SigningKey, Signature, Wallet, solidityPackedKeccak256 } from 'ethers';
|
|
|
|
const ROOT = join(import.meta.dirname, '..');
|
|
const NILE = 'https://nile.trongrid.io';
|
|
const USDT = 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf';
|
|
const LOCK = 5_000_000;
|
|
const DEPLOY_FEE = 2_000_000_000, CALL_FEE = 500_000_000;
|
|
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 tronKey = JSON.parse(readFileSync(join(ROOT, '.tron-nile-keys.json'), 'utf8'));
|
|
const tronWeb = new TronWeb({ fullHost: NILE, privateKey: tronKey.privateKey });
|
|
const ALICE_TRON = tronKey.address;
|
|
const evm = (b58) => '0x' + tronWeb.address.toHex(b58).slice(2);
|
|
|
|
// 3 signer keys (persist; gitignored via .tron-*.json)
|
|
const SIGNER_FILE = join(ROOT, '.tron-mofn-signers.json');
|
|
let signerPks;
|
|
if (existsSync(SIGNER_FILE)) signerPks = JSON.parse(readFileSync(SIGNER_FILE, 'utf8')).pks;
|
|
else { signerPks = Array.from({ length: 3 }, () => '0x' + randomBytes(32).toString('hex')); writeFileSync(SIGNER_FILE, JSON.stringify({ pks: signerPks }, null, 2)); }
|
|
const signers = signerPks.map((pk) => ({ pk, evmAddr: new Wallet(pk).address, tron: tronWeb.address.fromPrivateKey(pk.slice(2)) }));
|
|
|
|
function compile() {
|
|
const out = JSON.parse(solc.compile(JSON.stringify({
|
|
language: 'Solidity',
|
|
sources: { 'TronUSDTVaultMofN.sol': { content: readFileSync(join(ROOT, 'contracts/tron/TronUSDTVaultMofN.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'); }
|
|
const c = out.contracts['TronUSDTVaultMofN.sol'].TronUSDTVaultMofN;
|
|
return { abi: c.abi, bytecode: c.evm.bytecode.object };
|
|
}
|
|
async function call(contract, method, args, label, feeLimit = CALL_FEE) {
|
|
let txid;
|
|
try { txid = await contract[method](...args).send({ feeLimit, callValue: 0 }); }
|
|
catch (e) { console.log(` ${label}: REJECTED(build) — ${String(e.message).split('\n')[0].slice(0, 60)}`); return { result: 'REVERT', reason: 'build' }; }
|
|
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');
|
|
let reason = '';
|
|
const cr = info?.contractResult?.[0];
|
|
if (cr && cr.startsWith('08c379a0')) { const len = parseInt(cr.slice(72, 136), 16); reason = Buffer.from(cr.slice(136, 136 + len * 2), 'hex').toString('utf8'); }
|
|
console.log(` ${label}: ${result}${reason ? ` ("${reason}")` : ''} ${txid.slice(0, 12)}…`);
|
|
return { result, reason, txid };
|
|
}
|
|
// sign a digest with a signer key; return 65-byte 0x sig
|
|
const signDigest = (pk, digest) => Signature.from(new SigningKey(pk).sign(digest)).serialized;
|
|
// order sigs by recovered (evm) address ascending — the contract requires it
|
|
function orderedSigs(chosen, digest) {
|
|
return [...chosen].sort((a, b) => (a.evmAddr.toLowerCase() < b.evmAddr.toLowerCase() ? -1 : 1)).map((s) => signDigest(s.pk, digest));
|
|
}
|
|
|
|
async function main() {
|
|
console.log('B6 — Tron M-of-N vault (2-of-3)');
|
|
console.log('signers:', signers.map((s) => s.tron).join(', '));
|
|
|
|
step('Deploy TronUSDTVaultMofN(USDT, [3 signers], threshold=2) + lock 5 USDT');
|
|
const { abi, bytecode } = compile();
|
|
const inst = await tronWeb.contract().new({ abi, bytecode, feeLimit: DEPLOY_FEE, callValue: 0, parameters: [USDT, signers.map((s) => s.tron), 2] });
|
|
const VAULT = tronWeb.address.fromHex(inst.address);
|
|
const vault = await tronWeb.contract(abi, VAULT);
|
|
console.log(` vault ${VAULT}`);
|
|
const usdt = await tronWeb.contract().at(USDT);
|
|
await call(usdt, 'approve', [VAULT, LOCK], 'approve USDT');
|
|
await call(vault, 'lock', [LOCK, '0x' + '00'.repeat(32)], 'lock 5 USDT');
|
|
const locked0 = Number(await vault.lockedBalance().call());
|
|
console.log(` vault holds ${locked0 / 1e6} USDT`);
|
|
|
|
// digest over the redeem payload (redeemId, to, amount, vault)
|
|
const REDEEM = 1;
|
|
const digest = solidityPackedKeccak256(['uint256', 'address', 'uint256', 'address'], [REDEEM, evm(ALICE_TRON), LOCK, evm(VAULT)]);
|
|
|
|
step('B6 checks — threshold enforcement on release(redeemId=1, to=Alice, 5 USDT)');
|
|
// 1) one signature -> below threshold
|
|
const one = orderedSigs([signers[0]], digest);
|
|
const r1 = await call(vault, 'release', [REDEEM, ALICE_TRON, LOCK, one], '1 sig (expect REVERT below threshold)');
|
|
|
|
// 2) two sigs but one is a non-signer -> not a signer
|
|
const stranger = { pk: '0x' + randomBytes(32).toString('hex'), evmAddr: '', tron: '' };
|
|
stranger.evmAddr = new Wallet(stranger.pk).address;
|
|
const mixed = orderedSigs([signers[0], stranger], digest);
|
|
const r2 = await call(vault, 'release', [REDEEM, ALICE_TRON, LOCK, mixed], '2 sigs incl. non-signer (expect REVERT not a signer)');
|
|
|
|
// 3) two valid signer sigs -> SUCCESS
|
|
const two = orderedSigs([signers[0], signers[1]], digest);
|
|
const r3 = await call(vault, 'release', [REDEEM, ALICE_TRON, LOCK, two], '2 valid sigs (expect SUCCESS)');
|
|
const locked1 = Number(await vault.lockedBalance().call());
|
|
|
|
// 4) replay the same release -> redeem used
|
|
const r4 = await call(vault, 'release', [REDEEM, ALICE_TRON, LOCK, two], 'replay same release (expect REVERT redeem used)');
|
|
|
|
step('RESULT — B6 Tron M-of-N release');
|
|
const checks = [
|
|
['1-of-3 rejected (below threshold)', r1.result === 'REVERT'],
|
|
['non-signer rejected (not a signer)', r2.result === 'REVERT'],
|
|
['2-of-3 released the USDT (SUCCESS, vault drained)', r3.result === 'SUCCESS' && locked1 === 0],
|
|
['replay rejected (redeem used)', r4.result === 'REVERT'],
|
|
];
|
|
let ok = true; for (const [l, p] of checks) { console.log(` ${p ? 'PASS' : 'FAIL'} ${l}`); if (!p) ok = false; }
|
|
divider();
|
|
console.log(ok
|
|
? `B6 VALIDATED (Tron side) — M-of-N threshold release: 2-of-3 sigs release the locked\nUSDT; 1 sig and a non-signer are rejected; replay is blocked. Pairs with the BCH bond +\nequivocation slash (BondedSignerSlash.cash / M3-bond): Tron enforces the threshold, BCH\nenforces the punishment. The bridge's single-operator trust (B3) becomes a bonded\nM-of-N trust floor — trust-minimized, tBTC-v2-style.`
|
|
: 'SOME CHECKS FAILED.');
|
|
console.log(`\nvault @ ${VAULT}`);
|
|
if (!ok) process.exitCode = 1;
|
|
}
|
|
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });
|