feat(t6): validate BTC↔BCH end-to-end + bonded M-of-N + wallet UX (ADR-0046)
"Bitcoin Cash as a better Lightning" (T6) and the BTC-IOU trust floor, validated end-to-end on BCH chipnet + BTC signet with real coins. - LN→T6 routing: a routed payment received into a T6 channel; one preimage settles both chains (BCH board reveal -> BTC HTLC claim). Scripts: chipnet-ln-to-t6-routing, btc-signet-ln-leg, btc-claim-htlc. - T6c unilateral exit complete: honest CSV-delayed self-close (ELSE branch) added alongside the penalty branch (btc-signet-honest-close). - T4 real BTC↔BCH atomic swap: one hashlock over a signet P2WSH HTLC + chipnet AtomicSwapHTLC; claim + refund (btc-bch-htlc-swap). Fixes btc-htlc.mjs bigint bug. - Model-3 bonded M-of-N redemption: BondedSignerSlash.cash (equivocation -> slash) + 2-of-3 signet release; BTC enforces the threshold, BCH enforces the punishment (chipnet-bonded-signer-test, btc-signet-mofn-release). - T6-aware wallet UX: one-tap send/receive over the validated stack, every tap a real on-chain BchChannelBoard update (t6-wallet-server + design/t6-wallet); fresh-provider + retry hardening for flaky-chipnet broadcasts. - Docs: ADR-0046, spec §16, Observability-and-privacy companion (the auditability property + the three-baseline privacy framing), complete-record/model-explainer/ tracker updates, snapshots 0177–0179. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
60b2140432
commit
850afd651d
21 changed files with 1720 additions and 14 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -76,6 +76,9 @@ Parameters/FIATm/transcripts/
|
|||
# BTC signet HTLC-swap testnet keys (T4 — BTC↔BCH atomic swap). Testnet-only.
|
||||
.btc-*.json
|
||||
|
||||
# T6 ephemeral demo state (channel handle, routing run-state). Regenerated; not secrets.
|
||||
.t6-*.json
|
||||
|
||||
# Local scratch / temp files.
|
||||
.tmp-*
|
||||
|
||||
|
|
|
|||
|
|
@ -39,12 +39,16 @@ starting with the LN model.
|
|||
| **T1** | BCH HTLC covenant + BCH↔FT atomic swap (claim/reveal/refund + guards) | chipnet | none | the HTLC primitive works first-party | ✅ **validated 2026-06-24** |
|
||||
| **T2** | BCH↔BCH covenant-native payment channel (2-of-2, off-chain commitments, coop + unilateral close, penalty on stale broadcast) | chipnet | none | the LN channel mechanics where BCH is judge *and* memory (BTC can't — no `ANYPREVOUT`) | ✅ **validated 2026-06-24** — all 3 paths (coop close, penalty-on-stale, finalize-after-CSV) PASS |
|
||||
| **T3** | BCH-as-watchtower / state-anchor for channel state | chipnet | none | permissionless watchtowing + no-lost-state (the "better board") | planned |
|
||||
| **T4** | BTC(signet) HTLC ↔ BCH(chipnet) cross-chain swap | +signet | bitcoinjs | the real trustless BTC↔BCH swap | scaffolded |
|
||||
| **T4** | BTC(signet) HTLC ↔ BCH(chipnet) cross-chain swap | +signet | bitcoinjs | the real trustless BTC↔BCH swap | ✅ **validated 2026-06-27** — `scripts/btc-bch-htlc-swap.mjs`: one sha256 hashlock over a signet BTC HTLC + a chipnet `AtomicSwapHTLC`. Alice's BCH claim (`139b2312…`) revealed S; Bob claimed the signet BTC leg with it (`8bc0ba56…`); BTC refund rejected before CLTV, succeeded after (`02144e7b…`). No oracle/wrapped/custodian — Model 2 on real Bitcoin. |
|
||||
| **T5** | swap-in → ordinary on-chain BCH spend (0-conf, offline receive) | both | none | "escape the channel" — the experiential demo | ✅ **validated 2026-06-24** |
|
||||
| **RT** | full round-trip: switch in → use → **switch out** (swap-out) | chipnet | none | the Model-2 user journey end to end | ✅ **validated 2026-06-24** |
|
||||
| **T6** | lock BTC (2-of-2) + BCH-recorded channel state (board) + BTC settlement | chipnet + **signet** | bitcoinjs | Model 1 — "lock like LN, channel/board on BCH, stay in BTC" | ✅ **validated 2026-06-24** — BCH board (`BchChannelBoard.cash` + `chipnet-channel-board-test.mjs`) + real BTC 2-of-2 funding & cooperative close on signet (`btc-signet-channel.mjs`) + **trustless unilateral-exit penalty** validated on signet (`btc-signet-penalty.mjs`): a stale/revoked commitment is penalised — the counterparty seizes the cheater's output via the revocation key. T6 is the complete LN-faithful replacement. Only the honest CSV-delayed self-close (liveness mirror) is unrun. |
|
||||
| **T6** | lock BTC (2-of-2) + BCH-recorded channel state (board) + BTC settlement | chipnet + **signet** | bitcoinjs | Model 1 — "lock like LN, channel/board on BCH, stay in BTC" | ✅ **validated 2026-06-24** — BCH board (`BchChannelBoard.cash` + `chipnet-channel-board-test.mjs`) + real BTC 2-of-2 funding & cooperative close on signet (`btc-signet-channel.mjs`) + **trustless unilateral-exit penalty** validated on signet (`btc-signet-penalty.mjs`): a stale/revoked commitment is penalised — the counterparty seizes the cheater's output via the revocation key. **Honest CSV-delayed self-close (liveness mirror) validated 2026-06-27** (`btc-signet-honest-close.mjs`): the ELSE branch is rejected before the CSV delay (`non-BIP68-final`) and sweeps the closer's own output after — completing the trustless unilateral-exit pair. T6 is the complete LN-faithful replacement. |
|
||||
|
||||
T1–T3 are the LN-model focus, all on chipnet, zero new deps.
|
||||
| **T6r** | LN→T6 routing: route a (Lightning) payment INTO a T6 channel; one preimage links the BCH leg + the BTC HTLC leg | chipnet + **signet** | bitcoinjs | LN-interop — a routed payment received into a T6 channel, settling cross-chain | ✅ **validated 2026-06-27** — BCH leg (`BchChannelBoardHTLC.cash` `settleHtlc` + `chipnet-ln-to-t6-routing.mjs`): receiver credited, preimage S revealed on-chain. Cross-chain loop (`btc-signet-ln-leg.mjs` + `btc-claim-htlc.mjs`): Carol locked a real signet HTLC under H; S revealed on the BCH board (`19bcd25b…`); Bob **read S off the chain** (no prior knowledge) and claimed the BTC HTLC (`65256a63…`, 5600 sat). One preimage settled both chains. |
|
||||
|
||||
T1–T3 are the LN-model focus, all on chipnet, zero new deps. T6r proves LN interop: an LN
|
||||
payment routes into a T6 channel with a single preimage spanning BCH (the board) and BTC (the
|
||||
upstream HTLC) — exactly the "BCH as a better Lightning that still speaks Lightning" claim.
|
||||
|
||||
## Three models — ship all, let users choose (two axes: technical *and* adoption)
|
||||
|
||||
|
|
@ -60,6 +64,13 @@ adoption axis even though Model 2 wins the technical axis. Lead with **demonstra
|
|||
LN, NO node, NO LN-hub custody; backing publicly verifiable (no over-issuance, covenant-bounded).
|
||||
Stays BTC-denominated. Trust = the BTC lock (cooperative 2-of-2 / bonded M-of-N), trust-MINIMIZED
|
||||
not trustless. **The captured-audience on-ramp.** Real BTC lock = signet (T4/T6).
|
||||
**Bonded M-of-N redemption leg validated 2026-06-27** (the trust floor): the BTC reserve
|
||||
releases only with a 2-of-3 multisig on signet (`btc-signet-mofn-release.mjs` — a single sig is
|
||||
rejected by consensus), and each signer posts a **slashable bond on BCH** (`BondedSignerSlash.cash`
|
||||
+ `chipnet-bonded-signer-test.mjs`) — equivocation (two signatures over one redeemId with
|
||||
conflicting payloads) is a fraud proof anyone can submit to seize the bond. BTC enforces the
|
||||
release threshold; BCH enforces the punishment BTC cannot. That pair is what makes the M-of-N
|
||||
trust-MINIMIZED (tBTC-v2-style) rather than trusted.
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
110
Parameters/T6_BitcoinCash_LN/Observability-and-privacy.md
Normal file
110
Parameters/T6_BitcoinCash_LN/Observability-and-privacy.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# T6 — Observability & privacy
|
||||
|
||||
*What T6 makes visible that Lightning cannot, and what that does (and does not) cost in privacy.
|
||||
A focused companion to `T6_BTC-BCH_complete-record.md` (the full investigation) and
|
||||
`T6_model_explained.md` (the model). The conclusion: T6 adds a public, pseudonymous audit record
|
||||
of off-chain BTC flow that LN structurally lacks — and, against the baseline most users actually
|
||||
use (custodial LN), it does so while **improving** privacy and custody, not trading them away.*
|
||||
|
||||
---
|
||||
|
||||
## 1. The observability property
|
||||
|
||||
In LN, a channel's state — balances, every payment — is **private and bilateral**: held off-chain
|
||||
by the two nodes, never published. There is no shared ledger of balances or payments anywhere. So
|
||||
LN cannot tell you how much was transacted; only **channel capacity** (announced public channels)
|
||||
and **open/close** events are visible on-chain.
|
||||
|
||||
In T6, every channel update is a both-signed, strictly-monotonic commitment written to the BCH
|
||||
**board** (`stateNumber ‖ balanceA ‖ balanceB`), on-chain and permanent. From BCH alone, per
|
||||
channel, anyone can read:
|
||||
|
||||
- the **current balance split**,
|
||||
- the **full history of state transitions** (the board NFT's BCH tx history),
|
||||
- the **number of updates** and the **per-update deltas**.
|
||||
|
||||
Summed across all boards, this yields network-level analytics LN cannot produce: **total BTC
|
||||
locked in T6 channels, balance evolution over time, and gross in-channel flow.** This is the
|
||||
"clerk" role of BCH (it records and proves the state) generalized from one channel to aggregate
|
||||
bookkeeping.
|
||||
|
||||
## 2. Scope the claim honestly (three caveats)
|
||||
|
||||
1. **"Via T6," not "via the whole BCH network."** The board only sees channels that opted into
|
||||
using it. BTC moving over canonical LN or any non-T6 channel is invisible to BCH. The figure is
|
||||
the T6 subset.
|
||||
2. **"Locked" is exact; "transacted" needs care.** You can read precisely how much BTC is *locked*
|
||||
in T6 channels (and the funding 2-of-2s are also visible on the *BTC* chain, so the total is
|
||||
cross-checkable). But intra-channel value bounces back and forth over a fixed locked amount, so
|
||||
per-update flow is **reallocation, not settlement** — finality is at the BTC close. Report it as
|
||||
"X locked, N updates, Y gross in-channel volume," not as settled throughput.
|
||||
3. **It's a different finality than the swap/native models.** A T6 update isn't a settled payment;
|
||||
the on-chain BCH models (Model 2 swap, native transacting) are where each payment settles for
|
||||
real.
|
||||
|
||||
## 3. Address ≠ identity
|
||||
|
||||
On-chain data (BTC and BCH alike) is **pseudonymous**: it exposes addresses and flows, not people.
|
||||
Reaching an *identity* requires a **linking event** — a KYC anchor, an IP correlation, a doxxed
|
||||
address reuse — combined with clustering heuristics (common-input ownership, change detection,
|
||||
timing/amount correlation). Auditability of *transactions* is therefore **not** auditability of
|
||||
*persons*; at scale, deanonymizing pseudonymous activity is expensive and probabilistic, not a
|
||||
lookup. A user who keeps addresses unlinked to an identity is not identified merely because the
|
||||
flow is public.
|
||||
|
||||
## 4. The privacy comparison — pick the right baseline
|
||||
|
||||
The privacy question only has a clear answer once you fix what T6 is being compared *to*.
|
||||
|
||||
| baseline | custody | flow visible to a 3rd party | identity |
|
||||
|---|---|---|---|
|
||||
| **Custodial LN** (where most users end up) | custodian (trusted) | the custodian sees **everything** | **KYC'd** — bound to real ID |
|
||||
| **Non-custodial LN** (run your own node) | self | nobody | none unless you self-link |
|
||||
| **T6** | self | **public, pseudonymous** | none unless you self-link |
|
||||
|
||||
- **vs Custodial LN — strict win on all three axes.** LN's trilemma funnels most users to
|
||||
custodial wallets (see the complete-record §J). Custodial LN means the provider holds your
|
||||
coins, sees your entire balance and payment history, and usually enforces KYC that binds it to
|
||||
your real identity from day one. T6 is self-custodial, no-KYC, and only pseudonymous on-chain.
|
||||
So against the realistic mass baseline, T6 **gains** privacy and custody **and** adds
|
||||
auditability.
|
||||
- **vs Non-custodial LN — identical on identity and custody.** Both are self-custodial and
|
||||
pseudonymous ("none unless you self-link"). Neither is KYC'd. The **only** difference is
|
||||
**flow observability**: non-custodial LN hides the channel relationship and payment flow
|
||||
entirely; T6 publishes it pseudonymously. That single delta is precisely what *buys* the
|
||||
auditability.
|
||||
|
||||
## 5. The one genuine residual (and that it cuts the other way too)
|
||||
|
||||
Against the **non-custodial-node** LN user specifically, T6's residual exposure is: the board
|
||||
publishes the channel's **two pubkeys/addresses and the intra-channel flow timeline**, so a
|
||||
third-party observer can see "address A and address B share a channel and value moved A→B→A over
|
||||
time." Non-custodial LN hides that relationship graph.
|
||||
|
||||
This residual is **pseudonymous (address-level, not identity)** and **mitigable**: fresh keys per
|
||||
channel, no address reuse, and funding that isn't clustered to a KYC'd source.
|
||||
|
||||
And it does **not** run all one way. Running a public LN routing node broadcasts an **IP address**
|
||||
in gossip, bound to the node's pubkey and its channels (mitigated only by Tor) — a *passive*
|
||||
deanonymization vector. T6 has **no always-online node announcing anything**; its only surface is
|
||||
the on-chain pseudonymous channel graph. So on the passive identity surface, T6 is arguably
|
||||
**smaller** than a public LN node, not larger.
|
||||
|
||||
## 6. Net
|
||||
|
||||
- T6 **uniquely** turns a class of off-chain BTC activity into a **public, pseudonymous, permanent
|
||||
audit record** (locked totals, balance evolution, update volume) that Lightning, by design,
|
||||
cannot provide.
|
||||
- It does so without trading away privacy in the realistic case: **strict win vs custodial LN**
|
||||
(self-custody + no-KYC + pseudonymous, vs trusted custody + KYC + full custodian visibility),
|
||||
and **equal identity/custody vs non-custodial LN**, with the lone difference being pseudonymous
|
||||
flow observability (mitigable, and offset by T6's lack of a node-IP surface).
|
||||
- So the accurate one-liner is **not** "T6 trades privacy for auditability." It is: **T6 adds
|
||||
transaction transparency and auditability while keeping users self-custodial and no-KYC — a
|
||||
combination (no-KYC + self-custodial + auditable) that neither Lightning tier offers.**
|
||||
|
||||
---
|
||||
|
||||
*Cross-refs: `T6_BTC-BCH_complete-record.md` (§1 clerk/bailiff, §J LN topology), `T6_model_explained.md`
|
||||
§10 (this summarized), ADR-0046 ("Observability & privacy"). Auditability is the clerk role of the
|
||||
BCH board; the privacy framing is baseline-dependent and stated above per baseline.*
|
||||
|
|
@ -91,12 +91,18 @@ locked and yours" is acceptable).
|
|||
| **T6b** | BTC **2-of-2 channel** — funding + cooperative close | **signet** | ✅ validated | funded `764238…`, closed `af4db7…` Alice 5700 / Bob 3800 |
|
||||
| **T6c** | trustless **unilateral exit** — penalty for a stale broadcast | **signet** | ✅ validated | revoked commitment `b76d17…` penalised: victim swept the cheater's output via the revocation key (`b35186…`, seized 4500) |
|
||||
| **T6-scale** | many addresses / concurrent channels on the board | chipnet | ✅ validated | **3 concurrent channels = 8 addresses** (6 party + 1 watchtower + 1 operator); parties **sign-only, unfunded**; 1 permissionless watchtower read all 3 |
|
||||
| **T6r** | LN→T6 routing — route a payment INTO a T6 channel; one preimage links both legs | chipnet + **signet** | ✅ validated | BCH leg: `settleHtlc` credits receiver + reveals S on-chain (`19bcd25b…`). Cross-chain: Carol locked a real signet HTLC under H; Bob **read S off the BCH board** (no prior knowledge) and claimed the BTC HTLC (`65256a63…`, 5600 sat). One preimage settled both chains. |
|
||||
| **T6c-honest** | honest CSV-delayed self-close (ELSE branch / liveness mirror of the penalty) | **signet** | ✅ validated | `btc-signet-honest-close.mjs`: sweep REJECTED before the CSV delay (`non-BIP68-final`), SUCCEEDS after the output is CSV-deep (`a8d95644…`). Completes the trustless unilateral-exit pair. |
|
||||
| **T4** | real trustless BTC↔BCH atomic swap — one hashlock over a signet BTC HTLC + a chipnet `AtomicSwapHTLC` | chipnet + **signet** | ✅ validated | `btc-bch-htlc-swap.mjs`: Alice's BCH claim (`139b2312…`) revealed S; Bob claimed the signet BTC leg with it (`8bc0ba56…`); BTC refund rejected before CLTV, succeeded after (`02144e7b…`). Model 2 on real Bitcoin. |
|
||||
| **M3-bond** | bonded M-of-N redemption — BTC release threshold + BCH slashable signer bond | chipnet + **signet** | ✅ validated | BTC: 2-of-3 release on signet, 1 sig rejected by consensus (`1be08e1c…`, `btc-signet-mofn-release.mjs`). BCH: `BondedSignerSlash.cash` (`chipnet-bonded-signer-test.mjs`) — equivocation slashes the bond (5/5: slash on conflict; reject identical/different-id; honest withdraw; reject early withdraw). Trust floor (tBTC-v2-style). |
|
||||
|
||||
**T6 is validated end-to-end and trustless, with real coins on both chains**: BCH records
|
||||
the state; BTC locks, settles, *and penalises a stale broadcast*. The penalty (the security
|
||||
core of unilateral exit) is proven; cheating costs the cheater their whole balance. The only
|
||||
unrun piece is the *honest* CSV-delayed self-close — the liveness counterpart (closer takes
|
||||
their own output after the CSV delay via the script's ELSE branch); not security-critical.
|
||||
core of unilateral exit) is proven; cheating costs the cheater their whole balance. The
|
||||
*honest* CSV-delayed self-close — the liveness counterpart (closer takes their own output after
|
||||
the CSV delay via the script's ELSE branch) — is now **also validated** (`btc-signet-honest-close.mjs`,
|
||||
2026-06-27): the sweep is rejected before the delay (`non-BIP68-final`) and succeeds after, so
|
||||
the trustless unilateral-exit pair (penalty + honest close) is complete.
|
||||
|
||||
**The commitment's revocation output** (`btc-signet-penalty.mjs`):
|
||||
`OP_IF <revocationPub> OP_CHECKSIG OP_ELSE <CSV> OP_CHECKSEQUENCEVERIFY OP_DROP <closerPub>
|
||||
|
|
@ -159,12 +165,15 @@ checks need `publicKeyCompressed`.
|
|||
|
||||
## 6. Pending / next
|
||||
|
||||
- **T6c honest-close (liveness counterpart):** the *honest* CSV-delayed self-close (closer
|
||||
takes their own output after the CSV delay, the script's ELSE branch). The penalty (IF
|
||||
branch) is validated; this is the slow, non-security-critical mirror, runnable with a
|
||||
block-wait.
|
||||
- **T4 — real BTC↔BCH HTLC swap:** run `btc-htlc.mjs` against chipnet (Model 2, real BTC).
|
||||
- ~~**T6c honest-close (liveness counterpart)**~~ ✅ **done 2026-06-27** (`btc-signet-honest-close.mjs`):
|
||||
rejected before the CSV delay, sweeps after — the trustless unilateral-exit pair is complete.
|
||||
- ~~**LN→T6 routing**~~ ✅ **done 2026-06-27** (`chipnet-ln-to-t6-routing.mjs` +
|
||||
`btc-signet-ln-leg.mjs` + `btc-claim-htlc.mjs`): a routed payment received into a T6 channel;
|
||||
one preimage settled both chains (BCH board reveal ⇒ BTC HTLC claim).
|
||||
- ~~**T4 — real BTC↔BCH HTLC swap**~~ ✅ **done 2026-06-27** (`btc-bch-htlc-swap.mjs`): one
|
||||
hashlock over a signet BTC HTLC + a chipnet `AtomicSwapHTLC`; claim path + BTC refund safety net.
|
||||
- **Model 3 bonded signers:** M-of-N release leg (tBTC-v2-style) so any IOU holder can redeem.
|
||||
- **T6 ADR + spec section**, then a **T6-aware wallet UX** (one-tap send/receive over the stack).
|
||||
- **Strategy:** lead with **demonstration, not argument** — see the auto-memory
|
||||
`demonstration-over-reasoning-captured-audience`. Model 3 is the captured-audience on-ramp;
|
||||
T6 is the LN-faithful "better LN."
|
||||
|
|
|
|||
|
|
@ -192,5 +192,38 @@ can actually walk through.
|
|||
|
||||
---
|
||||
|
||||
## 10. Observability & privacy
|
||||
|
||||
A consequence of moving the state record onto a public chain: **T6 makes off-chain BTC flow
|
||||
auditable in a way Lightning structurally cannot.** Every update is on the BCH board, so anyone
|
||||
can read, per channel, the current split, the full history of state transitions, the number of
|
||||
updates, and the per-update deltas — and sum them across all boards for network-level figures
|
||||
(**total BTC locked in T6 channels, balance evolution, gross in-channel flow**). LN keeps channel
|
||||
state private and bilateral, so it can expose only **capacity** (announced channels) and
|
||||
**open/close** events — never balances, payments, or volume. Caveats: this covers the **T6 subset**
|
||||
only, and intra-channel flow is **reallocation, not settled throughput** (finality is at the BTC
|
||||
close), so report it as "locked + update volume," not settlement.
|
||||
|
||||
Does that auditability cost privacy? Only against the right baseline — and on-chain data is
|
||||
**pseudonymous (addresses, not identities)**; reaching an identity needs a separate linking event.
|
||||
|
||||
- **vs custodial LN** (where LN's trilemma funnels most users): a **strict win** — T6 is
|
||||
self-custodial and no-KYC with only pseudonymous on-chain data, where custodial LN means a
|
||||
trusted custodian that sees your entire history and usually KYC's it to your real identity.
|
||||
- **vs non-custodial LN** (run your own node): **identical** on identity (pseudonymous, none
|
||||
unless you self-link) and custody (self). The *only* difference is **flow observability** — LN
|
||||
hides the channel relationship and flow; T6 publishes it pseudonymously. That one delta is what
|
||||
*buys* the auditability, and it's mitigable (fresh keys per channel, no reuse). It even cuts the
|
||||
other way: a public LN routing node leaks an **IP** in gossip (bound to its pubkey + channels),
|
||||
while T6 runs **no always-online node** — so T6's passive identity surface is arguably smaller.
|
||||
|
||||
So the honest framing is **not** "T6 trades privacy for auditability." It is: **T6 adds transaction
|
||||
transparency and auditability while keeping users self-custodial and no-KYC** — a combination
|
||||
(no-KYC + self-custodial + auditable) neither Lightning tier offers. Full treatment:
|
||||
`Observability-and-privacy.md`.
|
||||
|
||||
---
|
||||
|
||||
*Companion to `T6_BTC-BCH_complete-record.md` (the full BTC↔BCH investigation: all models,
|
||||
all tests, the conceptual spine). This file is the focused T6 explainer.*
|
||||
all tests, the conceptual spine). This file is the focused T6 explainer; see also
|
||||
`Observability-and-privacy.md`.*
|
||||
|
|
|
|||
53
contracts/BondedSignerSlash.cash
Normal file
53
contracts/BondedSignerSlash.cash
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
pragma cashscript ^0.12.0;
|
||||
|
||||
// BondedSignerSlash — a peg-signer's SLASHABLE BOND on Bitcoin Cash.
|
||||
//
|
||||
// A trust-minimized BTC peg (tBTC-v2 style) custodies the locked BTC behind an M-of-N
|
||||
// signer set and releases it with a BTC multisig. But BTC has no covenants, so BTC cannot
|
||||
// PUNISH a signer who misbehaves — it can only check signatures. BCH can. So each signer
|
||||
// posts a BOND here, on BCH, and BCH enforces the punishment that BTC cannot.
|
||||
//
|
||||
// Honest operation: a signer issues EXACTLY ONE attestation per redeem slot (a redeemId).
|
||||
// EQUIVOCATION — two valid signatures by the same signer over the SAME redeemId but
|
||||
// CONFLICTING payloads (different recipient/amount) — is a self-contained cryptographic
|
||||
// FRAUD PROOF: it shows the signer tried to authorize two different outcomes for one
|
||||
// redeem. Anyone holding that proof can SLASH the bond (and take it — a standing bounty
|
||||
// that makes watching permissionless and cheating self-defeating). This is exactly the
|
||||
// piece that converts "trust the signers" into "trust-minimized": collusion is still
|
||||
// possible, but it is bonded and slashable, so a rational signer never equivocates.
|
||||
//
|
||||
// What BCH enforces here is the DETERRENT (the bond); what BTC enforces is the RELEASE
|
||||
// (the M-of-N multisig, on signet). Together they are the bonded M-of-N redemption leg.
|
||||
//
|
||||
// attestation message: redeemId(32) || payload(>=1) (payload = recipientLockingBytecode || amount; opaque to the covenant)
|
||||
//
|
||||
// slash(...) — present two valid signer signatures over the same redeemId with
|
||||
// different payloads → the bond is forfeit (authorization-only: the
|
||||
// prover routes it wherever they choose, typically to themselves).
|
||||
// withdraw(...) — after unbondHeight (absolute CLTV), with no fraud proven, the signer
|
||||
// reclaims their own bond.
|
||||
|
||||
contract BondedSignerSlash(
|
||||
pubkey signerPk, // the bonded signer's attestation key (same key signs attestations and withdrawals)
|
||||
int unbondHeight // absolute block height after which an un-slashed bond may be withdrawn
|
||||
) {
|
||||
// Anyone who can prove the signer equivocated takes the bond. Authorization-only: the
|
||||
// covenant gates WHO may spend (a valid fraud proof), not WHERE the funds go.
|
||||
function slash(datasig sigA, bytes msgA, datasig sigB, bytes msgB) {
|
||||
require(checkDataSig(sigA, msgA, signerPk)); // both are genuine signer signatures
|
||||
require(checkDataSig(sigB, msgB, signerPk));
|
||||
|
||||
bytes idA, bytes payloadA = msgA.split(32); // redeemId(32) || payload
|
||||
bytes idB, bytes payloadB = msgB.split(32);
|
||||
require(payloadA.length >= 1); // well-formed attestations (id + non-empty payload)
|
||||
require(payloadB.length >= 1);
|
||||
require(idA == idB); // SAME redeem slot
|
||||
require(payloadA != payloadB); // CONFLICTING outcome → equivocation (the fraud)
|
||||
}
|
||||
|
||||
// No fraud proven within the bond window → the signer reclaims their own bond.
|
||||
function withdraw(sig signerSig) {
|
||||
require(tx.time >= unbondHeight); // absolute CLTV: only after the unbond window
|
||||
require(checkSig(signerSig, signerPk));
|
||||
}
|
||||
}
|
||||
169
design/t6-wallet/index.html
Normal file
169
design/t6-wallet/index.html
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>T6 Wallet — Bitcoin on Bitcoin Cash rails</title>
|
||||
<style>
|
||||
:root{
|
||||
--sky:#0a0e1a; --sky2:#111933; --ink:#e8ecf8; --muted:#8b96b8; --line:#22304f;
|
||||
--acid:#9cff57; --acid-dim:#5c8f3a; --gold:#ffcf6b; --btc:#f7931a; --bch:#0ac18e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:radial-gradient(1200px 700px at 70% -10%,#16224a 0%,var(--sky) 60%);color:var(--ink);
|
||||
font-family:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;min-height:100vh;padding:28px 16px 60px}
|
||||
.wrap{max-width:880px;margin:0 auto}
|
||||
h1{font-family:Fraunces,Georgia,serif;font-weight:600;font-size:30px;margin:0 0 2px;letter-spacing:-.5px}
|
||||
.tag{color:var(--muted);font-size:13px;margin:0 0 22px}
|
||||
.grid{display:grid;grid-template-columns:1.15fr .85fr;gap:16px}
|
||||
@media(max-width:720px){.grid{grid-template-columns:1fr}}
|
||||
.card{background:linear-gradient(180deg,var(--sky2),#0c1326);border:1px solid var(--line);border-radius:16px;padding:18px 18px 16px}
|
||||
.card h2{font-family:Fraunces,Georgia,serif;font-weight:600;font-size:15px;margin:0 0 12px;color:var(--ink)}
|
||||
.bal{font-size:40px;font-weight:600;font-family:Fraunces,Georgia,serif;letter-spacing:-1px}
|
||||
.bal small{font-size:15px;color:var(--muted);font-family:"JetBrains Mono",monospace}
|
||||
.sub{color:var(--muted);font-size:12px;margin-top:4px}
|
||||
.row{display:flex;gap:10px;margin-top:14px}
|
||||
input[type=number]{flex:1;background:#0a1020;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:11px 12px;font:inherit;font-size:15px}
|
||||
button{cursor:pointer;border:0;border-radius:10px;padding:11px 14px;font:inherit;font-weight:600;font-size:14px;transition:.15s}
|
||||
button:disabled{opacity:.4;cursor:not-allowed}
|
||||
.send{background:var(--btc);color:#1a1205}
|
||||
.recv{background:var(--bch);color:#04261c}
|
||||
.send:hover,.recv:hover{filter:brightness(1.1)}
|
||||
.ghost{background:transparent;border:1px solid var(--line);color:var(--muted)}
|
||||
.ghost:hover{border-color:var(--acid);color:var(--acid)}
|
||||
.board{font-size:13px}
|
||||
.kv{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed #1b2640}
|
||||
.kv b{color:var(--acid);font-weight:600}
|
||||
.mono{font-size:11px;color:var(--muted);word-break:break-all}
|
||||
.hist{margin-top:10px;max-height:220px;overflow:auto}
|
||||
.h{display:flex;justify-content:space-between;gap:8px;font-size:12px;padding:7px 0;border-bottom:1px solid #161f38}
|
||||
.h a{color:var(--gold);text-decoration:none}
|
||||
.h a:hover{text-decoration:underline}
|
||||
.pill{display:inline-block;font-size:10px;padding:2px 7px;border-radius:99px;border:1px solid var(--line);color:var(--muted)}
|
||||
.dont li{margin:7px 0;font-size:13px;list-style:none}
|
||||
.dont ul{padding:0;margin:0}
|
||||
.no::before{content:"✗ ";color:#ff6b7d;font-weight:700}
|
||||
.yes::before{content:"✓ ";color:var(--acid);font-weight:700}
|
||||
.status{min-height:18px;font-size:12px;color:var(--gold);margin-top:10px}
|
||||
.spin{display:inline-block;width:11px;height:11px;border:2px solid #2a3a5e;border-top-color:var(--acid);border-radius:50%;animation:s .7s linear infinite;vertical-align:-1px;margin-right:6px}
|
||||
@keyframes s{to{transform:rotate(360deg)}}
|
||||
.foot{color:var(--muted);font-size:11px;margin-top:22px;line-height:1.6}
|
||||
.badge{font-size:10px;color:var(--acid);border:1px solid var(--acid-dim);border-radius:6px;padding:2px 6px;margin-left:8px;vertical-align:2px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>T6 Wallet <span class="badge">chipnet · live</span></h1>
|
||||
<p class="tag">Bitcoin on Bitcoin Cash rails. No Lightning node. No watchtower. No staying online to defend.</p>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="card" id="walletCard">
|
||||
<h2>Your channel balance</h2>
|
||||
<div class="bal" id="bal">— <small>sat</small></div>
|
||||
<div class="sub" id="chanSub">No channel open.</div>
|
||||
<div class="row">
|
||||
<input id="amt" type="number" min="1" value="5000" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="send" id="btnSend" disabled>Send →</button>
|
||||
<button class="recv" id="btnRecv" disabled>← Receive</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="ghost" id="btnOpen" title="Fund a BTC 2-of-2 (on-chain). If your funds are already in a Lightning channel, a reverse submarine swap moves them out without closing it.">Open channel (fund BTC 2-of-2)</button>
|
||||
<button class="ghost" id="btnReset">Reset</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:16px">
|
||||
<h2>What you don't run <span class="pill">vs Lightning</span></h2>
|
||||
<div class="dont">
|
||||
<ul>
|
||||
<li class="no">A persistent Lightning <b>node</b> (daemon + private channel state)</li>
|
||||
<li class="no">A <b>watchtower</b> service to catch a cheating counterparty</li>
|
||||
<li class="no">"<b>Stay online</b>" to defend — or lose funds to a stale broadcast</li>
|
||||
</ul>
|
||||
<hr style="border:0;border-top:1px solid var(--line);margin:10px 0" />
|
||||
<ul>
|
||||
<li class="yes">Two <b>light wallets</b> (one key signs BTC + BCH)</li>
|
||||
<li class="yes">The <b>BCH chain remembers</b> the state — public, permanent, unloseable</li>
|
||||
<li class="yes">Anyone can watch the board — <b>permissionless watchtower</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card board">
|
||||
<h2>The board <span class="pill">BCH on-chain record</span></h2>
|
||||
<div class="kv"><span>state #</span><b id="bState">—</b></div>
|
||||
<div class="kv"><span>you (Alice)</span><b id="bA">—</b></div>
|
||||
<div class="kv"><span>peer (Bob)</span><b id="bB">—</b></div>
|
||||
<div class="kv" style="border:0"><span>on-chain ✓</span><b id="bChain">—</b></div>
|
||||
<div class="mono" id="boardAddr" style="margin-top:8px"></div>
|
||||
<h2 style="margin-top:16px">History (audit trail)</h2>
|
||||
<div class="hist" id="hist"><div class="sub">—</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="foot">
|
||||
Every Send / Receive is a <b>real on-chain update</b> of a <code>BchChannelBoard</code> covenant on chipnet
|
||||
(both-signed, strictly monotonic — a stale state can never overwrite a newer one). The channel's BTC value sits
|
||||
in a 2-of-2 on Bitcoin and settles there; BCH only records the state. This is T6 — the Lightning model with its
|
||||
weakest layer (the private node) replaced by a public chain. ADR-0046 · spec §16.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const EXPLORER = "https://chipnet.imaginary.cash/tx/";
|
||||
let busy = false;
|
||||
|
||||
function setStatus(msg, spin){ $("status").innerHTML = msg ? ((spin?'<span class="spin"></span>':'') + msg) : ""; }
|
||||
function fmt(n){ return Number(n).toLocaleString(); }
|
||||
|
||||
function render(s){
|
||||
const open = s && s.open;
|
||||
$("btnSend").disabled = !open || busy;
|
||||
$("btnRecv").disabled = !open || busy;
|
||||
$("btnOpen").disabled = open || busy;
|
||||
if(!open){ $("bal").innerHTML = '— <small>sat</small>'; $("chanSub").textContent = "No channel open.";
|
||||
$("bState").textContent=$("bA").textContent=$("bB").textContent=$("bChain").textContent="—"; $("boardAddr").textContent=""; $("hist").innerHTML='<div class="sub">—</div>'; return; }
|
||||
$("bal").innerHTML = fmt(s.balA) + ' <small>sat</small>';
|
||||
$("chanSub").textContent = "channel open · peer holds " + fmt(s.balB) + " sat";
|
||||
$("bState").textContent = "#" + s.state;
|
||||
$("bA").textContent = fmt(s.balA);
|
||||
$("bB").textContent = fmt(s.balB);
|
||||
$("bChain").textContent = s.onchain ? ("#" + s.onchain.state + " ✓ matches") : "reading…";
|
||||
$("boardAddr").textContent = s.boardAddr || "";
|
||||
const h = (s.history||[]).slice().reverse().map(e =>
|
||||
`<div class="h"><span><b style="color:var(--acid)">#${e.state}</b> ${e.action}</span>`+
|
||||
`<a href="${EXPLORER}${e.txid}" target="_blank" rel="noopener">${e.txid.slice(0,10)}…</a></div>`).join("");
|
||||
$("hist").innerHTML = h || '<div class="sub">—</div>';
|
||||
}
|
||||
|
||||
async function refresh(){ try{ const r = await fetch('/api/state'); render(await r.json()); }catch(e){ setStatus("offline: "+e.message); } }
|
||||
|
||||
async function act(url, payload, label){
|
||||
if(busy) return; busy = true; render(window._last||{}); setStatus(label+"…", true);
|
||||
try{
|
||||
const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: payload?JSON.stringify(payload):undefined});
|
||||
const j = await r.json();
|
||||
if(j.error) throw new Error(j.error);
|
||||
window._last = j.open===false ? null : j;
|
||||
setStatus(label+" — on-chain ✓");
|
||||
}catch(e){ setStatus("✗ "+e.message); }
|
||||
busy = false; await refresh();
|
||||
}
|
||||
|
||||
$("btnOpen").onclick = () => act('/api/open', null, "Opening channel (fund BTC 2-of-2 → board state 0)");
|
||||
$("btnSend").onclick = () => act('/api/pay', {dir:'send', amount: Number($("amt").value)}, "Sending "+fmt($("amt").value)+" sat");
|
||||
$("btnRecv").onclick = () => act('/api/pay', {dir:'receive', amount: Number($("amt").value)}, "Receiving "+fmt($("amt").value)+" sat");
|
||||
$("btnReset").onclick = () => act('/api/reset', null, "Resetting local handle");
|
||||
|
||||
(async function loop(){ await refresh(); window._last = await (await fetch('/api/state')).json(); setInterval(()=>{ if(!busy) refresh(); }, 8000); })();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -732,7 +732,26 @@ wiki/
|
|||
|
||||
---
|
||||
|
||||
_Document version: V2.7 · Compiled 2026-05-19 · Verified against snapshots 0001–0021_
|
||||
## 16. Cross-Chain — BTC↔BCH (T6 / Swap / BTC-IOU)
|
||||
|
||||
> **Addendum (2026-06-27, snapshots 0172–0178).** This section is appended after the V2.7 body (verified against 0001–0021) to record the BTC↔BCH program. The authoritative design lives in `Parameters/T6_BitcoinCash_LN/` and the decision in **ADR-0046**; this is the spec-level summary.
|
||||
|
||||
**16.1 The governing constraint — the VM wall.** BTC has no covenants (only signatures, hashlocks, timelocks), so it cannot read another chain or bind a release to foreign state. Hence the **clerk/bailiff** split: BCH can *record, judge, and watch* a BTC channel, but only **BTC script can move BTC**. Every model below follows from this.
|
||||
|
||||
**16.2 Three models (ship all; axes = technical trustlessness × adoption).**
|
||||
- **Swap (Model 2)** — trustless HTLC atomic swap BTC↔BCH, then transact on-chain. Validated with real coins (T4: one hashlock over a signet BTC HTLC + chipnet `AtomicSwapHTLC`; claim + refund).
|
||||
- **BTC-IOU (Model 3)** — lock BTC → mint a bearer IOU CashToken (`DeviantWrapMintIOU`) → transact on-chain; backing publicly verifiable. Trust-minimized (see §16.4), the captured-audience on-ramp.
|
||||
- **T6 (Model 1)** — lock BTC in a 2-of-2; keep LN's off-chain commitments; **record channel state on a BCH covenant "board"** instead of a private LN node.
|
||||
|
||||
**16.3 T6 — Bitcoin Cash as a better Lightning.** A T6 channel = an LN channel that records its state on BCH instead of in a node. The board (`BchChannelBoard.cash`) is a both-signed, strictly-monotonic on-chain NFT: no lost-state, permissionless watchtower, no must-stay-online-to-defend; the user runs **two light wallets**, not a node. Validated end-to-end: BCH board (chipnet, 3 concurrent channels); BTC 2-of-2 funding + cooperative close (signet); trustless unilateral exit — **both** the penalty (stale broadcast seized via the revocation key) and the honest CSV-delayed self-close (signet); and **LN→T6 routing** (`BchChannelBoardHTLC.cash` `settleHtlc`) where one preimage settles both chains. *Residual friction (penalty, watchtower, sign-to-receive, routing) is the "BTC tax" — it exists only because the value stays on BTC, which cannot deny a stale state; it dissolves the moment value settles on BCH.*
|
||||
|
||||
**16.4 BTC-IOU trust floor — bonded M-of-N redemption.** A locked-BTC peg is necessarily trust-minimized (the VM wall), never trustless. The floor (tBTC-v2-style) is validated on both sides: **BTC release** via an M-of-N (2-of-3) multisig (signet; a single signature is rejected by consensus), and a **BCH slashable bond** (`BondedSignerSlash.cash`) where signer **equivocation** (two signatures over one `redeemId` with conflicting payloads) is a fraud proof anyone can submit to slash the bond. BTC enforces the release threshold; BCH enforces the punishment BTC cannot.
|
||||
|
||||
**16.5 Strategy.** Lead with **demonstration, not argument** — a conditioned audience integrates an experience, not a proof. See `Parameters/T6_BitcoinCash_LN/Why-demonstration-not-argument.md`.
|
||||
|
||||
---
|
||||
|
||||
_Document version: V2.7 · Compiled 2026-05-19 · Verified against snapshots 0001–0021 (§16 cross-chain addendum 2026-06-27)_
|
||||
_Changes from V2.6: §2.4 extended from four MBA classes to all eight asset classes the wallet recognizes. The four Token sub-classes (PTU, PTL, MTU, MTL) are now defined formally parallel to the four MBA classes (DEA, PLA, MCA, MTA), with the 2×2 quadrant (mint × backing) as the underlying classification structure. The classification algorithm — how the wallet detects which class a given CashToken UTXO belongs to — is now specified explicitly. §9 Wallet Display Rules table expanded to nine rows (four MBA classes, four Token sub-classes, plus MBA — Unverified for fallback). The wallet renders each of the eight classes with its own label and treatment. §2.4 renamed from "The Four MBA Classes — Canonical Definitions" to "Asset Class Definitions" to reflect the broader scope. §15 Glossary updated: single "Token" entry replaced with five entries (Token category + four sub-classes); MBA entry updated to reference the eight-class structure. The 4-letter naming form (PTUS, PTLS, MTUS, MTLS) is reserved for public taxonomy documents outside the spec where the derivation of the 3-letter form is explained; inside the spec only the 3-letter form is used._
|
||||
|
||||
_Document version: V2.6 · Compiled 2026-05-19 · Verified against snapshots 0001–0021_
|
||||
|
|
|
|||
195
scripts/btc-bch-htlc-swap.mjs
Normal file
195
scripts/btc-bch-htlc-swap.mjs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* btc-bch-htlc-swap.mjs — T4: the REAL trustless BTC↔BCH atomic swap (Model 2 with real BTC).
|
||||
*
|
||||
* T1 rehearsed this on chipnet alone (BCH↔FT, two on-chain HTLCs, one hashlock). T4 makes it
|
||||
* cross-chain with real coins: a signet BTC HTLC (P2WSH, scripts/lib/btc-htlc.mjs) and a chipnet
|
||||
* BCH HTLC (contracts/AtomicSwapHTLC.cash) share ONE sha256 hashlock. Revealing the preimage to
|
||||
* claim one leg exposes it for the other — that is the atomicity. No oracle, no wrapped token,
|
||||
* no custodian; each chain enforces its own leg.
|
||||
*
|
||||
* Parties (disjoint keys from the T6c honest-close run, so this can run concurrently):
|
||||
* Alice — has BTC, wants BCH. signet=carol (funds+refunds BTC) · chipnet=c (receives BCH). Generates S.
|
||||
* Bob — has BCH, wants BTC. chipnet=b (funds+refunds BCH) · signet=bob (receives BTC).
|
||||
*
|
||||
* Phase 1 — happy path (no wait):
|
||||
* 1. Alice funds the BTC HTLC on signet: claim→Bob with S, refund→Alice after T_btc (long).
|
||||
* 2. Bob funds the BCH HTLC on chipnet: claim→Alice with S, refund→Bob after T_bch (short < T_btc).
|
||||
* 3. Alice CLAIMS the BCH leg, revealing S on chipnet.
|
||||
* 4. Bob READS S off the chipnet claim tx and CLAIMS the BTC leg on signet.
|
||||
* Timelock rule: T_btc (wall-clock) outlasts T_bch so the secret-holder can't grief (T1 > T2).
|
||||
*
|
||||
* Phase 2 — BTC refund safety net (block-wait, short CLTV):
|
||||
* Alice funds a fresh BTC HTLC with CLTV = tip+2; the refund is REJECTED before the timeout
|
||||
* (non-final) and SUCCEEDS after — the funder always recovers if the counterparty vanishes.
|
||||
* (The BCH-side refund is already validated in T1 R1; not re-run here.)
|
||||
*
|
||||
* Run (network — sandbox disabled): node --import tsx scripts/btc-bch-htlc-swap.mjs --yes
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { TestNetWallet } from 'mainnet-js';
|
||||
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
|
||||
import { sha256 as laSha256 } from '@bitauth/libauth';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import ECPairFactory from 'ecpair';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import { htlcScript, htlcAddress, buildSpendTx, utxosOf, broadcast as btcBroadcast, tipHeight } from './lib/btc-htlc.mjs';
|
||||
|
||||
bitcoin.initEccLib(ecc);
|
||||
const ECPair = ECPairFactory(ecc);
|
||||
const NET = bitcoin.networks.testnet;
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
const ART = join(ROOT, 'contracts/dist/AtomicSwapHTLC.json');
|
||||
|
||||
const SWAP_BTC = 6000, SWAP_BCH = 20000n, MF = 2000n, BTC_FEE = 400;
|
||||
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 hex = (u) => Buffer.from(u).toString('hex');
|
||||
const sha256 = (b) => laSha256.hash(b);
|
||||
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 btcTxStatus(txid) { return (await (await fetch(`https://mempool.space/signet/api/tx/${txid}`)).json()).status; }
|
||||
async function waitBtcUtxo(addr, pred, label) { for (let i = 0; i < 40; i++) { await sleep(8000); const u = (await utxosOf(addr)).find(pred); if (u) return u; } throw new Error(`${label}: utxo not visible`); }
|
||||
async function waitChipUtxo(provider, addr, pred, label) { for (let i = 0; i < 12; i++) { const u = (await provider.getUtxos(addr)).find(pred); if (u) return u; await sleep(6000); } throw new Error(`${label}: chipnet utxo not visible`); }
|
||||
function extractPreimage(rawHex, H) { const b = Buffer.from(rawHex, 'hex'); const Hh = hex(H); for (let i = 0; i + 32 <= b.length; i++) { const c = b.subarray(i, i + 32); if (hex(sha256(c)) === Hh) return new Uint8Array(c); } return null; }
|
||||
|
||||
async function main() {
|
||||
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes to broadcast on signet + chipnet.'); return; }
|
||||
const art = JSON.parse(readFileSync(ART, 'utf8'));
|
||||
|
||||
// signet keys: Alice=carol (BTC funder), Bob=bob (BTC receiver)
|
||||
const carol = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-carol.json'), 'utf8')).wif, NET);
|
||||
const bobBtc = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-bob.json'), 'utf8')).wif, NET);
|
||||
const carolPub = Buffer.from(carol.publicKey), bobBtcPub = Buffer.from(bobBtc.publicKey);
|
||||
const carolA = bitcoin.payments.p2wpkh({ pubkey: carolPub, network: NET });
|
||||
const bobBtcA = bitcoin.payments.p2wpkh({ pubkey: bobBtcPub, network: NET });
|
||||
|
||||
// chipnet keys: Alice=c (BCH receiver/claimer), Bob=b (BCH funder/refunder)
|
||||
const Ac = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-c.json'), 'utf8')).wif);
|
||||
const Bb = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-b.json'), 'utf8')).wif);
|
||||
const provider = new ElectrumNetworkProvider('chipnet');
|
||||
const sigAc = new SignatureTemplate(Ac.privateKey);
|
||||
const sigBb = new SignatureTemplate(Bb.privateKey);
|
||||
async function pickChip(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 }; }
|
||||
const bchBal = async (w) => (await provider.getUtxos(w.cashaddr)).filter((x) => !x.token).reduce((s, x) => s + BigInt(x.satoshis), 0n);
|
||||
|
||||
// ---- secret + parameters ----
|
||||
const S = new Uint8Array(randomBytes(32)); const H = sha256(S);
|
||||
const hChip = await provider.getBlockHeight();
|
||||
const tipBtc = await tipHeight();
|
||||
const T_BCH = hChip + 20, T_BTC = tipBtc + 40;
|
||||
console.log(`Alice BTC ${carolA.address} | BCH ${Ac.cashaddr}`);
|
||||
console.log(`Bob BCH ${Bb.cashaddr} | BTC ${bobBtcA.address}`);
|
||||
console.log(`H = sha256(S) = ${hex(H).slice(0, 24)}… · T_BCH ${T_BCH} (chipnet) · T_BTC ${T_BTC} (signet)`);
|
||||
|
||||
// ============ Phase 1: happy-path cross-chain swap ============
|
||||
// 1. Alice funds the BTC HTLC (claim→Bob with S, refund→Alice after T_BTC)
|
||||
step(`1. Alice funds the BTC HTLC (${SWAP_BTC} sat) on signet — claim→Bob with S / refund→Alice @ ${T_BTC}`);
|
||||
const wScript = htlcScript({ hashlock: Buffer.from(H), claimerPub: bobBtcPub, refunderPub: carolPub, timeout: T_BTC });
|
||||
const htlcBtcAddr = htlcAddress(wScript);
|
||||
console.log(` BTC HTLC ${htlcBtcAddr}`);
|
||||
{
|
||||
const cu = (await utxosOf(carolA.address)).sort((a, b) => b.value - a.value);
|
||||
const ins = []; let amt = 0; for (const u of cu) { ins.push(u); amt += u.value; if (amt >= SWAP_BTC + BTC_FEE) break; }
|
||||
if (amt < SWAP_BTC + BTC_FEE) throw new Error(`carol ${amt} < ${SWAP_BTC + BTC_FEE}`);
|
||||
const psbt = new bitcoin.Psbt({ network: NET });
|
||||
for (const u of ins) psbt.addInput({ hash: u.txid, index: u.vout, witnessUtxo: { script: carolA.output, value: BigInt(u.value) } });
|
||||
psbt.addOutput({ address: htlcBtcAddr, value: BigInt(SWAP_BTC) });
|
||||
const ch = amt - SWAP_BTC - BTC_FEE; if (ch >= 294) psbt.addOutput({ address: carolA.address, value: BigInt(ch) });
|
||||
ins.forEach((_, i) => psbt.signInput(i, carol)); psbt.finalizeAllInputs();
|
||||
console.log(` funded ${await btcBroadcast(psbt.extractTransaction().toHex())}`);
|
||||
}
|
||||
const htlcBtcUtxo = await waitBtcUtxo(htlcBtcAddr, (u) => u.value === SWAP_BTC, 'btc-htlc');
|
||||
console.log(` BTC leg locked ${htlcBtcUtxo.txid}:${htlcBtcUtxo.vout}`);
|
||||
|
||||
// 2. Bob funds the BCH HTLC (claim→Alice with S, refund→Bob after T_BCH)
|
||||
step(`2. Bob funds the BCH HTLC (${SWAP_BCH} sat) on chipnet — claim→Alice with S / refund→Bob @ ${T_BCH}`);
|
||||
const htlcBch = new Contract(art, [Ac.publicKeyHash, Bb.publicKeyHash, Buffer.from(H), BigInt(T_BCH)], { provider });
|
||||
console.log(` BCH HTLC ${htlcBch.address}`);
|
||||
{
|
||||
// fund from the LIVE electrum utxo view (not mainnet-js's cache, which goes stale after
|
||||
// heavy reuse of key b and silently builds txs from already-spent inputs).
|
||||
const { p, t } = await pickChip(Bb, SWAP_BCH + MF);
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
for (const x of p) tb.addInput(x, sigBb.unlockP2PKH());
|
||||
tb.addOutput({ to: htlcBch.address, amount: SWAP_BCH });
|
||||
tb.addOutput({ to: Bb.cashaddr, amount: t - SWAP_BCH - MF });
|
||||
console.log(` funded ${txidOf(await tb.send())}`); await sleep(6000);
|
||||
}
|
||||
|
||||
// 3. Alice claims the BCH leg, revealing S on chipnet
|
||||
step('3. Alice claims the BCH leg — reveals S on chipnet');
|
||||
const aliceBchBefore = await bchBal(Ac);
|
||||
let claimBchTxid;
|
||||
{
|
||||
const u = await waitChipUtxo(provider, htlcBch.address, (x) => !x.token && BigInt(x.satoshis) === SWAP_BCH, 'bch-htlc');
|
||||
const { p, t } = await pickChip(Ac, MF);
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u, htlcBch.unlock.claim(Ac.publicKeyCompressed, sigAc, S));
|
||||
for (const x of p) tb.addInput(x, sigAc.unlockP2PKH());
|
||||
tb.addOutput({ to: Ac.cashaddr, amount: BigInt(u.satoshis) + t - MF });
|
||||
claimBchTxid = txidOf(await tb.send()); await sleep(9000);
|
||||
console.log(` Alice claimed ${SWAP_BCH} sat → ${claimBchTxid} (S now public on chipnet)`);
|
||||
}
|
||||
|
||||
// 4. Bob reads S off chipnet and claims the BTC leg
|
||||
step('4. Bob reads S off chipnet and claims the BTC leg on signet');
|
||||
const rawBch = await provider.getRawTransaction(claimBchTxid);
|
||||
const Sread = extractPreimage(rawBch, H);
|
||||
if (!Sread) throw new Error('could not read S off the chipnet claim tx');
|
||||
console.log(` Bob extracted S = ${hex(Sread).slice(0, 24)}… (matches: ${hex(Sread) === hex(S)})`);
|
||||
const claimBtcTxid = await btcBroadcast(buildSpendTx({ mode: 'claim', utxo: htlcBtcUtxo, witnessScript: wScript, keyPair: bobBtc, preimage: Buffer.from(Sread), timeout: T_BTC, toAddress: bobBtcA.address, feeSats: BTC_FEE }));
|
||||
console.log(` Bob claimed the BTC leg ${claimBtcTxid} → ${SWAP_BTC - BTC_FEE} sat to Bob`);
|
||||
const aliceBchAfter = await bchBal(Ac);
|
||||
const bobGotBtc = !!(await waitBtcUtxo(bobBtcA.address, (u) => u.txid === claimBtcTxid, 'btc-claim'));
|
||||
|
||||
// ============ Phase 2: BTC refund safety net (short CLTV block-wait) ============
|
||||
step('5. BTC refund safety net — fund a fresh HTLC (CLTV tip+2), reject early refund, then refund after maturity');
|
||||
const tip2 = await tipHeight(); const T_REFUND = tip2 + 2;
|
||||
const wRef = htlcScript({ hashlock: Buffer.from(sha256(new Uint8Array(randomBytes(32)))), claimerPub: bobBtcPub, refunderPub: carolPub, timeout: T_REFUND });
|
||||
const refAddr = htlcAddress(wRef);
|
||||
console.log(` refund-test HTLC ${refAddr} (CLTV ${T_REFUND}, tip ${tip2})`);
|
||||
let refFundTxid;
|
||||
{
|
||||
const cu = (await utxosOf(carolA.address)).sort((a, b) => b.value - a.value);
|
||||
const ins = []; let amt = 0; for (const u of cu) { ins.push(u); amt += u.value; if (amt >= SWAP_BTC + BTC_FEE) break; }
|
||||
const psbt = new bitcoin.Psbt({ network: NET });
|
||||
for (const u of ins) psbt.addInput({ hash: u.txid, index: u.vout, witnessUtxo: { script: carolA.output, value: BigInt(u.value) } });
|
||||
psbt.addOutput({ address: refAddr, value: BigInt(SWAP_BTC) });
|
||||
const ch = amt - SWAP_BTC - BTC_FEE; if (ch >= 294) psbt.addOutput({ address: carolA.address, value: BigInt(ch) });
|
||||
ins.forEach((_, i) => psbt.signInput(i, carol)); psbt.finalizeAllInputs();
|
||||
refFundTxid = await btcBroadcast(psbt.extractTransaction().toHex());
|
||||
console.log(` funded ${refFundTxid}`);
|
||||
}
|
||||
const refUtxo = await waitBtcUtxo(refAddr, (u) => u.txid === refFundTxid && u.value === SWAP_BTC, 'ref-htlc');
|
||||
const refundHex = buildSpendTx({ mode: 'refund', utxo: refUtxo, witnessScript: wRef, keyPair: carol, timeout: T_REFUND, toAddress: carolA.address, feeSats: BTC_FEE });
|
||||
let earlyRej = false, earlyMsg = '';
|
||||
try { const t = await btcBroadcast(refundHex); earlyMsg = `UNEXPECTEDLY ACCEPTED ${t}`; } catch (e) { earlyRej = /non-final|locktime|cltv/i.test(String(e.message)); earlyMsg = String(e.message).split('\n')[0]; }
|
||||
console.log(` early refund (before CLTV): ${earlyRej ? 'REJECTED as expected' : 'NOT rejected'} — ${earlyMsg}`);
|
||||
console.log(` waiting for tip ≥ ${T_REFUND}…`);
|
||||
let curTip = tip2;
|
||||
for (let i = 0; i < 120; i++) { curTip = await tipHeight(); if (curTip >= T_REFUND) break; if (i % 4 === 0) console.log(` tip ${curTip}/${T_REFUND}…`); await sleep(30000); }
|
||||
if (curTip < T_REFUND) throw new Error(`tip never reached ${T_REFUND}`);
|
||||
const refundTxid = await btcBroadcast(refundHex);
|
||||
console.log(` refunded after CLTV ${refundTxid} → Alice(carol) reclaimed ${SWAP_BTC - BTC_FEE} sat`);
|
||||
const refundOk = !!(await waitBtcUtxo(carolA.address, (u) => u.txid === refundTxid, 'refund'));
|
||||
|
||||
// ---- result ----
|
||||
step('RESULT — real BTC↔BCH atomic swap (T4)');
|
||||
const checks = [
|
||||
[`Alice received ${SWAP_BCH} sat BCH on chipnet (claim revealed S)`, aliceBchAfter > aliceBchBefore],
|
||||
['preimage matched across chains (one hashlock governs both legs)', !!Sread && hex(Sread) === hex(S)],
|
||||
['Bob claimed the real signet BTC leg with the revealed S (atomicity)', bobGotBtc],
|
||||
['BTC refund rejected before CLTV, succeeded after (funder safety net)', earlyRej && refundOk],
|
||||
];
|
||||
let ok = true; for (const [l, p] of checks) { console.log(` ${p ? 'PASS' : 'FAIL'} ${l}`); if (!p) ok = false; }
|
||||
divider();
|
||||
console.log(ok
|
||||
? `T4 VALIDATED — trustless BTC↔BCH atomic swap with real coins. One sha256 hashlock governed\nboth legs: Alice's BCH claim (${claimBchTxid}) revealed S; Bob used it to claim the signet BTC\nleg (${claimBtcTxid}). The BTC refund path (${refundTxid}) protects a funder whose counterparty\nvanishes. No oracle, no wrapped token, no custodian — each chain enforced its own leg. This is\nModel 2 (swap into BCH, transact on-chain) on real Bitcoin.`
|
||||
: 'SOME CHECKS FAILED.');
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });
|
||||
88
scripts/btc-claim-htlc.mjs
Normal file
88
scripts/btc-claim-htlc.mjs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* btc-claim-htlc.mjs — claim a signet BTC HTLC using the preimage REVEALED ON THE BCH BOARD.
|
||||
*
|
||||
* This is the "link" half of T6 cross-chain routing, isolated as a reusable primitive: Bob
|
||||
* (the bridge) reads the preimage S off a public BCH settle tx — he never held S; he learns it
|
||||
* from the chain — and uses it to claim Carol's upstream BTC HTLC on signet. One preimage,
|
||||
* two chains. (It also serves as the recovery path for an HTLC whose downstream BCH leg already
|
||||
* settled but whose upstream claim hasn't been broadcast.)
|
||||
*
|
||||
* S is extracted with NO prior knowledge: scan the BCH settle tx for a 32-byte value X such
|
||||
* that sha256(X) also appears in the same tx — i.e. (preimage, paymentHash) — which is exactly
|
||||
* the (S, H) pair the settleHtlc input reveals.
|
||||
*
|
||||
* Usage (defaults = run-1 of btc-signet-ln-leg):
|
||||
* node --import tsx scripts/btc-claim-htlc.mjs --yes \
|
||||
* [bchSettleTxid] [btcHtlcTxid:vout] [cltvHeight]
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ElectrumNetworkProvider } from 'cashscript';
|
||||
import { sha256 as laSha256 } from '@bitauth/libauth';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import ECPairFactory from 'ecpair';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import { htlcScript, htlcAddress, buildSpendTx, utxosOf, broadcast as btcBroadcast } from './lib/btc-htlc.mjs';
|
||||
|
||||
bitcoin.initEccLib(ecc);
|
||||
const ECPair = ECPairFactory(ecc);
|
||||
const NET = bitcoin.networks.testnet;
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
const hex = (u) => Buffer.from(u).toString('hex');
|
||||
const sha256 = (b) => laSha256.hash(b);
|
||||
const CLAIM_FEE = 400;
|
||||
|
||||
// defaults: run-1 of btc-signet-ln-leg.mjs
|
||||
const args = process.argv.slice(2).filter((a) => a !== '--yes');
|
||||
const SETTLE = args[0] || '19bcd25bcb8799a231a44149e269d03ddb899503b885d7266c8b4ec0c0131b9c';
|
||||
const [HTLC_TXID, HTLC_VOUT] = (args[1] || 'db5615374ef9249bd564e3ae05852c28450fe679a51c847805c71ca4f4c878b6:0').split(':');
|
||||
const CLTV = Number(args[2] || 310566);
|
||||
|
||||
/** Find (S, H) in a raw tx: a 32-byte X whose sha256(X) also appears as 32 bytes in the tx. */
|
||||
function findPreimagePair(rawHex) {
|
||||
const b = Buffer.from(rawHex, 'hex');
|
||||
const windows = new Set();
|
||||
for (let i = 0; i + 32 <= b.length; i++) windows.add(hex(b.subarray(i, i + 32)));
|
||||
for (let i = 0; i + 32 <= b.length; i++) {
|
||||
const X = b.subarray(i, i + 32);
|
||||
const h = hex(sha256(X));
|
||||
if (windows.has(h) && h !== hex(X)) return { S: new Uint8Array(X), H: Buffer.from(h, 'hex') };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes to broadcast on signet.'); return; }
|
||||
const carol = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-carol.json'), 'utf8')).wif, NET);
|
||||
const bob = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-bob.json'), 'utf8')).wif, NET);
|
||||
const carolPub = Buffer.from(carol.publicKey), bobPub = Buffer.from(bob.publicKey);
|
||||
const bobA = bitcoin.payments.p2wpkh({ pubkey: bobPub, network: NET });
|
||||
|
||||
console.log(`Reading S off the BCH settle tx ${SETTLE.slice(0, 16)}… (chipnet)`);
|
||||
const provider = new ElectrumNetworkProvider('chipnet');
|
||||
const raw = await provider.getRawTransaction(SETTLE);
|
||||
const pair = findPreimagePair(raw);
|
||||
if (!pair) throw new Error('no (preimage, paymentHash) pair found in the BCH settle tx');
|
||||
const { S, H } = pair;
|
||||
console.log(` found preimage S = ${hex(S).slice(0, 24)}… with H = sha256(S) = ${hex(H).slice(0, 24)}…`);
|
||||
|
||||
// rebuild the exact HTLC witness script Carol funded, from the chain-sourced H
|
||||
const wScript = htlcScript({ hashlock: H, claimerPub: bobPub, refunderPub: carolPub, timeout: CLTV });
|
||||
const addr = htlcAddress(wScript);
|
||||
console.log(` rebuilt HTLC P2WSH ${addr}`);
|
||||
const u = (await utxosOf(addr)).find((x) => x.txid === HTLC_TXID && x.vout === Number(HTLC_VOUT));
|
||||
if (!u) throw new Error(`HTLC utxo ${HTLC_TXID}:${HTLC_VOUT} not found at ${addr} (spent/refunded?)`);
|
||||
console.log(` HTLC utxo ${u.txid}:${u.vout} = ${u.value} sat (unspent)`);
|
||||
|
||||
const claimHex = buildSpendTx({ mode: 'claim', utxo: u, witnessScript: wScript, keyPair: bob, preimage: Buffer.from(S), timeout: CLTV, toAddress: bobA.address, feeSats: CLAIM_FEE });
|
||||
const claimTxid = await btcBroadcast(claimHex);
|
||||
|
||||
console.log('\n================================================================');
|
||||
console.log(`CLAIMED — Bob spent the BTC HTLC using S read off the BCH board.`);
|
||||
console.log(` BCH reveal : ${SETTLE}`);
|
||||
console.log(` BTC claim : ${claimTxid} (${u.value - CLAIM_FEE} sat → ${bobA.address})`);
|
||||
console.log('One preimage linked both chains: BCH settle ⇒ BTC settles. The clerk records, the');
|
||||
console.log('bailiff pays. Cross-chain LN→T6 routing loop closed with real coins.');
|
||||
}
|
||||
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });
|
||||
123
scripts/btc-signet-honest-close.mjs
Normal file
123
scripts/btc-signet-honest-close.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* btc-signet-honest-close.mjs — T6c LIVENESS MIRROR: the HONEST CSV-delayed self-close.
|
||||
*
|
||||
* btc-signet-penalty.mjs validated the IF branch (a STALE commitment is penalised immediately
|
||||
* by the counterparty with the revocation key). This validates the ELSE branch: the HONEST
|
||||
* closer, after the CSV delay, takes their OWN output. Same revocation script, the other path:
|
||||
*
|
||||
* OP_IF <revocationPub> OP_CHECKSIG ← penalty (immediate, counterparty) [0175]
|
||||
* OP_ELSE <CSV> OP_CHECKSEQUENCEVERIFY OP_DROP <closerPub> OP_CHECKSIG ← honest self-close
|
||||
* OP_ENDIF
|
||||
*
|
||||
* The CSV delay is the window in which a penalty COULD apply had the state been stale; for the
|
||||
* latest (un-revoked) state no penalty exists, so the closer simply waits it out and sweeps.
|
||||
*
|
||||
* Proof has two parts:
|
||||
* 1. BEFORE maturity the ELSE sweep is REJECTED (BIP68 non-final) — the delay is enforced.
|
||||
* 2. AFTER the output is CSV-deep, the ELSE sweep SUCCEEDS — the closer recovers their funds.
|
||||
*
|
||||
* Block-wait step (signet ~10 min/block). CSV defaults to 2 to keep the wait bounded.
|
||||
* Run (network — sandbox disabled): node --import tsx scripts/btc-signet-honest-close.mjs --yes
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import ECPairFactory from 'ecpair';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
|
||||
bitcoin.initEccLib(ecc);
|
||||
const ECPair = ECPairFactory(ecc);
|
||||
const NET = bitcoin.networks.testnet;
|
||||
const API = 'https://mempool.space/signet/api';
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
const { OP_IF, OP_ELSE, OP_ENDIF, OP_CHECKSIG, OP_CHECKSEQUENCEVERIFY, OP_DROP } = bitcoin.script.OPS;
|
||||
const SIGHASH_ALL = bitcoin.Transaction.SIGHASH_ALL;
|
||||
|
||||
const CSV = Number(process.env.CSV || 2);
|
||||
const FUND = 6000, FUND_FEE = 400, SWEEP_FEE = 400;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const utxos = (a) => fetch(`${API}/address/${a}/utxo`).then((r) => r.json());
|
||||
async function broadcast(hex) { const r = await fetch(`${API}/tx`, { method: 'POST', body: hex }); const b = await r.text(); if (!r.ok) throw new Error(b); return b; }
|
||||
async function tip() { return Number(await (await fetch(`${API}/blocks/tip/height`)).text()); }
|
||||
async function txStatus(txid) { return (await (await fetch(`${API}/tx/${txid}`)).json()).status; }
|
||||
async function waitUtxo(addr, pred, label) { for (let i = 0; i < 40; i++) { await sleep(8000); const u = (await utxos(addr)).find(pred); if (u) return u; } throw new Error(`${label}: utxo not visible`); }
|
||||
|
||||
function revScript(revPub, closerPub) {
|
||||
return bitcoin.script.compile([OP_IF, revPub, OP_CHECKSIG, OP_ELSE, bitcoin.script.number.encode(CSV), OP_CHECKSEQUENCEVERIFY, OP_DROP, closerPub, OP_CHECKSIG, OP_ENDIF]);
|
||||
}
|
||||
|
||||
/** Build the honest ELSE self-close sweep (sequence = CSV → enables BIP68 relative locktime). */
|
||||
function buildElseSweep({ fundTxid, vout, value, rScript, closer, toAddr }) {
|
||||
const tx = new bitcoin.Transaction(); tx.version = 2;
|
||||
tx.addInput(Buffer.from(fundTxid, 'hex').reverse(), vout, CSV); // nSequence = CSV (BIP68 relative blocks)
|
||||
tx.addOutput(bitcoin.address.toOutputScript(toAddr, NET), BigInt(value - SWEEP_FEE));
|
||||
const sighash = tx.hashForWitnessV0(0, rScript, BigInt(value), SIGHASH_ALL);
|
||||
const sig = bitcoin.script.signature.encode(Buffer.from(closer.sign(sighash)), SIGHASH_ALL);
|
||||
tx.ins[0].witness = [sig, Buffer.alloc(0), rScript]; // empty → OP_IF is false → ELSE branch
|
||||
return tx.toHex();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes (sandbox disabled).'); return; }
|
||||
const closer = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-keys.json'), 'utf8')).wif, NET);
|
||||
const revKey = ECPair.makeRandom({ network: NET }); // this state's revocation key (NOT revealed → no penalty)
|
||||
const closerPub = Buffer.from(closer.publicKey);
|
||||
const closerA = bitcoin.payments.p2wpkh({ pubkey: closerPub, network: NET });
|
||||
const rScript = revScript(Buffer.from(revKey.publicKey), closerPub);
|
||||
const rP2wsh = bitcoin.payments.p2wsh({ redeem: { output: rScript }, network: NET });
|
||||
console.log(`Closer ${closerA.address}\nRevocation output (honest self-close, CSV=${CSV}) ${rP2wsh.address}`);
|
||||
|
||||
// ---- fund the revocation output directly (models the closer's own commitment output) ----
|
||||
console.log('\n[fund] revocation output…');
|
||||
const ins = []; let inAmt = 0;
|
||||
for (const u of (await utxos(closerA.address)).sort((a, b) => b.value - a.value)) { ins.push(u); inAmt += u.value; if (inAmt >= FUND + FUND_FEE) break; }
|
||||
if (inAmt < FUND + FUND_FEE) throw new Error(`closer ${inAmt} < ${FUND + FUND_FEE}`);
|
||||
let fundTxid;
|
||||
{
|
||||
const psbt = new bitcoin.Psbt({ network: NET });
|
||||
for (const u of ins) psbt.addInput({ hash: u.txid, index: u.vout, witnessUtxo: { script: closerA.output, value: BigInt(u.value) } });
|
||||
psbt.addOutput({ address: rP2wsh.address, value: BigInt(FUND) });
|
||||
const change = inAmt - FUND - FUND_FEE; if (change >= 294) psbt.addOutput({ address: closerA.address, value: BigInt(change) });
|
||||
ins.forEach((_, i) => psbt.signInput(i, closer)); psbt.finalizeAllInputs();
|
||||
fundTxid = await broadcast(psbt.extractTransaction().toHex());
|
||||
console.log(`[fund] ${fundTxid}`);
|
||||
}
|
||||
const fundUtxo = await waitUtxo(rP2wsh.address, (u) => u.txid === fundTxid && u.value === FUND, 'fund');
|
||||
console.log(`[fund] revocation output ${fundUtxo.txid}:${fundUtxo.vout} (${fundUtxo.value} sat)`);
|
||||
|
||||
// ---- (1) BEFORE maturity: the ELSE sweep must be REJECTED (BIP68 non-final) ----
|
||||
console.log('\n[before] attempting the honest sweep BEFORE the CSV delay (expect rejection)…');
|
||||
const sweepHex = buildElseSweep({ fundTxid, vout: fundUtxo.vout, value: FUND, rScript, closer, toAddr: closerA.address });
|
||||
let earlyRejected = false, earlyMsg = '';
|
||||
try { const t = await broadcast(sweepHex); earlyMsg = `UNEXPECTEDLY ACCEPTED ${t}`; }
|
||||
catch (e) { earlyRejected = /non-bip68|non-final|locktime|csv|sequence/i.test(String(e.message)); earlyMsg = String(e.message).split('\n')[0]; }
|
||||
console.log(`[before] ${earlyRejected ? 'REJECTED as expected' : 'NOT rejected as expected'} — ${earlyMsg}`);
|
||||
|
||||
// ---- wait until the funding output is CSV-deep ----
|
||||
console.log(`\n[wait] waiting for the revocation output to reach ${CSV} confirmations…`);
|
||||
let depth = 0;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const st = await txStatus(fundTxid);
|
||||
if (st.confirmed) { depth = (await tip()) - st.block_height + 1; if (depth >= CSV) break; }
|
||||
if (i % 4 === 0) console.log(`[wait] depth ${depth}/${CSV}…`);
|
||||
await sleep(30000);
|
||||
}
|
||||
if (depth < CSV) throw new Error(`output never reached ${CSV} confirmations (got ${depth})`);
|
||||
console.log(`[wait] output is ${depth} confs deep — CSV satisfied`);
|
||||
|
||||
// ---- (2) AFTER maturity: the honest ELSE sweep SUCCEEDS ----
|
||||
console.log('\n[after] broadcasting the honest self-close sweep (ELSE branch)…');
|
||||
const sweepTxid = await broadcast(sweepHex);
|
||||
console.log(`[after] swept ${sweepTxid} → ${FUND - SWEEP_FEE} sat to ${closerA.address}`);
|
||||
await sleep(8000);
|
||||
const swept = (await utxos(closerA.address)).some((u) => u.txid === sweepTxid);
|
||||
|
||||
console.log('\n================================================================');
|
||||
const ok = earlyRejected && swept;
|
||||
console.log(ok
|
||||
? `T6c HONEST-CLOSE (liveness) VALIDATED on signet — the ELSE branch works:\n • before the CSV delay the self-close was REJECTED (BIP68 enforced the wait)\n • after the output was ${CSV} confs deep the closer swept their OWN funds (${sweepTxid})\nWith the penalty path (0175) this completes the trustless unilateral-exit pair: honest closers\nwait out the CSV and recover; cheaters who broadcast a stale state are penalised immediately.`
|
||||
: `INCOMPLETE — earlyRejected=${earlyRejected}, swept=${swept}`);
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });
|
||||
189
scripts/btc-signet-ln-leg.mjs
Normal file
189
scripts/btc-signet-ln-leg.mjs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* btc-signet-ln-leg.mjs — Step 2 of T6: CLOSE THE CROSS-CHAIN ROUTING LOOP with real coins.
|
||||
*
|
||||
* Step 1 (chipnet-ln-to-t6-routing.mjs) proved the T6/BCH leg in isolation: a routed payment
|
||||
* received into a T6 channel, the preimage revealed on the BCH board. This script closes the
|
||||
* loop: the SAME preimage that settles on BCH also claims a REAL BTC HTLC on signet. One
|
||||
* secret, two chains — the actual interop proof.
|
||||
*
|
||||
* Scenario: Carol pays Alice, routed through Bob (the bridge node).
|
||||
* 1. Alice's invoice: preimage S, paymentHash H = sha256(S).
|
||||
* 2. Carol LOCKS a BTC HTLC on signet under H (claimable by Bob with S; refund to Carol on
|
||||
* CLTV timeout). ← the upstream leg, real BTC.
|
||||
* 3. Bob FORWARDS the payment into the T6 channel: settleHtlc on the BCH board credits Alice
|
||||
* and REVEALS S on-chain (chipnet). ← the downstream leg.
|
||||
* 4. Bob READS S off the BCH chain (scans the settle tx for the 32-byte value whose
|
||||
* sha256 == H — he never had S; he reads it from the public board) and CLAIMS the BTC
|
||||
* HTLC. ← the link: BCH reveal ⇒ BTC settles.
|
||||
*
|
||||
* Faithful ordering: the BTC HTLC is funded FIRST and has a comfortable CLTV timeout, so Bob
|
||||
* can always claim upstream after he forwards downstream (no griefing window).
|
||||
*
|
||||
* Roles use separate keys per chain (chipnet b/c, signet carol/bob); the cross-chain link is
|
||||
* the shared PREIMAGE, not a shared key. (T6 can use one secp256k1 key on both chains; here we
|
||||
* keep the existing per-chain keys and let S do the linking.)
|
||||
*
|
||||
* Run (network — sandbox disabled): node --import tsx scripts/btc-signet-ln-leg.mjs --yes
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { TestNetWallet } from 'mainnet-js';
|
||||
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
|
||||
import { sha256 as laSha256 } from '@bitauth/libauth';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import ECPairFactory from 'ecpair';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import { htlcScript, htlcAddress, buildSpendTx, utxosOf, broadcast as btcBroadcast, tipHeight } from './lib/btc-htlc.mjs';
|
||||
|
||||
bitcoin.initEccLib(ecc);
|
||||
const ECPair = ECPairFactory(ecc);
|
||||
const NET = bitcoin.networks.testnet; // signet shares testnet params (tb1…)
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
const ART = join(ROOT, 'contracts/dist/BchChannelBoardHTLC.json');
|
||||
const STATE = join(ROOT, '.t6-routing-state.json');
|
||||
|
||||
// BCH board amounts (dust NFT, miner fee, routed credit) — same shape as Step 1.
|
||||
const DUST = 1000n, MF = 2000n, PAY = 3000n;
|
||||
// BTC HTLC amounts (sat).
|
||||
const BTC_LOCK = 6000, BTC_FUND_FEE = 400, BTC_CLAIM_FEE = 400, CLTV_WINDOW = 20;
|
||||
|
||||
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 hex = (u) => Buffer.from(u).toString('hex');
|
||||
const sha256 = (b) => laSha256.hash(b);
|
||||
function u32le(n) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(n), true); return b; }
|
||||
function u64le(n) { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }
|
||||
function cat(...a) { const o = new Uint8Array(a.reduce((s, x) => s + x.length, 0)); let p = 0; for (const x of a) { o.set(x, p); p += x.length; } return o; }
|
||||
const commit = (n, a, b) => cat(u32le(n), u64le(a), u64le(b));
|
||||
const readState = (h) => { const c = Buffer.from(h, 'hex'); return { n: c.readUInt32LE(0), a: c.readBigUInt64LE(4), b: c.readBigUInt64LE(12) }; };
|
||||
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 waitBtcUtxo(addr, pred, label) { for (let i = 0; i < 30; i++) { await sleep(8000); const u = (await utxosOf(addr)).find(pred); if (u) return u; } throw new Error(`${label}: utxo not visible`); }
|
||||
|
||||
/** Bob reads S off the BCH chain: scan the settle tx for a 32-byte value with sha256 == H. */
|
||||
function extractPreimage(rawHex, H) {
|
||||
const bytes = Buffer.from(rawHex, 'hex');
|
||||
const Hhex = hex(H);
|
||||
for (let i = 0; i + 32 <= bytes.length; i++) {
|
||||
const cand = bytes.subarray(i, i + 32);
|
||||
if (hex(sha256(cand)) === Hhex) return new Uint8Array(cand);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes to broadcast on signet + chipnet.'); return; }
|
||||
const art = JSON.parse(readFileSync(ART, 'utf8'));
|
||||
|
||||
// ---- keys ----
|
||||
const carolRec = JSON.parse(readFileSync(join(ROOT, '.btc-signet-carol.json'), 'utf8'));
|
||||
const bobRec = JSON.parse(readFileSync(join(ROOT, '.btc-signet-bob.json'), 'utf8'));
|
||||
const carol = ECPair.fromWIF(carolRec.wif, NET);
|
||||
const bob = ECPair.fromWIF(bobRec.wif, NET);
|
||||
const carolPub = Buffer.from(carol.publicKey), bobPub = Buffer.from(bob.publicKey);
|
||||
const carolA = bitcoin.payments.p2wpkh({ pubkey: carolPub, network: NET });
|
||||
const bobA = bitcoin.payments.p2wpkh({ pubkey: bobPub, network: NET });
|
||||
|
||||
const A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-c.json'), 'utf8')).wif); // Alice (BCH receiver)
|
||||
const Bch = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-b.json'), 'utf8')).wif); // Bob (BCH bridge)
|
||||
const provider = new ElectrumNetworkProvider('chipnet');
|
||||
const sigA = new SignatureTemplate(A.privateKey), sigB = new SignatureTemplate(Bch.privateKey);
|
||||
const board = new Contract(art, [A.publicKeyCompressed, Bch.publicKeyCompressed], { provider });
|
||||
|
||||
async function pickChip(need) { const u = (await provider.getUtxos(A.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() { let u = await A.getUtxos(); let e = u.filter((x) => x.vout === 0 && !x.token); if (e.length) return e[0]; await A.send([{ cashaddr: A.cashaddr, value: 3000n, unit: 'sat' }]); await sleep(12000); u = await A.getUtxos(); e = u.filter((x) => x.vout === 0 && !x.token); if (!e.length) throw new Error('splitter'); return e[0]; }
|
||||
const bUtxo = async (c) => (await provider.getUtxos(board.tokenAddress)).find((x) => x.token?.category === c && x.token?.nft);
|
||||
|
||||
// ---- Alice's invoice ----
|
||||
const S = new Uint8Array(randomBytes(32)); const H = sha256(S);
|
||||
console.log(`Alice(BCH receiver) ${A.cashaddr}`);
|
||||
console.log(`Bob (bridge) BCH ${Bch.cashaddr} | BTC ${bobA.address}`);
|
||||
console.log(`Carol(BTC payer) ${carolA.address}`);
|
||||
console.log(`\nAlice issues an invoice: H = sha256(S) = ${hex(H).slice(0, 24)}… (S kept secret by Alice)`);
|
||||
|
||||
// ============ 1. Carol LOCKS the BTC HTLC under H (real signet) ============
|
||||
step(`1. Carol locks a BTC HTLC (${BTC_LOCK} sat) under H — claimable by Bob with S, refund to Carol after CLTV`);
|
||||
const tip = await tipHeight();
|
||||
const cltv = tip + CLTV_WINDOW;
|
||||
const wScript = htlcScript({ hashlock: Buffer.from(H), claimerPub: bobPub, refunderPub: carolPub, timeout: cltv });
|
||||
const htlcAddr = htlcAddress(wScript);
|
||||
console.log(` HTLC P2WSH ${htlcAddr} (CLTV refund at height ${cltv}, tip ${tip})`);
|
||||
{
|
||||
const cUtxos = (await utxosOf(carolA.address)).sort((a, b) => b.value - a.value);
|
||||
const ins = []; let inAmt = 0; for (const u of cUtxos) { ins.push(u); inAmt += u.value; if (inAmt >= BTC_LOCK + BTC_FUND_FEE) break; }
|
||||
if (inAmt < BTC_LOCK + BTC_FUND_FEE) throw new Error(`Carol ${inAmt} < ${BTC_LOCK + BTC_FUND_FEE}`);
|
||||
const psbt = new bitcoin.Psbt({ network: NET });
|
||||
for (const u of ins) psbt.addInput({ hash: u.txid, index: u.vout, witnessUtxo: { script: carolA.output, value: BigInt(u.value) } });
|
||||
psbt.addOutput({ address: htlcAddr, value: BigInt(BTC_LOCK) });
|
||||
const change = inAmt - BTC_LOCK - BTC_FUND_FEE;
|
||||
if (change >= 294) psbt.addOutput({ address: carolA.address, value: BigInt(change) });
|
||||
ins.forEach((_, i) => psbt.signInput(i, carol)); psbt.finalizeAllInputs();
|
||||
console.log(` funded HTLC ${await btcBroadcast(psbt.extractTransaction().toHex())}`);
|
||||
}
|
||||
const htlcUtxo = await waitBtcUtxo(htlcAddr, (u) => u.value === BTC_LOCK, 'htlc');
|
||||
console.log(` HTLC locked ${htlcUtxo.txid}:${htlcUtxo.vout} (${htlcUtxo.value} sat) — upstream BTC leg live`);
|
||||
|
||||
// ============ 2. open the T6 channel on the BCH board ============
|
||||
step('2. Open the T6 channel — BCH board state 0 (Alice 5000 / Bob 5000)');
|
||||
const u0 = await eligible(); const category = u0.txid;
|
||||
{
|
||||
const { p, t } = await pickChip(DUST + MF);
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u0, sigA.unlockP2PKH()); for (const x of p) if (!(x.txid === u0.txid && x.vout === u0.vout)) tb.addInput(x, sigA.unlockP2PKH());
|
||||
tb.addOutput({ to: board.tokenAddress, amount: DUST, token: { amount: 0n, category, nft: { capability: 'mutable', commitment: hex(commit(0, 5000n, 5000n)) } } });
|
||||
tb.addOutput({ to: A.cashaddr, amount: BigInt(u0.satoshis) + t - DUST - MF });
|
||||
console.log(` opened ${txidOf(await tb.send())} · category ${category.slice(0, 12)}…`); await sleep(9000);
|
||||
}
|
||||
|
||||
// ============ 3. Bob forwards into the T6 channel: settleHtlc reveals S on BCH ============
|
||||
step(`3. Bob forwards the routed payment (${PAY}) into the T6 channel — settleHtlc reveals S on-chain`);
|
||||
let settleTxid;
|
||||
{
|
||||
const u = await bUtxo(category);
|
||||
const { p, t } = await pickChip(MF);
|
||||
const newA = 5000n + PAY, newB = 5000n - PAY;
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u, board.unlock.settleHtlc(sigA, sigB, S, Buffer.from(H), u32le(1), u64le(newA), u64le(newB)));
|
||||
for (const x of p) tb.addInput(x, sigA.unlockP2PKH());
|
||||
tb.addOutput({ to: board.tokenAddress, amount: DUST, token: { amount: 0n, category, nft: { capability: 'mutable', commitment: hex(commit(1, newA, newB)) } } });
|
||||
tb.addOutput({ to: A.cashaddr, amount: t + BigInt(u.satoshis) - DUST - MF });
|
||||
settleTxid = txidOf(await tb.send()); await sleep(9000);
|
||||
console.log(` settled ${settleTxid} — Alice credited ${PAY} on the board; S now public on BCH`);
|
||||
}
|
||||
|
||||
// ============ 4. Bob READS S off the BCH chain, then CLAIMS the BTC HTLC ============
|
||||
step('4. Bob reads S off the BCH board and claims the upstream BTC HTLC');
|
||||
const rawBch = await provider.getRawTransaction(settleTxid);
|
||||
const Sread = extractPreimage(rawBch, H);
|
||||
if (!Sread) throw new Error('could not extract preimage from the BCH settle tx');
|
||||
const matches = hex(Sread) === hex(S);
|
||||
console.log(` Bob extracted S from BCH tx ${settleTxid.slice(0, 12)}… : ${hex(Sread).slice(0, 24)}… (matches Alice's S: ${matches})`);
|
||||
let claimTxid;
|
||||
{
|
||||
const claimHex = buildSpendTx({ mode: 'claim', utxo: htlcUtxo, witnessScript: wScript, keyPair: bob, preimage: Buffer.from(Sread), timeout: cltv, toAddress: bobA.address, feeSats: BTC_CLAIM_FEE });
|
||||
claimTxid = await btcBroadcast(claimHex);
|
||||
console.log(` Bob CLAIMED the BTC HTLC ${claimTxid} → ${BTC_LOCK - BTC_CLAIM_FEE} sat to Bob, using S from the board`);
|
||||
}
|
||||
const claimed = await waitBtcUtxo(bobA.address, (u) => u.txid === claimTxid, 'claim');
|
||||
|
||||
// ---- persist + verify ----
|
||||
writeFileSync(STATE, JSON.stringify({ H: hex(H), category, bchOpen: category, settleTxid, htlc: `${htlcUtxo.txid}:${htlcUtxo.vout}`, claimTxid, when: 'recorded by btc-signet-ln-leg' }, null, 2));
|
||||
const bnft = await bUtxo(category); const st = readState(bnft.token.nft.commitment);
|
||||
|
||||
step('RESULT — cross-chain routing loop closed with real coins');
|
||||
const checks = [
|
||||
[`BCH leg: Alice credited ${PAY} on the board (state #${st.n}: Alice ${st.a}/Bob ${st.b})`, st.n === 1 && st.a === 5000n + PAY],
|
||||
['Bob extracted the preimage from the BCH chain (not pre-shared) and it matches H', !!Sread && hex(sha256(Sread)) === hex(H)],
|
||||
['BTC leg: Bob CLAIMED the real signet HTLC with that same preimage', !!claimed],
|
||||
];
|
||||
let ok = true; for (const [l, p] of checks) { console.log(` ${p ? 'PASS' : 'FAIL'} ${l}`); if (!p) ok = false; }
|
||||
divider();
|
||||
console.log(ok
|
||||
? `LN→T6 CROSS-CHAIN LOOP VALIDATED with real coins — one preimage S linked both chains:\n • BCH settle ${settleTxid}\n • BTC claim ${claimTxid}\nBob received the payment into the T6 channel on BCH (revealing S) and used that exact S,\nread back off the public BCH board, to claim Carol's real BTC HTLC on signet. The clerk\n(BCH) records and reveals; the bailiff (BTC) settles. State saved → ${STATE}`
|
||||
: 'SOME CHECKS FAILED.');
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });
|
||||
104
scripts/btc-signet-mofn-release.mjs
Normal file
104
scripts/btc-signet-mofn-release.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* btc-signet-mofn-release.mjs — Step 5 (BTC side): the M-of-N RELEASE leg of a bonded BTC peg.
|
||||
*
|
||||
* Model-3's locked BTC is custodied behind an M-of-N signer set and released with a BTC
|
||||
* multisig (the only thing BTC can enforce — it has no covenants). This validates that leg on
|
||||
* real signet: lock BTC in a 2-of-3 P2WSH; release it on redeem with 2 of the 3 signers; and
|
||||
* show that a single signature is INSUFFICIENT (the threshold is enforced by BTC consensus).
|
||||
*
|
||||
* The trust-MINIMIZATION (what stops the M signers from simply stealing the locked BTC) is the
|
||||
* SLASHABLE BOND on BCH — validated separately in chipnet-bonded-signer-test.mjs. Together:
|
||||
* BTC enforces the release threshold; BCH enforces the punishment for equivocation. That pair
|
||||
* is the bonded M-of-N redemption leg (the tBTC-v2-style trust floor for Model 3).
|
||||
*
|
||||
* Run (network — sandbox disabled): node --import tsx scripts/btc-signet-mofn-release.mjs --yes
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import ECPairFactory from 'ecpair';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
|
||||
bitcoin.initEccLib(ecc);
|
||||
const ECPair = ECPairFactory(ecc);
|
||||
const NET = bitcoin.networks.testnet;
|
||||
const API = 'https://mempool.space/signet/api';
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
const SIGHASH_ALL = bitcoin.Transaction.SIGHASH_ALL;
|
||||
const LOCK = 6000, FUND_FEE = 400, REL_FEE = 400;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const utxos = (a) => fetch(`${API}/address/${a}/utxo`).then((r) => r.json());
|
||||
async function broadcast(hex) { const r = await fetch(`${API}/tx`, { method: 'POST', body: hex }); const b = await r.text(); if (!r.ok) throw new Error(b); return b; }
|
||||
async function waitUtxo(addr, pred, label) { for (let i = 0; i < 40; i++) { await sleep(8000); const u = (await utxos(addr)).find(pred); if (u) return u; } throw new Error(`${label}: utxo not visible`); }
|
||||
const head1 = (e) => String(e?.message ?? e).split('\n')[0].slice(0, 120);
|
||||
|
||||
async function main() {
|
||||
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes (sandbox disabled).'); return; }
|
||||
const alice = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-keys.json'), 'utf8')).wif, NET);
|
||||
const bob = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-bob.json'), 'utf8')).wif, NET);
|
||||
const carol = ECPair.fromWIF(JSON.parse(readFileSync(join(ROOT, '.btc-signet-carol.json'), 'utf8')).wif, NET);
|
||||
const aPk = Buffer.from(alice.publicKey), bPk = Buffer.from(bob.publicKey), cPk = Buffer.from(carol.publicKey);
|
||||
const carolA = bitcoin.payments.p2wpkh({ pubkey: cPk, network: NET });
|
||||
const redeemer = bitcoin.payments.p2wpkh({ pubkey: bPk, network: NET }); // where the redeemed BTC goes
|
||||
|
||||
// 2-of-3 signer set
|
||||
const pubs = [aPk, bPk, cPk].sort((x, y) => x.compare(y));
|
||||
const ms = bitcoin.payments.p2ms({ m: 2, pubkeys: pubs, network: NET });
|
||||
const lock = bitcoin.payments.p2wsh({ redeem: ms, network: NET });
|
||||
console.log(`signer set (2-of-3) ${lock.address}\nredeemer ${redeemer.address}`);
|
||||
|
||||
// ---- lock BTC into the 2-of-3 (the peg's BTC reserve) ----
|
||||
console.log('\n[lock] funding the 2-of-3 reserve…');
|
||||
const ins = []; let amt = 0; for (const u of (await utxos(carolA.address)).sort((a, b) => b.value - a.value)) { ins.push(u); amt += u.value; if (amt >= LOCK + FUND_FEE) break; }
|
||||
if (amt < LOCK + FUND_FEE) throw new Error(`carol ${amt} < ${LOCK + FUND_FEE}`);
|
||||
let lockTxid;
|
||||
{
|
||||
const psbt = new bitcoin.Psbt({ network: NET });
|
||||
for (const u of ins) psbt.addInput({ hash: u.txid, index: u.vout, witnessUtxo: { script: carolA.output, value: BigInt(u.value) } });
|
||||
psbt.addOutput({ address: lock.address, value: BigInt(LOCK) });
|
||||
const ch = amt - LOCK - FUND_FEE; if (ch >= 294) psbt.addOutput({ address: carolA.address, value: BigInt(ch) });
|
||||
ins.forEach((_, i) => psbt.signInput(i, carol)); psbt.finalizeAllInputs();
|
||||
lockTxid = await broadcast(psbt.extractTransaction().toHex());
|
||||
console.log(`[lock] ${lockTxid}`);
|
||||
}
|
||||
const reserve = await waitUtxo(lock.address, (u) => u.txid === lockTxid && u.value === LOCK, 'lock');
|
||||
console.log(`[lock] reserve ${reserve.txid}:${reserve.vout} (${reserve.value} sat)`);
|
||||
|
||||
// ---- (1) insufficient: a SINGLE signature must be rejected ----
|
||||
console.log('\n[1-of-3] attempting release with ONE signature (expect rejection)…');
|
||||
let oneRejected = false, oneMsg = '';
|
||||
{
|
||||
const tx = new bitcoin.Transaction(); tx.version = 2;
|
||||
tx.addInput(Buffer.from(reserve.txid, 'hex').reverse(), reserve.vout, 0xffffffff);
|
||||
tx.addOutput(bitcoin.address.toOutputScript(redeemer.address, NET), BigInt(LOCK - REL_FEE));
|
||||
const sh = tx.hashForWitnessV0(0, ms.output, BigInt(LOCK), SIGHASH_ALL);
|
||||
const sigC = bitcoin.script.signature.encode(Buffer.from(carol.sign(sh)), SIGHASH_ALL);
|
||||
tx.ins[0].witness = [Buffer.alloc(0), sigC, ms.output]; // OP_0 + ONE sig (need two)
|
||||
try { const t = await broadcast(tx.toHex()); oneMsg = `UNEXPECTEDLY ACCEPTED ${t}`; }
|
||||
catch (e) { oneRejected = true; oneMsg = head1(e); }
|
||||
}
|
||||
console.log(`[1-of-3] ${oneRejected ? 'REJECTED as expected' : 'NOT rejected'} — ${oneMsg}`);
|
||||
|
||||
// ---- (2) release with 2-of-3 (carol + alice) ----
|
||||
console.log('\n[2-of-3] releasing the reserve with two signers (carol + alice)…');
|
||||
let relTxid;
|
||||
{
|
||||
const psbt = new bitcoin.Psbt({ network: NET });
|
||||
psbt.addInput({ hash: reserve.txid, index: reserve.vout, witnessUtxo: { script: lock.output, value: BigInt(LOCK) }, witnessScript: ms.output });
|
||||
psbt.addOutput({ address: redeemer.address, value: BigInt(LOCK - REL_FEE) });
|
||||
psbt.signInput(0, carol); psbt.signInput(0, alice); psbt.finalizeAllInputs();
|
||||
relTxid = await broadcast(psbt.extractTransaction().toHex());
|
||||
console.log(`[2-of-3] released ${relTxid} → ${LOCK - REL_FEE} sat to the redeemer`);
|
||||
}
|
||||
const released = await waitUtxo(redeemer.address, (u) => u.txid === relTxid, 'release');
|
||||
|
||||
console.log('\n================================================================');
|
||||
const ok = oneRejected && !!released;
|
||||
console.log(ok
|
||||
? `M-of-N RELEASE VALIDATED on signet — the peg's BTC reserve released only with the 2-of-3\nthreshold (a single signature was rejected by consensus). Release tx ${relTxid}.\nThis is the BTC side of Model-3 redemption; the BCH slashable bond (chipnet-bonded-signer-test)\nis what keeps those signers honest. Together = the bonded M-of-N redemption leg.`
|
||||
: `INCOMPLETE — oneRejected=${oneRejected}, released=${!!released}`);
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });
|
||||
165
scripts/chipnet-bonded-signer-test.mjs
Normal file
165
scripts/chipnet-bonded-signer-test.mjs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* chipnet-bonded-signer-test.mjs — Step 5 (core): validate the SLASHABLE BOND that makes a
|
||||
* BTC peg's M-of-N signer set trust-MINIMIZED instead of trusted.
|
||||
*
|
||||
* A trust-minimized BTC peg releases the locked BTC with an M-of-N multisig (BTC side, signet).
|
||||
* BTC can't punish a signer who cheats; BCH can. Each signer bonds here. EQUIVOCATION — two
|
||||
* valid signatures by the same signer over the SAME redeemId with CONFLICTING payloads — is a
|
||||
* cryptographic fraud proof anyone can submit to SLASH the bond. Honest signers reclaim their
|
||||
* bond after an unbond window.
|
||||
*
|
||||
* Checks (a guard only counts if it passes/fails for the COVENANT's reason):
|
||||
* S1 slash with conflicting double-sign (same id, different payload) → SUCCEEDS (bond seized)
|
||||
* S2 slash with the same message twice (id+payload identical) → REJECTED (payload equal)
|
||||
* S3 slash with two different redeemIds → REJECTED (ids differ)
|
||||
* W1 withdraw after the unbond height → SUCCEEDS (signer reclaims)
|
||||
* W2 withdraw before the unbond height → REJECTED (CLTV guard)
|
||||
*
|
||||
* Run: node --import tsx scripts/chipnet-bonded-signer-test.mjs --yes
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { TestNetWallet } from 'mainnet-js';
|
||||
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
|
||||
import { secp256k1, sha256 as laSha256 } from '@bitauth/libauth';
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
const ART = join(ROOT, 'contracts/dist/BondedSignerSlash.json');
|
||||
const KEYS = { c: join(ROOT, '.chipnet-keys-c.json'), e: join(ROOT, '.chipnet-keys-e.json') };
|
||||
|
||||
const BOND = 12000n, MF = 2000n;
|
||||
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 head1 = (e) => String(e?.message ?? e).split('\n')[0].slice(0, 110);
|
||||
const oracleSign = (priv, msg) => { const s = secp256k1.signMessageHashSchnorr(priv, laSha256.hash(msg)); if (typeof s === 'string') throw new Error(s); return s; };
|
||||
function cat(...a) { const o = new Uint8Array(a.reduce((s, x) => s + x.length, 0)); let p = 0; for (const x of a) { o.set(x, p); p += x.length; } return o; }
|
||||
const u64le = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; };
|
||||
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 main() {
|
||||
if (!process.argv.includes('--yes')) { console.log('Re-run with --yes to broadcast on chipnet.'); return; }
|
||||
const art = JSON.parse(readFileSync(ART, 'utf8'));
|
||||
for (const [k, f] of Object.entries(KEYS)) if (!existsSync(f)) throw new Error(`missing key ${k} (${f})`);
|
||||
const C = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c, 'utf8')).wif); // challenger / funder / fees
|
||||
const E = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.e, 'utf8')).wif); // the bonded signer
|
||||
const provider = new ElectrumNetworkProvider('chipnet');
|
||||
const sigC = new SignatureTemplate(C.privateKey);
|
||||
const sigE = new SignatureTemplate(E.privateKey);
|
||||
const signerPk = E.publicKeyCompressed;
|
||||
const bond = (unbondHeight) => new Contract(art, [signerPk, BigInt(unbondHeight)], { provider });
|
||||
|
||||
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 fundBond(c) { // fund via TransactionBuilder from the LIVE utxo view (reliable)
|
||||
const { p, t } = await pick(C, BOND + MF);
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
for (const x of p) tb.addInput(x, sigC.unlockP2PKH());
|
||||
tb.addOutput({ to: c.address, amount: BOND });
|
||||
tb.addOutput({ to: C.cashaddr, amount: t - BOND - MF });
|
||||
const id = txidOf(await tb.send()); await sleep(6000);
|
||||
for (let i = 0; i < 12; i++) { const u = (await provider.getUtxos(c.address)).find((x) => !x.token && BigInt(x.satoshis) === BOND); if (u) return u; await sleep(6000); }
|
||||
throw new Error('bond utxo not visible');
|
||||
}
|
||||
|
||||
const h = await provider.getBlockHeight();
|
||||
console.log(`signer(E) ${E.cashaddr}\nchallenger(C) ${C.cashaddr}\nchipnet height ${h}`);
|
||||
|
||||
const results = [];
|
||||
|
||||
// ---- S1: slash with a genuine equivocation (same redeemId, different payload) ----
|
||||
step('S1 — slash on equivocation (same redeemId, CONFLICTING payloads)');
|
||||
{
|
||||
const c = bond(h + 1000); const u = await fundBond(c);
|
||||
const redeemId = new Uint8Array(randomBytes(32));
|
||||
const msgA = cat(redeemId, u64le(50000), Uint8Array.from([0x01])); // outcome A
|
||||
const msgB = cat(redeemId, u64le(50000), Uint8Array.from([0x02])); // outcome B (conflict)
|
||||
const sigA = oracleSign(E.privateKey, msgA), sigB = oracleSign(E.privateKey, msgB);
|
||||
let ok = false;
|
||||
try {
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u, c.unlock.slash(sigA, msgA, sigB, msgB));
|
||||
tb.addOutput({ to: C.cashaddr, amount: BOND - MF }); // challenger seizes the bond
|
||||
const id = txidOf(await tb.send()); ok = true;
|
||||
console.log(` slashed: ${id} — challenger seized ${BOND - MF} sat`);
|
||||
} catch (e) { console.log(` UNEXPECTED reject: ${head1(e)}`); }
|
||||
results.push(['S1 equivocation fraud proof slashes the bond', ok]); await sleep(6000);
|
||||
}
|
||||
|
||||
// ---- S2: same message twice → not a conflict → rejected ----
|
||||
step('S2 — slash attempt with the SAME message twice (no conflict) → must reject');
|
||||
{
|
||||
const c = bond(h + 1000); const u = await fundBond(c);
|
||||
const redeemId = new Uint8Array(randomBytes(32));
|
||||
const msg = cat(redeemId, u64le(50000), Uint8Array.from([0x01]));
|
||||
const s = oracleSign(E.privateKey, msg);
|
||||
let rejected = false;
|
||||
try {
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u, c.unlock.slash(s, msg, s, msg));
|
||||
tb.addOutput({ to: C.cashaddr, amount: BOND - MF });
|
||||
await tb.send(); console.log(' !! slash SUCCEEDED with identical messages — BUG');
|
||||
} catch (e) { rejected = true; console.log(` rejected as expected: ${head1(e)}`); }
|
||||
results.push(['S2 identical (non-conflicting) messages cannot slash', rejected]); await sleep(6000);
|
||||
}
|
||||
|
||||
// ---- S3: different redeemIds → not the same slot → rejected ----
|
||||
step('S3 — slash attempt across DIFFERENT redeemIds (not the same slot) → must reject');
|
||||
{
|
||||
const c = bond(h + 1000); const u = await fundBond(c);
|
||||
const msgA = cat(new Uint8Array(randomBytes(32)), u64le(50000), Uint8Array.from([0x01]));
|
||||
const msgB = cat(new Uint8Array(randomBytes(32)), u64le(50000), Uint8Array.from([0x02]));
|
||||
const sigA = oracleSign(E.privateKey, msgA), sigB = oracleSign(E.privateKey, msgB);
|
||||
let rejected = false;
|
||||
try {
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u, c.unlock.slash(sigA, msgA, sigB, msgB));
|
||||
tb.addOutput({ to: C.cashaddr, amount: BOND - MF });
|
||||
await tb.send(); console.log(' !! slash SUCCEEDED across different redeemIds — BUG');
|
||||
} catch (e) { rejected = true; console.log(` rejected as expected: ${head1(e)}`); }
|
||||
results.push(['S3 two different redeemIds cannot slash (must be the same slot)', rejected]); await sleep(6000);
|
||||
}
|
||||
|
||||
// ---- W1: honest withdraw after the unbond height (already elapsed) ----
|
||||
step('W1 — honest withdraw AFTER the unbond height → signer reclaims the bond');
|
||||
{
|
||||
const c = bond(h); const u = await fundBond(c); // unbondHeight = now → already mature
|
||||
let ok = false;
|
||||
try {
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u, c.unlock.withdraw(sigE), { sequence: 0xfffffffe });
|
||||
tb.addOutput({ to: E.cashaddr, amount: BOND - MF });
|
||||
tb.setLocktime(h);
|
||||
const id = txidOf(await tb.send()); ok = true;
|
||||
console.log(` withdrawn: ${id} — signer reclaimed ${BOND - MF} sat`);
|
||||
} catch (e) { console.log(` UNEXPECTED reject: ${head1(e)}`); }
|
||||
results.push(['W1 signer reclaims the bond after the unbond window', ok]); await sleep(6000);
|
||||
}
|
||||
|
||||
// ---- W2: withdraw before the unbond height → rejected ----
|
||||
step('W2 — withdraw BEFORE the unbond height → must reject (CLTV guard)');
|
||||
{
|
||||
const c = bond(h + 50); const u = await fundBond(c); // unbondHeight in the future
|
||||
let rejected = false;
|
||||
try {
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u, c.unlock.withdraw(sigE), { sequence: 0xfffffffe });
|
||||
tb.addOutput({ to: E.cashaddr, amount: BOND - MF });
|
||||
tb.setLocktime(h); // locktime now < unbondHeight → reject
|
||||
await tb.send(); console.log(' !! withdraw SUCCEEDED early — BUG');
|
||||
} catch (e) { rejected = true; console.log(` rejected as expected: ${head1(e)}`); }
|
||||
results.push(['W2 withdraw before the unbond height is rejected', rejected]);
|
||||
}
|
||||
|
||||
// ---- result ----
|
||||
step('RESULT — bonded peg-signer (the trust-minimization core of Model-3 redemption)');
|
||||
let ok = true; for (const [l, p] of results) { console.log(` ${p ? 'PASS' : 'FAIL'} ${l}`); if (!p) ok = false; }
|
||||
divider();
|
||||
console.log(ok
|
||||
? 'BONDED SIGNER VALIDATED on chipnet — equivocation is slashable, honest bonds are reclaimable.\nThis is what makes a BTC peg\'s M-of-N trust-MINIMIZED: BCH enforces the punishment BTC cannot,\nso a signer who double-signs a redeem loses their bond. Pair with the signet M-of-N release\n(the BTC side) for the full bonded redemption leg of Model 3.'
|
||||
: 'SOME CHECKS FAILED.');
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exitCode = 1; });
|
||||
|
|
@ -59,7 +59,7 @@ export function buildSpendTx({ mode, utxo, witnessScript, keyPair, preimage, tim
|
|||
if (mode === 'refund') tx.locktime = timeout;
|
||||
tx.addOutput(bitcoin.address.toOutputScript(toAddress, NETWORK), BigInt(utxo.value) - BigInt(feeSats));
|
||||
|
||||
const sighash = tx.hashForWitnessV0(0, witnessScript, Number(utxo.value), SIGHASH_ALL);
|
||||
const sighash = tx.hashForWitnessV0(0, witnessScript, BigInt(utxo.value), SIGHASH_ALL);
|
||||
const sig = bitcoin.script.signature.encode(Buffer.from(keyPair.sign(sighash)), SIGHASH_ALL);
|
||||
|
||||
// OP_IF selector: truthy (OP_1) → claim branch; empty → else/refund branch.
|
||||
|
|
|
|||
157
scripts/t6-wallet-server.mjs
Normal file
157
scripts/t6-wallet-server.mjs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* t6-wallet-server.mjs — a T6-aware wallet UX over the validated stack.
|
||||
*
|
||||
* The thesis of T6 (ADR-0046): a Lightning-style channel whose STATE RECORD lives on the BCH
|
||||
* chain instead of in a private node — so the user runs two light wallets, never a node, never
|
||||
* a watchtower, never "stay online to defend." This server makes that tangible: every Send /
|
||||
* Receive tap is a REAL on-chain BCH update of a `BchChannelBoard` covenant (both-signed,
|
||||
* strictly monotonic). The board IS the watchtower — public, permanent, unloseable.
|
||||
*
|
||||
* The one-time channel funding (a BTC 2-of-2, opened via a submarine swap) is the validated
|
||||
* signet leg (btc-signet-channel.mjs / btc-bch-htlc-swap.mjs); here it is the "open" step,
|
||||
* shown as the abstracted-underneath origin. What the UI exercises live is the part that makes
|
||||
* T6 different from LN: the on-chain state board.
|
||||
*
|
||||
* GET / → the wallet UI
|
||||
* GET /api/state → live board state (read off chipnet)
|
||||
* POST /api/open → open the channel: write board state 0 (Alice/Bob split)
|
||||
* POST /api/pay → { dir: 'send'|'receive', amount } → monotonic board update (real tx)
|
||||
* POST /api/reset → forget the local channel handle (board history stays on-chain)
|
||||
*
|
||||
* Run: node --import tsx scripts/t6-wallet-server.mjs (default port 5190)
|
||||
* Then open http://localhost:5190/
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TestNetWallet } from 'mainnet-js';
|
||||
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
const ART = join(ROOT, 'contracts/dist/BchChannelBoard.json');
|
||||
const STATE = join(ROOT, '.t6-wallet-state.json');
|
||||
const PORT = Number((process.argv.find((a) => a.startsWith('--port=')) || '').split('=')[1] || 5190);
|
||||
const PAGE = join(ROOT, 'design/t6-wallet/index.html');
|
||||
|
||||
const DUST = 1000n, MF = 2000n, OPEN_A = 50000n, OPEN_B = 50000n;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const txidOf = (s) => (typeof s === 'string' ? s : s?.txid ?? s?.txId);
|
||||
const hex = (u) => Buffer.from(u).toString('hex');
|
||||
function u32le(n) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(n), true); return b; }
|
||||
function u64le(n) { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }
|
||||
function cat(...a) { const o = new Uint8Array(a.reduce((s, x) => s + x.length, 0)); let p = 0; for (const x of a) { o.set(x, p); p += x.length; } return o; }
|
||||
const commit = (n, a, b) => cat(u32le(n), u64le(a), u64le(b));
|
||||
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 }; }
|
||||
const readSt = () => (existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : null);
|
||||
const writeSt = (s) => writeFileSync(STATE, JSON.stringify(s, null, 2));
|
||||
|
||||
let A, B, sigA, sigB, art;
|
||||
|
||||
async function init() {
|
||||
art = JSON.parse(readFileSync(ART, 'utf8'));
|
||||
A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-c.json'), 'utf8')).wif); // the user (Alice)
|
||||
B = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-b.json'), 'utf8')).wif); // counterparty (Bob)
|
||||
sigA = new SignatureTemplate(A.privateKey); sigB = new SignatureTemplate(B.privateKey);
|
||||
}
|
||||
|
||||
// Fresh provider + contract per operation. A persistent electrum socket goes stale under a flaky
|
||||
// chipnet (dropped sockets → "Missing inputs" from a stale utxo view, or a wedged process); a
|
||||
// short-lived connection per call is far more robust for a demo doing real 0-conf broadcasts.
|
||||
function ctx() { const provider = new ElectrumNetworkProvider('chipnet'); const board = new Contract(art, [A.publicKeyCompressed, B.publicKeyCompressed], { provider }); return { provider, board }; }
|
||||
const STALE = /missing inputs|rejected|mempool|conflict|already|txn-|bad-txns/i;
|
||||
async function withRetry(fn, tries = 4) { let last; for (let i = 0; i < tries; i++) { try { return await fn(); } catch (e) { last = e; if (!STALE.test(String(e?.message ?? e))) throw e; await sleep(5000); } } throw last; }
|
||||
|
||||
async function pick(provider, need) { const u = (await provider.getUtxos(A.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(provider) {
|
||||
// CashToken genesis needs an input with outpoint index 0; use the LIVE provider view.
|
||||
let live = (await provider.getUtxos(A.cashaddr)).filter((x) => !x.token);
|
||||
let e = live.filter((x) => x.vout === 0);
|
||||
if (e.length) return e[0];
|
||||
const { p, t } = pickBch(live.sort((a, b) => Number(b.satoshis) - Number(a.satoshis)), 3000n + MF);
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
for (const x of p) tb.addInput(x, sigA.unlockP2PKH());
|
||||
tb.addOutput({ to: A.cashaddr, amount: 3000n }); // becomes a vout==0 utxo
|
||||
tb.addOutput({ to: A.cashaddr, amount: t - 3000n - MF });
|
||||
await tb.send(); await sleep(8000);
|
||||
e = (await provider.getUtxos(A.cashaddr)).filter((x) => !x.token && x.vout === 0);
|
||||
if (!e.length) throw new Error('splitter'); return e[0];
|
||||
}
|
||||
const boardUtxo = async (provider, board, c) => (await provider.getUtxos(board.tokenAddress)).find((x) => x.token?.category === c && x.token?.nft);
|
||||
async function waitBoard(provider, board, c) { for (let i = 0; i < 12; i++) { const u = await boardUtxo(provider, board, c); if (u) return u; await sleep(5000); } throw new Error('board utxo not visible'); }
|
||||
|
||||
async function openChannel() {
|
||||
return withRetry(async () => {
|
||||
const { provider, board } = ctx();
|
||||
const u0 = await eligible(provider); const category = u0.txid;
|
||||
const { p, t } = await pick(provider, DUST + MF);
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u0, sigA.unlockP2PKH()); for (const x of p) if (!(x.txid === u0.txid && x.vout === u0.vout)) tb.addInput(x, sigA.unlockP2PKH());
|
||||
tb.addOutput({ to: board.tokenAddress, amount: DUST, token: { amount: 0n, category, nft: { capability: 'mutable', commitment: hex(commit(0, OPEN_A, OPEN_B)) } } });
|
||||
tb.addOutput({ to: A.cashaddr, amount: BigInt(u0.satoshis) + t - DUST - MF });
|
||||
const txid = txidOf(await tb.send());
|
||||
await waitBoard(provider, board, category);
|
||||
const st = { category, state: 0, balA: OPEN_A.toString(), balB: OPEN_B.toString(), history: [{ state: 0, balA: OPEN_A.toString(), balB: OPEN_B.toString(), txid, action: 'open (fund BTC 2-of-2)' }] };
|
||||
writeSt(st); return st;
|
||||
});
|
||||
}
|
||||
|
||||
async function pay(dir, amount) {
|
||||
const s = readSt(); if (!s || !s.category) throw new Error('no open channel');
|
||||
const amt = BigInt(amount);
|
||||
let balA = BigInt(s.balA), balB = BigInt(s.balB);
|
||||
if (dir === 'send') { if (amt > balA) throw new Error('insufficient channel balance'); balA -= amt; balB += amt; }
|
||||
else { if (amt > balB) throw new Error('counterparty has insufficient inbound'); balA += amt; balB -= amt; }
|
||||
const newState = s.state + 1;
|
||||
return withRetry(async () => {
|
||||
const { provider, board } = ctx();
|
||||
const u = await boardUtxo(provider, board, s.category); if (!u) throw new Error('board utxo missing');
|
||||
const { p, t } = await pick(provider, MF);
|
||||
const tb = new TransactionBuilder({ provider });
|
||||
tb.addInput(u, board.unlock.update(sigA, sigB, u32le(newState), u64le(balA), u64le(balB)));
|
||||
for (const x of p) tb.addInput(x, sigA.unlockP2PKH());
|
||||
tb.addOutput({ to: board.tokenAddress, amount: DUST, token: { amount: 0n, category: s.category, nft: { capability: 'mutable', commitment: hex(commit(newState, balA, balB)) } } });
|
||||
tb.addOutput({ to: A.cashaddr, amount: t + BigInt(u.satoshis) - DUST - MF });
|
||||
const txid = txidOf(await tb.send());
|
||||
await sleep(3000);
|
||||
s.state = newState; s.balA = balA.toString(); s.balB = balB.toString();
|
||||
s.history.push({ state: newState, balA: s.balA, balB: s.balB, txid, action: dir === 'send' ? `send ${amt}` : `receive ${amt}` });
|
||||
writeSt(s); return s;
|
||||
});
|
||||
}
|
||||
|
||||
async function liveState() {
|
||||
const s = readSt(); if (!s || !s.category) return { open: false };
|
||||
const { provider, board } = ctx();
|
||||
let onchain = null;
|
||||
// race the on-chain echo against a short timeout so the file-based state still renders if the
|
||||
// chain is briefly unreachable — the board's truth is independently on-chain.
|
||||
try {
|
||||
const u = await Promise.race([boardUtxo(provider, board, s.category), new Promise((r) => setTimeout(() => r(null), 5000))]);
|
||||
if (u) { const c = Buffer.from(u.token.nft.commitment, 'hex'); onchain = { state: c.readUInt32LE(0), balA: c.readBigUInt64LE(4).toString(), balB: c.readBigUInt64LE(12).toString() }; }
|
||||
} catch {}
|
||||
return { open: true, ...s, onchain, boardAddr: board.tokenAddress, user: A.cashaddr, counterparty: B.cashaddr };
|
||||
}
|
||||
|
||||
function body(req) { return new Promise((res) => { let d = ''; req.on('data', (c) => (d += c)); req.on('end', () => res(d ? JSON.parse(d) : {})); }); }
|
||||
|
||||
async function main() {
|
||||
await init();
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj)); };
|
||||
try {
|
||||
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(readFileSync(PAGE, 'utf8')); return; }
|
||||
if (req.method === 'GET' && req.url === '/api/state') return send(200, await liveState());
|
||||
if (req.method === 'POST' && req.url === '/api/open') return send(200, await openChannel());
|
||||
if (req.method === 'POST' && req.url === '/api/pay') { const b = await body(req); return send(200, await pay(b.dir, b.amount)); }
|
||||
if (req.method === 'POST' && req.url === '/api/reset') { if (existsSync(STATE)) writeFileSync(STATE, JSON.stringify({}, null, 2)); return send(200, { ok: true }); }
|
||||
res.writeHead(404); res.end('not found');
|
||||
} catch (e) { send(500, { error: e?.message ?? String(e) }); }
|
||||
});
|
||||
server.listen(PORT, () => {
|
||||
console.log(`T6 wallet demo — http://localhost:${PORT}/`);
|
||||
console.log(` user(Alice) board ${ctx().board.tokenAddress}`);
|
||||
console.log(' every Send/Receive is a real on-chain BchChannelBoard update on chipnet. Ctrl+C to stop.');
|
||||
});
|
||||
}
|
||||
main().catch((e) => { console.error('FAILED:', e?.stack ?? e?.message ?? e); process.exit(1); });
|
||||
72
snapshots/0177-t6-ln-to-t6-routing-validated-real-coins.md
Normal file
72
snapshots/0177-t6-ln-to-t6-routing-validated-real-coins.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Snapshot 0177 — LN→T6 routing validated end-to-end with real coins (one preimage, two chains)
|
||||
|
||||
**Date:** 2026-06-27
|
||||
**Track:** BTC↔BCH / "Bitcoin Cash as a better Lightning" (T6 — the LN-faithful demonstration)
|
||||
**Status:** ✅ LN→T6 routing validated — a routed (Lightning) payment received *into* a T6
|
||||
channel, with **one preimage settling both chains**: revealed on the BCH board, then used to
|
||||
claim a real BTC HTLC on signet.
|
||||
|
||||
Builds on 0175 (T6 board + BTC 2-of-2 + unilateral-exit penalty). This is Step 2 of the
|
||||
straight-through T6 build plan (Step 1 = the BCH/T6 leg in isolation, also validated this pass).
|
||||
|
||||
---
|
||||
|
||||
## A — Step 1: the T6/BCH leg (chipnet)
|
||||
|
||||
`scripts/chipnet-ln-to-t6-routing.mjs` (contract `contracts/BchChannelBoardHTLC.cash`,
|
||||
function `settleHtlc`). A routed payment is received into a T6 channel: the board credits the
|
||||
receiver **and reveals the HTLC preimage S on-chain** (`sha256(S) == paymentHash`). That
|
||||
on-chain reveal is exactly what lets the upstream LN-side HTLC be claimed — one preimage links
|
||||
the two channels.
|
||||
|
||||
**Validated on chipnet:** board opened (state 0, Alice 5000 / Bob 5000) → `settleHtlc` credited
|
||||
Alice +3000 (state #1, Alice 8000 / Bob 2000) → preimage S visible in the settle tx. Both
|
||||
self-checks PASS.
|
||||
|
||||
## B — Step 2: close the cross-chain loop with real coins
|
||||
|
||||
`scripts/btc-signet-ln-leg.mjs` (orchestrator) + `scripts/btc-claim-htlc.mjs` (the reusable
|
||||
claim primitive / recovery path). Scenario: **Carol pays Alice, routed through Bob (the bridge).**
|
||||
|
||||
1. **Carol locks a real BTC HTLC** on signet under `H` (6000 sat; claimable by Bob with S,
|
||||
refund to Carol after CLTV). Lock tx `db5615374ef9…:0`.
|
||||
2. **Bob forwards into the T6 channel** — `settleHtlc` on the BCH board credits Alice and
|
||||
reveals S on chipnet. Settle tx `19bcd25bcb87…` (S = `94697eb9…`, H = `55cb8ce1…`).
|
||||
3. **Bob reads S off the BCH chain** — `findPreimagePair` scans the settle tx for the
|
||||
`(preimage, paymentHash)` pair with **no prior knowledge of S** — then **claims Carol's BTC
|
||||
HTLC**. Claim tx `65256a637f21…` → 5600 sat to Bob.
|
||||
|
||||
So one preimage settled both chains: **BCH settle ⇒ BTC settles.** Clerk (BCH) records and
|
||||
reveals; bailiff (BTC) pays. This is the LN-interop proof: an LN payment routes *into* a T6
|
||||
channel, the BCH board is the watchtower/record, and the upstream BTC leg settles off the same
|
||||
secret.
|
||||
|
||||
## C — Engineering notes
|
||||
|
||||
- **Bug fixed in the T4 scaffold** `scripts/lib/btc-htlc.mjs`: `hashForWitnessV0` in the current
|
||||
`bitcoinjs-lib` requires a **bigint** amount — was `Number(utxo.value)`, now `BigInt(...)`
|
||||
(matches the working `btc-signet-penalty.mjs` pattern). This was the lib's first real run.
|
||||
- **Faithful extraction over convenience:** Bob does not reuse an in-memory S; he re-derives it
|
||||
from public chain data, which is what proves the cross-chain link is real and not staged.
|
||||
- **Per-chain keys, shared preimage:** roles use separate keys on each chain (chipnet b/c,
|
||||
signet carol/bob); the link is the preimage, not a shared key. (T6 can use one secp256k1 key
|
||||
on both chains; not required for the proof.)
|
||||
- **A transient chipnet UTXO flake** ("Missing inputs", stale electrum view after a prior
|
||||
spend) aborted one orchestrator run *after* its HTLC lock; that HTLC (`c0b9ac73…`) is
|
||||
refundable by Carol after CLTV 310569 (her own funds). The claim primitive
|
||||
(`btc-claim-htlc.mjs`) then closed the loop against the still-locked run-1 HTLC — which is the
|
||||
stronger proof anyway (cross-run, chain-sourced S).
|
||||
|
||||
## D — Pending (next steps in the plan)
|
||||
|
||||
- **Step 3 — T6c honest-close liveness mirror:** the honest CSV-delayed self-close (ELSE branch
|
||||
of the revocation script); the penalty/IF branch is already validated (0175). Block-wait.
|
||||
- **Step 4 — T4 full BTC↔BCH HTLC swap:** bidirectional claim + refund harness tying the signet
|
||||
P2WSH HTLC to chipnet `AtomicSwapHTLC` under one hash (the lib is now proven on the claim path).
|
||||
- **Step 5 — Model-3 bonded M-of-N redemption** leg (tBTC-v2-style, slashable).
|
||||
- **Step 6 — T6 ADR + spec section.** **Step 7 — T6-aware wallet UX.**
|
||||
|
||||
**Artifacts:** `contracts/BchChannelBoardHTLC.cash`, `scripts/chipnet-ln-to-t6-routing.mjs`,
|
||||
`scripts/btc-signet-ln-leg.mjs`, `scripts/btc-claim-htlc.mjs`, `scripts/lib/btc-htlc.mjs` (fixed).
|
||||
Tracker: `Parameters/ASM DEX/btc-on-bch-rails_test-program.md` (row **T6r**). Complete record:
|
||||
`Parameters/T6_BitcoinCash_LN/T6_BTC-BCH_complete-record.md` §3.
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Snapshot 0178 — T6 trustless gaps closed: honest-close, real BTC↔BCH swap, bonded M-of-N
|
||||
|
||||
**Date:** 2026-06-27
|
||||
**Track:** BTC↔BCH / "Bitcoin Cash as a better Lightning" (T6) + Model-3 (BTC-IOU) trust floor
|
||||
**Status:** ✅ Steps 3–5 of the straight-through plan validated with real coins — the remaining
|
||||
trustless pieces of the BTC↔BCH program are now all proven on chipnet + BTC signet.
|
||||
|
||||
Builds on 0177 (LN→T6 routing). Complete record:
|
||||
`Parameters/T6_BitcoinCash_LN/T6_BTC-BCH_complete-record.md` §3.
|
||||
|
||||
---
|
||||
|
||||
## A — Step 3: T6c honest-close (liveness mirror)
|
||||
|
||||
`scripts/btc-signet-honest-close.mjs`. The penalty/IF branch was validated in 0175; this is the
|
||||
honest ELSE branch of the revocation script (the closer takes their OWN output after the CSV
|
||||
delay). Two-part proof on signet: the sweep is **rejected before** the CSV delay (`non-BIP68-final`)
|
||||
and **succeeds after** the output is CSV-deep (`a8d95644…`). With the penalty path, the trustless
|
||||
unilateral-exit pair is complete: honest closers wait out the CSV and recover; cheaters who
|
||||
broadcast a stale state are penalised immediately.
|
||||
|
||||
## B — Step 4: T4 — real trustless BTC↔BCH atomic swap
|
||||
|
||||
`scripts/btc-bch-htlc-swap.mjs` (+ the `scripts/lib/btc-htlc.mjs` bigint fix from 0177). One
|
||||
sha256 hashlock governs a signet BTC HTLC (P2WSH) and a chipnet `AtomicSwapHTLC`:
|
||||
- Alice funds the BTC leg (claim→Bob with S / refund→Alice); Bob funds the BCH leg (claim→Alice
|
||||
with S / refund→Bob, shorter timeout).
|
||||
- Alice claims the BCH leg (`139b2312…`), revealing S on chipnet; Bob reads S off the chain and
|
||||
claims the signet BTC leg (`8bc0ba56…`). One preimage, both chains — atomicity by reveal.
|
||||
- BTC refund safety net: rejected before CLTV (`non-final`), succeeds after (`02144e7b…`).
|
||||
No oracle, no wrapped token, no custodian — each chain enforces its own leg. This is Model 2
|
||||
(swap into BCH, transact on-chain) on real Bitcoin.
|
||||
|
||||
## C — Step 5: Model-3 bonded M-of-N redemption (the trust floor)
|
||||
|
||||
The honest conclusion of the BTC-IOU model: a wrapped/locked BTC peg is necessarily
|
||||
trust-MINIMIZED (a bonded M-of-N), never trustless, because BTC has no covenants. This step
|
||||
validates that floor on both sides:
|
||||
|
||||
- **BTC release threshold (signet)** — `scripts/btc-signet-mofn-release.mjs`: a 2-of-3 P2WSH
|
||||
reserve releases only with two signers (`1be08e1c…`); a single signature is rejected by
|
||||
consensus. BTC enforces the *release*.
|
||||
- **BCH slashable bond (chipnet)** — NEW `contracts/BondedSignerSlash.cash` +
|
||||
`scripts/chipnet-bonded-signer-test.mjs`: each signer posts a bond on BCH. **Equivocation** —
|
||||
two valid signatures over the same `redeemId` with conflicting payloads — is a cryptographic
|
||||
fraud proof anyone can submit to **slash** the bond. 5/5 checks: slash on a genuine conflict
|
||||
(S1); reject identical messages (S2) and different redeemIds (S3); honest withdraw after the
|
||||
unbond height (W1); reject early withdraw (W2). BCH enforces the *punishment* BTC cannot.
|
||||
|
||||
Together (BTC release + BCH slashable bond) = the **bonded M-of-N redemption leg** (tBTC-v2
|
||||
style): collusion is possible but bonded and slashable, so a rational signer never equivocates.
|
||||
This is what turns Model-3's "trust the signers" into "trust-minimized."
|
||||
|
||||
## D — Engineering notes / lessons
|
||||
|
||||
- **mainnet-js wallet UTXO cache goes stale** under heavy key reuse — `wallet.send()` can build a
|
||||
tx from already-spent inputs that the network silently drops (a returned txid that never lands).
|
||||
Fund covenants via `TransactionBuilder` from the **live** `provider.getUtxos()` view instead.
|
||||
Applied to the T4 BCH-leg funding and the bonded-bond funding.
|
||||
- **`checkDataSig` with a compressed pubkey** verifies a Schnorr signature over `sha256(msg)`
|
||||
(the `secp256k1.signMessageHashSchnorr` pattern) — confirmed by S1.
|
||||
- Transient signet/chipnet indexing lag is handled with polling helpers (`waitBtcUtxo` /
|
||||
`waitChipUtxo`), not single lookups. A few orphaned test HTLCs (lost-preimage runs) are
|
||||
refundable by their funders after their timeouts — test-coin only.
|
||||
|
||||
## E — Remaining plan steps
|
||||
|
||||
- **Step 6 — T6 ADR + spec section** (consolidate the now-fully-validated design into
|
||||
`wiki/decisions.md` + the technical spec).
|
||||
- **Step 7 — T6-aware wallet UX** (one-tap send/receive over the validated stack).
|
||||
|
||||
**New artifacts:** `contracts/BondedSignerSlash.cash`, `scripts/btc-signet-honest-close.mjs`,
|
||||
`scripts/btc-bch-htlc-swap.mjs`, `scripts/btc-signet-mofn-release.mjs`,
|
||||
`scripts/chipnet-bonded-signer-test.mjs`. Tracker: `Parameters/ASM DEX/btc-on-bch-rails_test-program.md`
|
||||
(rows T4, Model-3). Keys/state gitignored.
|
||||
76
snapshots/0179-t6-aware-wallet-ux-onetap-sendreceive.md
Normal file
76
snapshots/0179-t6-aware-wallet-ux-onetap-sendreceive.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Snapshot 0179 — T6-aware wallet UX: one-tap send/receive over the validated stack
|
||||
|
||||
**Date:** 2026-06-27
|
||||
**Track:** BTC↔BCH / T6 — productization (the demonstration an LN user can actually touch)
|
||||
**Status:** ✅ Step 7 of the straight-through plan — a working T6 wallet demo, verified live on
|
||||
chipnet in the browser preview. This completes the seven-step plan (Steps 1–7 all done).
|
||||
|
||||
Builds on 0177 (LN→T6 routing) + 0178 (trustless gaps) + ADR-0046. The protocol is proven;
|
||||
this wraps it in a one-tap experience.
|
||||
|
||||
---
|
||||
|
||||
## A — What it is
|
||||
|
||||
`scripts/t6-wallet-server.mjs` + `design/t6-wallet/index.html`. A tiny demo server (the
|
||||
project's dashboard-serve pattern) presenting a plain **Send / Receive** wallet whose every tap
|
||||
is a **real on-chain `BchChannelBoard` update on chipnet** (both-signed, strictly monotonic).
|
||||
The thesis made tangible: the channel's state record lives on the BCH chain, so the user runs
|
||||
two light wallets — no Lightning node, no watchtower service, no staying online to defend.
|
||||
|
||||
- `GET /` — the wallet UI (channel balance, Send/Receive, the board as the live watchtower,
|
||||
the audit-trail history with explorer links, and a "what you don't run vs Lightning" panel).
|
||||
- `POST /api/open` — fund the channel → board state 0 (Alice/Bob split).
|
||||
- `POST /api/pay {dir, amount}` — a monotonic board update (a real chipnet tx).
|
||||
- `GET /api/state` — live state; the on-chain board commitment is echoed and shown as
|
||||
"#N ✓ matches" (read straight off chipnet), racing a short timeout so the file-based state
|
||||
still renders if the chain is briefly unreachable.
|
||||
|
||||
Registered in `.claude/launch.json` as `t6-wallet` (port 5190).
|
||||
|
||||
## B — Verified live (browser preview, real chipnet)
|
||||
|
||||
Drove a full round-trip from the UI/API:
|
||||
- **Open** → board state #0, Alice 50000 / Bob 50000 (`c89bd6d9…`).
|
||||
- **Send 8000** (Alice→Bob) → state #1, 42000 / 58000 (`2aab5e27…`).
|
||||
- **Receive 3000** (Bob→Alice) → state #2, 45000 / 55000 (`ce7a8eab…`).
|
||||
|
||||
The page renders the open channel, the board panel showing **state #2 with "✓ matches"** (the
|
||||
on-chain commitment echoed back), and the three-entry history with real txid links. Screenshot
|
||||
captured; no console errors.
|
||||
|
||||
## C — Honest framing fixes (from review)
|
||||
|
||||
- The "Open channel" button originally read "(submarine swap)". Corrected to **"fund BTC
|
||||
2-of-2"**: opening a T6 (or LN) channel is a plain on-chain funding tx, *not* a submarine
|
||||
swap. A (reverse) submarine swap is only the interop path — funding a T6 channel from money
|
||||
already sitting in a Lightning channel, without closing it. The tooltip states this.
|
||||
- The BTC 2-of-2 leg is presented as the one-time funding origin; what the UI exercises live is
|
||||
the part that makes T6 different from LN — the on-chain state board.
|
||||
|
||||
## D — Engineering note / robustness
|
||||
|
||||
Two fixes were needed for reliable live operation:
|
||||
1. The server's `eligible()` (CashToken-genesis input picker) reads the **live
|
||||
`provider.getUtxos()`** view, not the mainnet-js wallet cache (the "Missing inputs" footgun
|
||||
from 0178).
|
||||
2. **Fresh provider + contract per request, plus retry-on-stale-input.** A persistent
|
||||
ElectrumNetworkProvider socket in a long-lived server goes stale under flaky chipnet — a
|
||||
dropped socket serves a stale UTXO view ("Missing inputs" on broadcast) and can wedge the
|
||||
whole process so even reads hang. `ctx()` now builds a short-lived provider per API call, and
|
||||
`withRetry()` re-fetches UTXOs and rebuilds the tx on any `missing inputs|rejected|mempool|…`
|
||||
rejection (4 attempts, 5s apart). After this, reset→open→send round-trips cleanly from the UI.
|
||||
(Lesson for any demo server doing real 0-conf broadcasts: don't hold one electrum socket open
|
||||
across requests; reconnect per op and retry on transient rejections.)
|
||||
|
||||
## E — Plan complete
|
||||
|
||||
All seven steps of the straight-through T6 plan are done: (1) LN→T6 routing BCH leg, (2) the
|
||||
cross-chain loop, (3) T6c honest-close, (4) real BTC↔BCH swap, (5) bonded M-of-N redemption,
|
||||
(6) ADR-0046 + spec §16, (7) this wallet UX. The BTC↔BCH program is validated end-to-end on
|
||||
real coins and now has a touchable demonstration.
|
||||
|
||||
**Artifacts:** `scripts/t6-wallet-server.mjs`, `design/t6-wallet/index.html`,
|
||||
`.claude/launch.json` (t6-wallet), `.gitignore` (`.t6-*.json`). Run:
|
||||
`node --import tsx scripts/t6-wallet-server.mjs` → http://localhost:5190/ (or preview
|
||||
`t6-wallet`). Cross-refs: ADR-0046, spec §16, snapshots 0175/0177/0178.
|
||||
|
|
@ -1966,3 +1966,56 @@ Two principles the exploration established and this ADR rests on:
|
|||
- Pricing/peg: `DeviantOracleMBA` / `src/oracleUsdm.ts` (EURm/USDm/GBPm/JPYm/INRm).
|
||||
- Front-end: `src/web/{api,server}.ts` + `shells/app/` — one shared API behind a web Mini-App page and a Telegram bot/Mini App.
|
||||
- Cross-references: ADR-0044 (market gap + primitives; this refines its open dispute problem), ADR-0024 (escrow family), ADR-0030 (order book), ADR-0036 (on-ramp aggregator fallback + reputation/jurisdiction).
|
||||
|
||||
---
|
||||
|
||||
## ADR-0046 — BTC↔BCH: T6 ("Bitcoin Cash as a better Lightning") + the bonded BTC-IOU trust floor
|
||||
|
||||
**Date:** 2026-06-27
|
||||
**Status:** Accepted (validated end-to-end on BCH chipnet + BTC signet, real coins). Consolidates the design record `Parameters/T6_BitcoinCash_LN/` (complete-record, model-explainer, why-demonstration) and the live tracker `Parameters/ASM DEX/btc-on-bch-rails_test-program.md`. Snapshots 0172–0178.
|
||||
|
||||
**Context.** A captured BTC audience (Vol.13/14; auto-memory [[demonstration-over-reasoning-captured-audience]]) cannot be *argued* into Bitcoin Cash — only shown. The deliverable is therefore a runnable *demonstration* that BCH does what BTC+Lightning do, better, meeting LN users inside their own model (lock BTC, channel, stay in BTC). Four turns of protocol mechanics produced one governing constraint:
|
||||
|
||||
> **The VM wall.** BTC has no covenants — it can verify signatures, hashlocks, timelocks, nothing more. So BTC cannot read another chain or bind a release to foreign state. Consequence (the **clerk/bailiff** split): BCH can *record, judge, and watch* a BTC channel (the clerk), but only **BTC script can move the BTC** (the bailiff). BCH verdicts are invisible to BTC. This single fact shapes every model below.
|
||||
|
||||
**Decision — ship a three-model menu, two axes (technical trustlessness × adoption), let users choose:**
|
||||
|
||||
- **Model 2 — Swap (T1/T4/T5/RT).** Trustless HTLC atomic swap BTC↔BCH, then transact natively on-chain. Wins the *technical* axis; costs leaving BTC. Validated on real coins (T4, `btc-bch-htlc-swap.mjs`): one sha256 hashlock over a signet BTC HTLC + a chipnet `AtomicSwapHTLC`; claim path + BTC refund safety net.
|
||||
- **Model 3 — BTC-IOU on BCH.** Lock BTC → mint a bearer IOU CashToken → transact on-chain (no LN/node/hub), backing publicly verifiable. Wins the *adoption* axis (BTC stays "locked and yours"). Settlement of the locked BTC is **trust-minimized, not trustless** — see the trust floor below.
|
||||
- **Model 1 / T6 — Lock + BCH board.** Keep LN's model (2-of-2 lock, off-chain commitments, BTC settlement) but **replace LN's private, must-not-lose, must-stay-online node with a public, permanent, permissionless BCH on-chain record** (the "board"). The LN-faithful "better Lightning."
|
||||
|
||||
**T6 architecture (the headline).** A T6 channel and an LN channel are the *same object* — a BTC 2-of-2 with co-signed commitments settling on BTC — differing only in **where the state record lives**: private LN node vs. public BCH board. That one difference cascades into every advantage: no lost-state (the chain remembers), permissionless watchtower (anyone reads BCH), no must-stay-online-to-defend. The user runs **two light, self-custodial wallets** instead of (or instead of trusting) a node. One secp256k1 key signs both the BTC commitment and the BCH board update.
|
||||
|
||||
**Validated layers (real coins, chipnet + signet):**
|
||||
- **T6a — BCH board** (`BchChannelBoard.cash`): both-signed, strictly-monotonic on-chain state; a stale state can never overwrite a newer one (the board is its own watchtower). Scaled to 3 concurrent channels, permissionless read.
|
||||
- **T6b — BTC 2-of-2** (signet): funding + cooperative close.
|
||||
- **T6c — unilateral exit, both branches** (signet): the penalty/IF branch (a stale commitment is seized via the revocation key, `b35186…`) **and** the honest CSV-delayed self-close/ELSE branch (`a8d95644…`, rejected before the delay, swept after). The trustless unilateral-exit pair is complete.
|
||||
- **T6r — LN→T6 routing** (chipnet + signet): a routed payment received *into* a T6 channel; one preimage settles both chains — revealed on the BCH board (`19bcd25b…`), then read off-chain by the bridge to claim the upstream BTC HTLC (`65256a63…`). So a T6 channel that implements LN's HTLC/routing **is** an LN channel that uses BCH as its watchtower; LN payments route into it off-chain.
|
||||
|
||||
**The "penalty is a BTC tax" — why the residual friction is BTC's, not BCH's.** T6 needs a penalty + a watchtower *only because the funds stay on BTC*, which cannot deny a stale commitment (a revoked state is a perfectly valid BTC tx; BTC can't read the board to know it is old). So BTC's only option is let-it-through-and-punish. On a **BCH-native** channel a covenant simply reads the latest on-chain state and *denies* the stale settlement — no penalty, no watchtower, no timelock race. The penalty, the watchtower, sign-to-receive, and routing are the last of the "BTC tax"; they dissolve the moment value settles on BCH. **T6 shows BCH makes a better LN; the on-chain models show BCH does not need one — and T6 is the door an LN user can actually walk through.**
|
||||
|
||||
**The BTC-IOU trust floor — bonded M-of-N redemption (M3-bond).** A wrapped/locked BTC peg is *necessarily* trust-minimized, never trustless, because of the VM wall. The honest floor (tBTC-v2-style) is validated on both sides:
|
||||
- **BTC release threshold** (signet, `btc-signet-mofn-release.mjs`): an M-of-N (2-of-3) multisig releases the reserve; a single signature is rejected by consensus. BTC enforces the *release*.
|
||||
- **BCH slashable bond** (chipnet, `contracts/BondedSignerSlash.cash`): each signer posts a bond on BCH; **equivocation** — two valid signatures over the same `redeemId` with conflicting payloads — is a self-contained cryptographic fraud proof anyone can submit to slash the bond. BCH enforces the *punishment BTC cannot*. (5/5 checks: slash on conflict; reject identical/different-id proofs; honest withdraw after the unbond height; reject early withdraw.)
|
||||
- Together: collusion is possible but bonded and slashable, so a rational signer never equivocates. This is what makes Model-3's "trust the signers" into "trust-minimized," with publicly-verifiable backing on top.
|
||||
|
||||
**Alternatives considered:**
|
||||
- *Trustless wrapped BTC (a circulating BTC token with no bonded signers).* Impossible — an HTLC is reclaimable, so it cannot back a persistent token; and BTC has no covenant to bind a lock to a burn. The floor for any BTC peg is a bonded signer set. Rejected as unbuildable, not undesirable.
|
||||
- *Argue the case (whitepaper, block-size history, trust-model proofs).* Rejected for the primary audience: a conditioned reader does not integrate disconfirming facts (motivated reasoning). Lead with demonstration; keep the reasoning available for the few who want it.
|
||||
- *Treat streaming micropayments as LN's permanent moat.* Rejected — BCH batching gives near-zero amortized per-payment cost **and** real per-batch finality, while an LN per-update is not settlement (finality is deferred to the BTC channel close).
|
||||
|
||||
**Engineering record (for future cross-chain work):**
|
||||
- BTC side via `bitcoinjs-lib` v7; the HTLC/spend lib `scripts/lib/btc-htlc.mjs` needs **bigint** amounts in `hashForWitnessV0` (a `Number`→`BigInt` fix; the lib's first real run was T6r/T4).
|
||||
- Fund covenants via `TransactionBuilder` from the **live** `provider.getUtxos()` view — mainnet-js's `wallet.send()` UTXO cache goes stale under heavy key reuse and can emit a txid for a tx the network silently drops.
|
||||
- `checkDataSig(sig, msg, compressedPk)` verifies a Schnorr signature over `sha256(msg)`.
|
||||
- BCH-side gotchas (carried): no ternary `?:`; CSV is `this.age`, CLTV is `tx.time`; consume unused `split()` vars with `.length` invariants; covenant `hash160` checks need `publicKeyCompressed`.
|
||||
|
||||
**Observability & privacy.** Moving the channel state onto a public chain gives T6 a property LN structurally lacks: **off-chain BTC flow becomes auditable.** Every update is on the BCH board, so anyone can read per channel the balance split, the full state history, the update count and per-update deltas — and aggregate them across all boards for **total BTC locked in T6 channels, balance evolution, and gross in-channel flow.** LN keeps channel state private/bilateral and can expose only **capacity** (announced channels) + **open/close** events — never balances, payments, or volume. Scope honestly: it covers the **T6 subset** only, and intra-channel flow is **reallocation, not settled throughput** (finality is at the BTC close).
|
||||
|
||||
Whether this costs privacy is **baseline-dependent**, and on-chain data is **pseudonymous (addresses, not identities)** — deanonymization needs a separate linking event (KYC anchor, IP, address reuse) plus clustering, expensive at scale:
|
||||
- **vs custodial LN** (LN's trilemma funnels most users here): a **strict win** — T6 is self-custodial + no-KYC with only pseudonymous on-chain data, where custodial LN is trusted custody + full custodian visibility + usually KYC bound to real ID.
|
||||
- **vs non-custodial LN** (run a node): **identical** on identity (pseudonymous; none unless self-linked) and custody (self). The **only** delta is **flow observability** — LN hides the channel relationship/flow, T6 publishes it pseudonymously. That delta is what *buys* the auditability; it is mitigable (fresh keys per channel, no reuse) and is partly offset because a public LN routing node leaks an **IP** in gossip (bound to its pubkey + channels) whereas T6 runs **no always-online node** (smaller passive identity surface).
|
||||
|
||||
So the accurate statement is **not** "T6 trades privacy for auditability" but: **T6 adds transaction transparency + auditability while keeping users self-custodial and no-KYC** — a combination (no-KYC + self-custodial + auditable) neither LN tier offers. Full treatment: `Parameters/T6_BitcoinCash_LN/Observability-and-privacy.md`.
|
||||
|
||||
**Cross-references:** complete record + model explainer + why-demonstration + **observability-and-privacy** in `Parameters/T6_BitcoinCash_LN/`; tracker `Parameters/ASM DEX/btc-on-bch-rails_test-program.md` (rows T1/T2/T4/T5/RT/T6/T6r/T6c-honest/M3-bond); snapshots 0172 (T1), 0173 (T2), 0174 (T5/RT), 0175 (T6), 0177 (T6r), 0178 (honest-close/T4/bonded M-of-N); auto-memories [[btc-on-bch-rails-not-channel]], [[demonstration-over-reasoning-captured-audience]]. ADR-0035 (bridge/oracle trust) for the IOU mint attestation.
|
||||
|
|
|
|||
|
|
@ -106,9 +106,11 @@ Living docs for the BCH two-tier token economy platform. Updated as work progres
|
|||
- [Connector](connector.md) — Wallet ↔ dApp over WalletConnect / CashConnect: proposer (validated) + wallet-side responder (ADR-0041, build pending)
|
||||
- [On-chain game settlement threat model](onchain-game-settlement-threat-model.md) — **SETTLED:** can a losing player double-spend/reverse a staked game pot? Live-chipnet double-spend test (first-seen + two-node race both fail), the 3-layer decomposition, and the confirmation-scaling design rule
|
||||
- [Football Tote demo](football-tote-demo.md) — pari-mutuel betting on a real fixture (WC2026 opener) bet + settled live on chipnet; deployable web platform (`design/football-tote/`) + `chipnet-football-tote-test.mjs`; the oracle-result-publish path for third-party Totes
|
||||
- [Tote platform](tote-platform.md) — standalone, multi-market, event-agnostic pari-mutuel platform (football launch category); browse/create/bet/settle markets + `/embed/:id` iframe + WIF connect; `scripts/tote-platform-server.mjs` + `design/tote-platform/`; live on chipnet
|
||||
- [Voting systems](voting-systems.md) — Three on-chain voting primitives, three composition profiles, the sybil/named-signer/allowlist distinction, plutocracy framing + BTC/BCH hashwar case study (ADR-0040)
|
||||
- [Bootstrap playbook](voting-systems-bootstrap-playbook.md) — Operational guide for launching a voting system: founder cohort selection, Phase 0 viral growth, the concrete 15-phase adaptive schedule (formulas + table), Elder emergence, risk register, and external validation requirements
|
||||
- [USD rail](usd-rail.md) — BCH as a self-custodied USD payment rail: the four-option matrix (A/B/C/D), the synthetic-USD + covenant-protected-transfer + ASM-exit assembly, bridge taxonomy, and the "why not LN" preamble (ADR-0035)
|
||||
- [BTC↔BCH / T6](../Parameters/T6_BitcoinCash_LN/) — **"Bitcoin Cash as a better Lightning"** + the three-model menu (Swap / BTC-IOU / T6 lock+board). The VM wall + clerk/bailiff split; T6 replaces the LN node with a BCH on-chain "board" (no lost-state, permissionless watchtower); bonded M-of-N redemption as the BTC-IOU trust floor. Validated end-to-end on chipnet + BTC signet (ADR-0046; spec §16; snapshots 0172–0179). Design record: complete-record, model-explainer, why-demonstration, observability-and-privacy. **Observability:** T6 makes off-chain BTC flow publicly auditable (LN can't); no-KYC + self-custodial + auditable — strict privacy win vs custodial LN, equal identity/custody vs non-custodial LN. Tracker: `Parameters/ASM DEX/btc-on-bch-rails_test-program.md`.
|
||||
- [Sound-money primer](sound-money-primer.md) — **Public-facing explainer** for USDm + the FIATm family: three-value coin structure (metallic/nominal/commercial), the historical Greek Tetradrachm parallel, fiat history in reverse, the visible-inflation gauge, and the political fairness argument. For readers without project context.
|
||||
- [FIATm design record](../Parameters/FIATm/fiatm_complete-design_FOR-design-record.md) — **Technical design record** for the FIATm family (state-currency-agnostic sound-money notes on the MBA template). USDm is the worked example; EURm/GBPm/JPYm/INRm/ARSm/TRYm are chipnet-validated instances on the same contract. Covers the five-layer adaptive controller, oracle stack, TVL options, maturity ladder, calibration program. Lives under [`Parameters/FIATm/`](../Parameters/FIATm/) along with the absorbed sound-money note and the market diagram report.
|
||||
- [Seed sweep](sweep.md) — Recover BCH from old wallets: BIP-44 gap scan, single consolidated tx, security model, Capacitor notes
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue