From 32eec62ba43ee630e3bad28db4c5a1a31a001d8b Mon Sep 17 00:00:00 2001 From: vsecoder Date: Sat, 18 Apr 2026 17:51:14 +0300 Subject: [PATCH] fix(chain): RELAY_PROOF dedup by envelopeID + sticky BlockContact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RELAY_PROOF previously had no per-envelope dedup — every relay that saw the gossipsub re-broadcast could extract the sender's FeeSig from the envelope and submit its own RELAY_PROOF claim with its own RelayPubKey. The tx-ID uniqueness check didn't help because tx.ID = sha256(relayPubKey||envelopeID)[:16], which is unique per (relay, envelope) pair. A malicious mesh of N relays could drain N× the fee from the sender's balance for a single message. Fix: record prefixRelayProof: on first successful apply and reject subsequent claims for the same envelope. CONTACT_REQUEST previously overwrote any prior record (including a blocked one) back to pending, letting spammers unblock themselves by paying another MinContactFee. Now the handler reads the existing record first and rejects the tx with "recipient has blocked sender" when prev.Status == ContactBlocked. Block becomes sticky. Co-Authored-By: Claude Opus 4.7 (1M context) --- blockchain/chain.go | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/blockchain/chain.go b/blockchain/chain.go index ace73af..1ce8361 100644 --- a/blockchain/chain.go +++ b/blockchain/chain.go @@ -47,6 +47,7 @@ const ( prefixPayChan = "paychan:" // paychan: → PayChanState JSON prefixRelay = "relay:" // relay: → RegisterRelayPayload JSON prefixRelayHB = "relayhb:" // relayhb: → unix seconds (int64) of last HB + prefixRelayProof = "relayproof:" // relayproof: → claimant node_pubkey (1 claim per envelope) prefixContactIn = "contact_in:" // contact_in:: → contactRecord JSON prefixValidator = "validator:" // validator: → "" (presence = active) prefixContract = "contract:" // contract: → ContractRecord JSON @@ -795,9 +796,21 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) { if err := json.Unmarshal(tx.Payload, &p); err != nil { return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err) } + if p.EnvelopeID == "" { + return 0, fmt.Errorf("%w: RELAY_PROOF: envelope_id is required", ErrTxFailed) + } if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 { return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed) } + // Per-envelope dedup — only one relay may claim the fee for a given + // envelope. Without this check, every relay that saw the gossipsub + // re-broadcast could extract the sender's FeeSig and submit its own + // RELAY_PROOF, draining the sender's balance by N× for one message. + proofKey := []byte(prefixRelayProof + p.EnvelopeID) + if _, err := txn.Get(proofKey); err == nil { + return 0, fmt.Errorf("%w: RELAY_PROOF: envelope %s already claimed", + ErrTxFailed, p.EnvelopeID) + } authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT) ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig) if err != nil || !ok { @@ -818,6 +831,10 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) { }); err != nil { return 0, err } + // Mark envelope as claimed — prevents replay by other relays. + if err := txn.Set(proofKey, []byte(p.RelayPubKey)); err != nil { + return 0, fmt.Errorf("mark relay proof: %w", err) + } case EventBindWallet: var p BindWalletPayload @@ -956,6 +973,19 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) { return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d", ErrTxFailed, tx.Amount, MinContactFee) } + // Sticky block — if recipient previously blocked this sender, refuse + // the new request instead of silently overwriting the blocked status + // back to pending. Prevents unblock-via-respam. + key := prefixContactIn + tx.To + ":" + tx.From + if item, err := txn.Get([]byte(key)); err == nil { + var prev contactRecord + if verr := item.Value(func(val []byte) error { + return json.Unmarshal(val, &prev) + }); verr == nil && prev.Status == string(ContactBlocked) { + return 0, fmt.Errorf("%w: CONTACT_REQUEST: recipient has blocked sender", + ErrTxFailed) + } + } if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil { return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err) } @@ -970,7 +1000,6 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) { CreatedAt: tx.Timestamp.Unix(), } val, _ := json.Marshal(rec) - key := prefixContactIn + tx.To + ":" + tx.From if err := txn.Set([]byte(key), val); err != nil { return 0, fmt.Errorf("store contact record: %w", err) }