fix(chain): RELAY_PROOF dedup by envelopeID + sticky BlockContact

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:<envelopeID> 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) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 17:51:14 +03:00
parent 78d97281f0
commit 32eec62ba4

View File

@@ -47,6 +47,7 @@ const (
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active) prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON prefixContract = "contract:" // contract:<contractID> → 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 { if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err) 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 { if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 {
return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed) 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) authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT)
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig) ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
if err != nil || !ok { if err != nil || !ok {
@@ -818,6 +831,10 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
}); err != nil { }); err != nil {
return 0, err 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: case EventBindWallet:
var p BindWalletPayload 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", return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d",
ErrTxFailed, tx.Amount, MinContactFee) 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 { if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err) 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(), CreatedAt: tx.Timestamp.Unix(),
} }
val, _ := json.Marshal(rec) val, _ := json.Marshal(rec)
key := prefixContactIn + tx.To + ":" + tx.From
if err := txn.Set([]byte(key), val); err != nil { if err := txn.Set([]byte(key), val); err != nil {
return 0, fmt.Errorf("store contact record: %w", err) return 0, fmt.Errorf("store contact record: %w", err)
} }