From 7bbeb5f42e79419be9ca289b03c4f40263eb6c80 Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Fri, 4 Sep 2026 18:13:25 -0400 Subject: [PATCH 1/2] test(api): document per-transaction fee admission --- README.md | 1 + docs/package-fees.md | 99 +++++++++++++++++++++++++ services/api_server/package_fee_test.go | 90 ++++++++++++++++++++++ validator/package_fee_test.go | 75 +++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 docs/package-fees.md create mode 100644 services/api_server/package_fee_test.go create mode 100644 validator/package_fee_test.go diff --git a/README.md b/README.md index 2852881..a03aaea 100644 --- a/README.md +++ b/README.md @@ -575,6 +575,7 @@ Read the [AI Usage & Assistant Guidelines](.github/tech-conventions/ai-complianc ## 📚 Resources - [Architecture Documentation](doc.md) +- [Transaction fees and unconfirmed ancestors](docs/package-fees.md) — per-transaction policy and package acceptance prerequisites - [Observability](docs/observability.md) — OTLP traces/metrics, structured log field canon, and transaction-lifecycle logging - [Teranode Documentation](https://docs.bsvblockchain.org/) - [Arc API Reference](https://github.com/bitcoin-sv/arc) diff --git a/docs/package-fees.md b/docs/package-fees.md new file mode 100644 index 0000000..18e5c8b --- /dev/null +++ b/docs/package-fees.md @@ -0,0 +1,99 @@ +# Transaction fees and unconfirmed ancestors + +Arcade's `GET /policy` reports the fee rate enforced at transaction intake. +`policy.miningFee` is a ratio of satoshis to bytes; it is not a package quote +or an advertisement of child-pays-for-parent (CPFP) support. + +## Current submission behavior + +Both `/tx` and `/txs` validate fees for each parsed transaction. A batch is +not a fee-sharing package: an under-floor parent is rejected even if its +child pays enough for their combined size. The batch aborts before any +transaction is published for propagation. Structural and script checks still +apply independently of the fee check. + +Dependency-aware propagation orders transactions around parent acceptance. +It does not aggregate fees or give an underfunded parent credit for a child. +Consequently, bypassing intake fee validation alone is not a CPFP solution. + +## Admission and mining are separate policies + +The legacy Bitcoin SV node can admit a transaction below its mining rate into +its secondary mempool, subject to its rolling mempool admission floor. A +paying child can then promote a connected ancestor group into the primary +mempool for mining. This does not imply that every relay in front of the node +accepts those parents. Arcade currently enforces its advertised mining rate +at admission for each transaction. + +Source: Bitcoin SV node revision +[`879fc8b`](https://github.com/bitcoin-sv/bitcoin-sv/tree/879fc8b42168dd0e608dafd51b39c6dabad37d4d), +[`src/validation.cpp`](https://github.com/bitcoin-sv/bitcoin-sv/blob/879fc8b42168dd0e608dafd51b39c6dabad37d4d/src/validation.cpp) +and [`src/txmempool.cpp`](https://github.com/bitcoin-sv/bitcoin-sv/blob/879fc8b42168dd0e608dafd51b39c6dabad37d4d/src/txmempool.cpp). +Node source behavior is not evidence of a particular deployment's settings. + +Teranode `v0.16.0-beta-9`, the version pinned by Arcade, instead enforces the +mining fee during per-transaction policy validation, before admitting the +transaction to block assembly. Its assembly path receives validated +transactions and does not implement ancestor-fee selection. The source also +labels CPFP fee calculation as not actively implemented. The same behavior +was found on main at `40faeb0f0bfcf94baf1eaa69517b5e5889af0d89`. + +Pinned Teranode sources: +[BDK fee policy](https://github.com/bsv-blockchain/teranode/blob/eec632537b940967dd705092bd63054af235c421/services/validator/ScriptVerifierGoBDK.go#L206-L224), +[admission before assembly](https://github.com/bsv-blockchain/teranode/blob/eec632537b940967dd705092bd63054af235c421/services/validator/Validator.go#L790-L824), +[CPFP setting caveat](https://github.com/bsv-blockchain/teranode/blob/eec632537b940967dd705092bd63054af235c421/settings/policy_settings.go#L16-L17), +and [candidate construction](https://github.com/bsv-blockchain/teranode/blob/eec632537b940967dd705092bd63054af235c421/services/blockassembly/BlockAssembler.go#L1407-L1553). + +Accepting a valid block containing low-fee transactions is a separate consensus +operation. It does not establish that the node admits those transactions via +its normal policy path or constructs such a block itself. Disabling policy +checks to demonstrate block validity would not test CPFP support. + +## Wallet implications + +For an ordinary transaction, estimate its serialized transaction size and +apply the advertised rate, rounding up to whole satoshis. BEEF transport bytes +are not the transaction size used for its mining fee. Recalculate after input +selection and signing-size changes. The final wallet fee can differ from a +payload-only estimate. + +Unconfirmed does not mean underfunded. Confirmed ancestors contribute no +shortfall, and sufficiently funded unconfirmed ancestors need no additional +payment. New parent transactions should meet the applicable fee policy when +they are created. + +If a relay and miner explicitly support the relevant package policy, a wallet +can calculate a candidate child fee as: + +```text +max(ceil(rate * childBytes), + ceil(rate * (childBytes + ancestorBytes)) - ancestorFees) +``` + +Here `ancestorBytes` and `ancestorFees` describe the unique, unconfirmed +ancestors of that child. Their fees must be derived from authenticated source +output values, and confirmation state must be verified. Recompute the set +when funding inputs change; do not assume unknown ancestry is confirmed or +has no shortfall. The candidate must also satisfy the target miner's package +limits and grouping rules. This equation does not establish that Arcade or a +particular miner accepts the package. + +## Acceptance prerequisite for a future implementation + +A functional change needs an isolated test through the intended node version +and submission interface. Use a valid under-floor parent and a child that +covers the shortfall. Verify both admission and inclusion by the node's normal +block-template/mining path. Also exercise insufficient combined fees, invalid +scripts, missing ancestry, duplicate/shared ancestors, and already-confirmed +parents. A mocked downstream success or an HTTP success is insufficient. + +If the node supports the required package policy, Arcade must validate and +forward the connected package using that supported interface, while retaining +script, value, double-spend, and resource-limit checks. If the node rejects +each under-floor transaction independently, node support is required before +Arcade can offer this behavior. Advertise a capability only once the entire +configured submission path supports it. + +The regression tests accompanying this document characterize Arcade's +current per-transaction fee contract. They do not enable package acceptance +or change the advertised fee rate. diff --git a/services/api_server/package_fee_test.go b/services/api_server/package_fee_test.go new file mode 100644 index 0000000..a6e4bff --- /dev/null +++ b/services/api_server/package_fee_test.go @@ -0,0 +1,90 @@ +package api_server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bsv-blockchain/go-sdk/script" + sdkTx "github.com/bsv-blockchain/go-sdk/transaction" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + arcerrors "github.com/bsv-blockchain/arcade/errors" + "github.com/bsv-blockchain/arcade/kafka" + "github.com/bsv-blockchain/arcade/validator" +) + +func apiPackageFeeFixture(t *testing.T) (parentRaw, childRaw []byte, parentID string) { + parent := sdkTx.NewTransaction() + parent.Version = 2 + require.NoError(t, parent.AddInputFrom( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, "51", 100_000, nil, + )) + parent.AddOutput(&sdkTx.TransactionOutput{ + Satoshis: 99_999, + LockingScript: script.NewFromBytes([]byte{script.OpTRUE}), + }) + parent.Inputs[0].UnlockingScript = script.NewFromBytes([]byte{script.OpTRUE}) + child := sdkTx.NewTransaction() + child.Version = 2 + child.AddInputFromTx(parent, 0, nil) + child.Inputs[0].UnlockingScript = script.NewFromBytes([]byte{script.OpTRUE}) + child.AddOutput(&sdkTx.TransactionOutput{ + Satoshis: 98_999, + LockingScript: script.NewFromBytes([]byte{script.OpTRUE}), + }) + + var err error + parentRaw, err = parent.EF() + require.NoError(t, err) + childRaw, err = child.EF() + require.NoError(t, err) + return parentRaw, childRaw, parent.TxID().String() +} + +func TestPackageFeeBatchRejectsUnderpaidAncestorBeforePropagation(t *testing.T) { + parentRaw, childRaw, parentID := apiPackageFeeFixture(t) + + // Child first proves it has complete EF source data and is independently + // valid; the later parent must still fail the batch's per-tx fee loop. + body := append(append([]byte{}, childRaw...), parentRaw...) + child, used, err := sdkTx.NewTransactionFromStream(body) + require.NoError(t, err) + require.Equal(t, len(childRaw), used) + require.Len(t, child.Inputs, 1) + require.NotNil(t, child.Inputs[0].SourceTxOutput()) + parent, used, err := sdkTx.NewTransactionFromStream(body[used:]) + require.NoError(t, err) + require.Equal(t, len(parentRaw), used) + require.Len(t, parent.Inputs, 1) + require.NotNil(t, parent.Inputs[0].SourceTxOutput()) + + broker := &kafka.RecordingBroker{} + srv, router := setupServerWithStore(broker, &mockStore{}) + minFeePerKB := uint64(100) + srv.validator = validator.NewValidator(&validator.Policy{MinFeePerKB: &minFeePerKB}) + gin.SetMode(gin.TestMode) + + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/txs", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/octet-stream") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + var response struct { + TxID string `json:"txid"` + Status int `json:"status"` + Reason string `json:"reason"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Equal(t, parentID, response.TxID) + assert.Equal(t, int(arcerrors.StatusFees), response.Status) + assert.Contains(t, response.Reason, "fee is too low") + assert.Equal(t, 0, totalMessages(broker), "fee rejection must publish no propagation messages") +} diff --git a/validator/package_fee_test.go b/validator/package_fee_test.go new file mode 100644 index 0000000..1106487 --- /dev/null +++ b/validator/package_fee_test.go @@ -0,0 +1,75 @@ +package validator + +import ( + "context" + "testing" + + "github.com/bsv-blockchain/go-sdk/script" + sdkTx "github.com/bsv-blockchain/go-sdk/transaction" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + arcerrors "github.com/bsv-blockchain/arcade/errors" +) + +// packageFeeFixture returns a linked pair whose parent pays one satoshi and +// whose child pays enough to cover the 100 sat/kB floor for the pair. The +// source outputs are attached so validation exercises scripts and fees. +func packageFeeFixture(t *testing.T) (parent, child *sdkTx.Transaction, parentRaw, childRaw []byte) { + parent = sdkTx.NewTransaction() + parent.Version = 2 + require.NoError(t, parent.AddInputFrom( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, "51", 100_000, nil, + )) + parent.AddOutput(&sdkTx.TransactionOutput{ + Satoshis: 99_999, + LockingScript: script.NewFromBytes([]byte{script.OpTRUE}), + }) + parent.Inputs[0].UnlockingScript = script.NewFromBytes([]byte{script.OpTRUE}) + + child = sdkTx.NewTransaction() + child.Version = 2 + child.AddInputFromTx(parent, 0, nil) + child.Inputs[0].UnlockingScript = script.NewFromBytes([]byte{script.OpTRUE}) + child.AddOutput(&sdkTx.TransactionOutput{ + Satoshis: 98_999, + LockingScript: script.NewFromBytes([]byte{script.OpTRUE}), + }) + + var err error + parentRaw, err = parent.EF() + require.NoError(t, err) + childRaw, err = child.EF() + require.NoError(t, err) + return +} + +func TestPackageFeeAncestorBelowFloorIsRejectedIndividually(t *testing.T) { + parent, child, parentRaw, childRaw := packageFeeFixture(t) + minFeePerKB := uint64(100) + parentFee, err := parent.GetFee() + require.NoError(t, err) + childFee, err := child.GetFee() + require.NoError(t, err) + assert.NotEmpty(t, parentRaw) + assert.NotEmpty(t, childRaw) + + parentFloor := (uint64(len(parent.Bytes()))*minFeePerKB + 999) / 1000 + packageFloor := (uint64(len(parent.Bytes())+len(child.Bytes()))*minFeePerKB + 999) / 1000 + assert.Equal(t, uint64(1), parentFee) + assert.Less(t, parentFee, parentFloor) + assert.GreaterOrEqual(t, childFee, packageFloor) + assert.GreaterOrEqual(t, parentFee+childFee, packageFloor) + + v := NewValidator(&Policy{MinFeePerKB: &minFeePerKB}) + ctx := context.Background() + require.NoError(t, v.ValidateTransaction(ctx, parent, true), "parent should pass non-fee script/structure checks") + require.NoError(t, v.ValidateTransaction(ctx, child, false), "child should pass its own fee check") + + err = v.ValidateTransaction(ctx, parent, false) + require.Error(t, err) + arcErr := arcerrors.GetArcError(err) + require.NotNil(t, arcErr) + assert.Equal(t, arcerrors.StatusFees, arcErr.StatusCode) +} From b07f1cdb49a612dfb1f86ffe4b7823479ce60abd Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Fri, 4 Sep 2026 18:14:32 -0400 Subject: [PATCH 2/2] docs(fees): cite legacy mining regression --- docs/package-fees.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/package-fees.md b/docs/package-fees.md index 18e5c8b..eb4f4e1 100644 --- a/docs/package-fees.md +++ b/docs/package-fees.md @@ -29,7 +29,11 @@ Source: Bitcoin SV node revision [`879fc8b`](https://github.com/bitcoin-sv/bitcoin-sv/tree/879fc8b42168dd0e608dafd51b39c6dabad37d4d), [`src/validation.cpp`](https://github.com/bitcoin-sv/bitcoin-sv/blob/879fc8b42168dd0e608dafd51b39c6dabad37d4d/src/validation.cpp) and [`src/txmempool.cpp`](https://github.com/bitcoin-sv/bitcoin-sv/blob/879fc8b42168dd0e608dafd51b39c6dabad37d4d/src/txmempool.cpp). -Node source behavior is not evidence of a particular deployment's settings. +The legacy node also has a [functional CPFP test](https://github.com/bitcoin-sv/bitcoin-sv/blob/879fc8b42168dd0e608dafd51b39c6dabad37d4d/test/functional/bsv-cpfp.py#L86-L132) +that checks low-fee ancestors are absent from a mining candidate until a +paying child arrives, then mines the group. This test was reviewed in source, +not executed for this change. Node source behavior is not evidence of a +particular deployment's settings. Teranode `v0.16.0-beta-9`, the version pinned by Arcade, instead enforces the mining fee during per-transaction policy validation, before admitting the