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) }