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:
@@ -47,6 +47,7 @@ const (
|
||||
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
|
||||
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
|
||||
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
|
||||
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user