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