deviant/scripts/tron-vault-mofn.mjs
walerikus 21d23b3754 feat(build-b): B6 bonded M-of-N trust floor (Tron side) — Build B complete
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>
2026-06-28 14:49:18 +02:00

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; });