Durcir le chemin de l'argent : sécurité transactionnelle du règlement KVR
Trois classes réelles de vulnérabilités — rejeu de signature de paiement, frappe de récompenses sans authentification, double dépense par course — trouvées, exploitées en test et fermées dans le service de règlement du gateway.
Dans une DePIN, le chemin de l'argent est exactement aussi adverse que celui du calcul : chaque endpoint qui crédite du KVR sera tôt ou tard sondé par quelqu'un qui veut du KVR sans faire le travail. Une passe de sécurité sur le service de règlement du gateway — le processus qui vérifie les paiements on-chain et crédite stakes, récompenses de nœud et frais d'inférence — a trouvé et fermé trois classes réelles de vulnérabilités. Chacune a été démontrée par un test façon exploit avant correction, puis re-vérifiée après.
Vérifier les faits on-chain, pas les affirmations du client
Le modèle de garde de Kvasir laisse les clés aux utilisateurs : les wallets signent les transactions, Solana les enregistre, et le travail du service de règlement est de vérifier ce qui s'est réellement passé sur la chaîne avant de toucher un solde. Les paiements suivent *quote → payment → inference*, chaque signature de transaction consommée étant inscrite dans un registre à usage unique usedSignatures, impossible à présenter deux fois. Cela fait du service de règlement le goulot — et la règle qu'il ne doit jamais briser : ne créditer que ce que la chaîne prouve, jamais ce que le client affirme. Le service détient bel et bien des fonds sur le devnet : les KVR stakés et les crédits prépayés sont dans sa trésorerie et suivis dans son registre jusqu'à la mise en service d'un programme de staking on-chain.

Lier le paiement au payeur
Les signatures Solana sont publiques. La vérification de stake contrôlait que le vault avait *reçu* le KVR attendu — jamais *qui l'avait envoyé*. Un attaquant pouvait guetter sur devnet le transfert KVR→vault d'une victime, puis soumettre {owner: attaquant, signature: celle de la victime} : le contrôle de réception du vault passait, le principal était crédité à l'attaquant, et un unstake plus tard les fonds étaient à lui. Un vol direct, avec pour seul outil un explorateur de blocs.
Le correctif : le KVR doit avoir été débité de comptes de jetons appartenant à l'owner crédité.
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 insteadDes récompenses uniquement de sources authentifiées
Les endpoints de récompense de nœuds frappaient du KVR réclamable à partir d'entrées auto-déclarées : POST /api/node/contribution créditait les units que le client affirmait — units: 1e9 et un appel de claim pouvaient vider le vault — et register/heartbeat honoraient des rôles hub/gateway auto-déclarés (récompenses d'infra horaires) et des scores de performance (multiplicateurs). Le correctif place chaque assertion affectant les récompenses derrière un rapporteur de confiance : seuls le jeton de service M2M utilisé par la sonde de contribution du hub, ou un admin authentifié, peuvent affirmer units, rôles d'infra ou niveaux de perf — imposé même en mode LAN ouvert, car cela frappe du KVR. La comparaison du jeton est à temps constant, et la liaison wallet↔nœud reste libre ; elle ne peut simplement plus auto-affirmer ses récompenses.
Un seul écrivain par solde
L'état de règlement était un read-modify-write sans verrou, et chaque opération d'argent fait un *await* de paiement ou de vérification on-chain en plein milieu — cédant la boucle d'événements avec un solde périmé en main. Deux claims concurrents pouvaient lire le même solde en attente de 100 KVR et le payer tous deux. Rien de théorique : le test d'exploit a montré trois claims concurrents payant 300 pour un solde de 100.
Sérialisation asynchrone par clé : les opérations d'argent de même clé s'exécutent strictement l'une après l'autre.
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
Où en est chaque couche
| Couche | Mécanisme |
|---|---|
| Identité | Connexion par signature de wallet SIWS sur un nonce serveur + 2FA TOTP + codes de secours à usage unique |
| Transport | Node tokens dérivés du wallet sur les téléchargements de shards ; accord d'empreinte de build sur le relais |
| Paiement | Liaison de l'expéditeur sur les transferts de stake ; usedSignatures à usage unique ; requestId privé pour l'inférence |
| Règlement | Verrous par clé autour de chaque écriture de solde ; payer d'abord puis débiter ; re-soumission idempotente |
| Rapports | Faits affectant les récompenses réservés au jeton de service M2M ou à l'admin, comparés à temps constant |
| Garde | Wallets non-dépositaires — le service ne peut déplacer que ce que contient le vault, jamais les clés des utilisateurs |
Chaque correctif embarque son propre test d'exploit
- Rejouer la signature de transfert d'une victime sous le wallet d'un attaquant est désormais rejeté (« not sent by owner ») ; stakes légitimes, sur-réclamations et paiements d'inférence se comportent inchangés.
- Trois claims concurrents sur un même solde paient exactement une fois ; re-soumettre une inférence déjà payée renvoie idempotemment le même résultat.
- Les
unitsauto-affirmées, rôles hub/gateway et niveaux de perf de clients non authentifiés ne déplacent plus un seul lamport de récompenses.
Le fil conducteur des trois correctifs est un principe appliqué de trois façons : la chaîne est la source de vérité, le service est un vérificateur, et chaque solde a exactement un écrivain. Le service de règlement tourne encore sur Solana devnet — précisément là où l'on veut trouver, exploiter et corriger ces classes avant que le mainnet ne relève la mise.