Hardening the Money Path: Transaction Security in KVR Settlement

Three real vulnerability classes — payment-signature replay, unauthenticated reward minting, and race double-spends — found, exploited in tests, and closed in the gateway's settlement service.

In a DePIN, the money path is exactly as adversarial as the compute path: every endpoint that credits KVR will eventually be probed by someone who wants KVR without doing the work. A security pass over the gateway's settlement service — the process that verifies on-chain payments and credits stakes, node rewards and inference charges — found and closed three real vulnerability classes. Each one was demonstrated with an exploit-style test before the fix and re-verified after.

The trust model

Verify on-chain facts, not client claims

Kvasir's custody model keeps keys with users: wallets sign transactions, Solana records them, and the settlement service's only job is to verify what actually happened on chain before touching a balance. Payments follow *quote → payment → inference*, with every consumed transaction signature recorded in a one-shot usedSignatures registry so it can never be presented twice. That makes the settlement service the chokepoint — and the rule it must never break is: credit only what the chain proves, never what the client asserts.

A settlement vault guarded by three locks: sender binding, trusted reporter, and a serialization gate
Fix #1 · sender binding

Bind the payment to the payer

Solana signatures are public. The stake-verification path checked that the vault *received* the expected KVR — but never *who sent it*. An attacker could watch devnet for a victim's KVR→vault transfer, then submit {owner: attacker, signature: victim's}: the vault-received check passed, the principal was credited to the attacker, and one unstake later the funds were theirs. Direct theft, using nothing but a block explorer.

The fix: the KVR must have been debited from token accounts owned by the credited owner.

verifyStakeTransfer(signature, owner, amount):
  delta(vault)  >= amount            # vault actually received it (old check)
  Σ debits from token accounts
    whose owner == credited owner    # NEW — sender binding
                >= amount            # summed across that owner's accounts
  # inference path (no owner): bound by private requestId
  # + one-shot usedSignatures instead
Fix #2 · trusted reporter

Rewards only from authenticated sources

The node-reward endpoints minted claimable KVR from self-reported input: POST /api/node/contribution credited whatever units the client claimed — units: 1e9 and a claim call could drain the vault — and register/heartbeat honored self-declared hub/gateway roles (hourly infra rewards) and performance scores (reward multipliers). The fix gates every reward-affecting assertion behind a trusted reporter: only the M2M service token used by the hub's contribution poll, or an authenticated admin, may assert units, infra roles or perf tiers — enforced even in open LAN mode, because these mint KVR. The token comparison is constant-time, and wallet↔node linking still works freely; it just can't assert its own rewards anymore.

Fix #3 · settlement serialization

One writer on every balance

Settlement state was a lock-free read-modify-write, and every money operation *awaits* an on-chain payout or verification in the middle — yielding the event loop with a stale balance in hand. Two concurrent claims could both read the same 100 KVR pending balance and both pay out. This wasn't theoretical: the exploit test showed three concurrent claims paying 300 for a 100 balance.

Per-key async serialization: same-key money operations run strictly one after another.

withLock(key, fn)         # per-key promise chain, self-cleaning map
  stake / unstake / claim  → keyed by owner
  inference settlement     → keyed by requestId
inside the lock:
  usedSignatures check + credit   # no same-signature double-credit
  pay out FIRST, then debit       # failed payout leaves balance intact
Defense in depth

Where each layer now stands

LayerMechanism
IdentitySIWS wallet-signature login over a server nonce + TOTP 2FA + single-use backup codes
TransportWallet-derived node tokens on shard downloads; build-fingerprint agreement on the relay
PaymentSender binding on stake transfers; one-shot usedSignatures; private requestId on inference
SettlementPer-key locks around every balance write; pay-first-then-debit; idempotent re-submit
ReportingReward-affecting facts only from the M2M service token or admin, constant-time compared
CustodyNon-custodial wallets — the service can move only what the vault holds, never user keys
Measured, not assumed

Each fix carries its own exploit test

  • Replaying a victim's transfer signature under an attacker's wallet is now rejected ("not sent by owner"); legitimate stakes, over-claims and inference payments behave unchanged.
  • Three concurrent claims against one balance pay exactly once; re-submitting an already-paid inference idempotently returns the same result.
  • Self-asserted units, hub/gateway roles and perf tiers from unauthenticated clients no longer move a single lamport of rewards.

The through-line of all three fixes is one principle applied three ways: the chain is the source of truth, the service is a verifier, and every balance has exactly one writer. The settlement service still runs on Solana devnet — which is exactly where you want to find, exploit and fix these classes before mainnet raises the stakes.