From 23b4f1d59f5a06b39d213300aa88c2dd6a196e6a Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Mon, 8 Jun 2026 03:21:12 +0000 Subject: [PATCH] fix(publish): enforce the AllowMainnet gate in BuildPSBT (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding #23. Submit refuses mainnet without AllowMainnet, but BuildPSBT — the external/hardware-signer flow — had no such gate, so on mainnet it pinned DID files to public IPFS and funded a real-BTC spend with no check firing (the out-of-band signer then completes it). Add the same Params.Name==MainNetParams.Name && !AllowMainnet gate to BuildPSBT, checked BEFORE the IPFS pin. Test: TestBuildPSBTMainnetGuard (refused without AllowMainnet; builds with it). Mutation-verified. go test -race ./... green. Co-authored-by: Liran Cohen Co-authored-by: Claude Opus 4.8 (1M context) --- docs/hardening-backlog.md | 2 +- internal/publish/psbt.go | 6 ++++++ internal/publish/psbt_test.go | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/hardening-backlog.md b/docs/hardening-backlog.md index c3cf7a9..7cd26ef 100644 --- a/docs/hardening-backlog.md +++ b/docs/hardening-backlog.md @@ -50,7 +50,7 @@ All items below survived an independent skeptic re-reading the cited code. | 20 | 🟡 medium | trivial | resource | `cas-unbounded-readall-error-body` | ✅ done (#87) | | 21 | 🟡 medium | small | security | `ban-reset-on-disconnect` | ✅ done (#93) | | 22 | ⚪ low | trivial | security | `keystore-open-no-perm-check` | ✅ done (#97) | -| 23 | ⚪ low | trivial | operational | `buildpsbt-missing-mainnet-gate` | open | +| 23 | ⚪ low | trivial | operational | `buildpsbt-missing-mainnet-gate` | ✅ done (#109) | | 24 | ⚪ low | small | correctness | `scan-cid-shape-only-no-multihash-validation` | open | | 25 | ⚪ low | trivial | operational | `config-command-hides-trust-anchors` | ✅ done (#107) | | 26 | ⚪ low | trivial | correctness | `scan-zero-and-overcount-consume-block-cap-slot` | open | diff --git a/internal/publish/psbt.go b/internal/publish/psbt.go index d8bd298..6f2a15c 100644 --- a/internal/publish/psbt.go +++ b/internal/publish/psbt.go @@ -64,6 +64,12 @@ func (w *Writer) BuildPSBT(ctx context.Context, ops []anchor.Op, utxos []btctx.U if w.cfg.Params == nil { return nil, fmt.Errorf("publish: nil chain params") } + // Same mainnet gate as Submit: building a PSBT pins the DID files to public IPFS + // and funds a real-BTC spend (the out-of-band signer then completes it), so it + // must not proceed on mainnet without AllowMainnet. Checked BEFORE the IPFS pin. + if w.cfg.Params.Name == chaincfg.MainNetParams.Name && !w.cfg.AllowMainnet { + return nil, fmt.Errorf("publish: refusing to build a mainnet anchoring PSBT without AllowMainnet (pins to public IPFS and funds a real-BTC spend)") + } if err := btctx.ValidateFeeRate(w.cfg.FeeRate); err != nil { return nil, err } diff --git a/internal/publish/psbt_test.go b/internal/publish/psbt_test.go index 090efef..1335bfd 100644 --- a/internal/publish/psbt_test.go +++ b/internal/publish/psbt_test.go @@ -275,3 +275,21 @@ func TestSignPSBTRefusesFeeOverCap(t *testing.T) { t.Fatalf("SignPSBT under a generous cap should succeed: %v", err) } } + +// TestBuildPSBTMainnetGuard guards #23: BuildPSBT pins to public IPFS and funds a +// real-BTC spend, so it must enforce the same AllowMainnet gate as Submit. +func TestBuildPSBTMainnetGuard(t *testing.T) { + key := mkFundingKey(t) + w := New(&fakeCAS{}, &fakeBroadcaster{}, key, Config{Prefix: "ion:", FeeRate: 2, Params: &chaincfg.MainNetParams}, nil) + ops := []anchor.Op{mkCreateOp(t, "psbtmain")} + utxos := []btctx.UTXO{fundingUTXO(key, 100_000)} + + if _, err := w.BuildPSBT(context.Background(), ops, utxos, key.PkScript()); err == nil || !strings.Contains(err.Error(), "AllowMainnet") { + t.Errorf("BuildPSBT must refuse mainnet without AllowMainnet, got %v", err) + } + // With the gate open, the mainnet guard no longer blocks (the PSBT builds). + w.cfg.AllowMainnet = true + if _, err := w.BuildPSBT(context.Background(), ops, utxos, key.PkScript()); err != nil { + t.Errorf("with AllowMainnet the gate should not block: %v", err) + } +}