Aller au contenu principal

Authentificateurs et dépenses déléguées

Les voies d'authentificateur (SDK 0.7.0, chaîne v3.1.85) permettent à une clé externe liée — une clé ed25519 Phantom, ou une clé secp256k1 MetaMask / EVM — de dépenser depuis LE compte canonique unique à PQC obligatoire, selon des conditions de moindre privilège, plafonnées en dépenses et révocables, sans que la clé externe ne produise jamais de co-signature ML-DSA.

C'est le pendant côté SDK du module d'abstraction de compte de la chaîne.

Le modèle du relayeur

Un relayeur soumet la transaction et paie les frais. La signature hybride (classique + ML-DSA-87) propre au relayeur satisfait le ante handler sur l'enveloppe, si bien que la signature PQC du compte canonique n'est pas requise on-chain. L'autorisation est à la place la signature de la clé liée sur un condensat de sign-bytes à séparation de domaine et protégé contre le rejeu.

Phantom / MetaMask key Relayer (pays fees) Chain
───────────────────── ─────────────────── ─────
sign(authSignBytes) ──────────▶ wrap in Msg, sign envelope ──▶ verify authenticator sig
check permission + rule
spend FROM canonical account

Le relayeur étant un compte différent de celui du propriétaire, il n'incrémente pas le nonce EVM du compte.

Les trois voies

VoieMessageSign-bytesDépense
EVMMsgExecuteEVMevmAuthSignBytesQOR natif / appel EVM depuis l'adresse 0x du compte
NativeMsgExecuteCosmoscosmosAuthSignBytesQOR natif via x/bank depuis le compte
Rotation de cléMsgRotatePQCKeyrotationSignBytes(fait tourner la clé PQC du compte)

Les URL de type des messages sont /qorechain.abstractaccount.v1.MsgExecuteEVM, /qorechain.abstractaccount.v1.MsgExecuteCosmos et /qorechain.pqc.v1.MsgRotatePQCKey.

Enregistrer un authentificateur Phantom

La liaison d'une clé est signée par le propriétaire (une transaction hybride normale du compte canonique) : MsgRegisterAuthenticator désigne la clé (schéma + octets bruts de la clé publique), les permissions accordées et une échéance de session expiryUnix. Les plafonds de dépense sont attachés avec une SpendingRule via MsgUpdateSpendingRules :

import { msg } from "@qorechain/sdk";

// The Phantom wallet in the browser:
const phantomPubkey = window.solana.publicKey.toBytes(); // 32-byte ed25519

// 1) Link the key: least privilege ("send" only) + a session expiry.
const register = msg.abstractaccount.registerAuthenticator({
owner: canonicalAccount, // the PQC-required account ("qor1…")
accountAddress: canonicalAccount, // the account the key may act for
scheme: "ed25519", // Phantom keys are ed25519
pubkey: phantomPubkey,
permissions: ["send"], // e.g. "send", "evm", "svm" — never "all" for a hot key
expiryUnix: String(Math.floor(Date.now() / 1000) + 7 * 24 * 3600), // 7 days
label: "phantom",
});

// 2) Bound what it can move: per-tx and daily limits, uqor only.
const limits = msg.abstractaccount.updateSpendingRules({
owner: canonicalAccount,
accountAddress: canonicalAccount,
rules: [
{
id: "phantom-hot",
perTxLimit: "1000000", // ≤ 1 QOR per spend
dailyLimit: "10000000", // ≤ 10 QOR per day
allowedDenoms: ["uqor"],
enabled: true,
},
],
});

// Broadcast BOTH owner-signed (hybrid) — e.g. via the hybrid tx path:
// await signAndBroadcastHybrid({ ..., messages: [register, limits] });

Pour désactiver instantanément une clé, le propriétaire diffuse msg.abstractaccount.revokeAuthenticator({ owner, accountAddress, scheme, pubkey }).

Dépenser depuis Phantom (voie Native, via un relayeur)

Une fois la clé liée, le navigateur construit un MsgExecuteCosmos prêt pour le relayeur : buildPhantomExecuteCosmos reconstruit le condensat à séparation de domaine, le fait signer par Phantom (signMessage) et renvoie le message { typeUrl, value }.

Navigateur (l'utilisateur Phantom) :

import { buildPhantomExecuteCosmos } from "@qorechain/sdk";

// window.solana is a Phantom-style wallet: { publicKey, signMessage }.
const msgExecute = await buildPhantomExecuteCosmos({
wallet: window.solana,
relayer: relayerAddress, // who will submit + pay fees
chainId: "qorechain-vladi",
account: canonicalAccount, // the PQC-required owner
to: recipient, // "qor1…"
amount: "100uqor", // single-coin amount string
nonce, // the per-authenticator sequence for (account, pubkey)
});

// Ship `msgExecute` to your relayer service (it is already signed by Phantom):
await fetch("/api/relay", {
method: "POST",
body: JSON.stringify({
typeUrl: msgExecute.typeUrl,
value: {
...msgExecute.value,
pubkey: Buffer.from(msgExecute.value.pubkey).toString("base64"),
signature: Buffer.from(msgExecute.value.signature).toString("base64"),
nonce: msgExecute.value.nonce.toString(),
},
}),
});

Serveur (le relayeur) : signe l'enveloppe avec son propre compte (hybride, comme d'habitude sur la voie Native) et paie les frais. La signature de l'authentificateur contenue dans le message constitue l'autorisation de dépenser depuis le compte du propriétaire.

import {
createClient,
deriveNativeAccount,
directSignerFromPrivateKey,
} from "@qorechain/sdk";

const client = createClient({
network: "mainnet",
endpoints: {
rpc: "https://rpc.qore.host",
rest: "https://api.qore.host",
},
});

// The relayer's OWN account — a different account than the owner.
const relayer = await deriveNativeAccount(process.env.RELAYER_MNEMONIC!);
const signer = await directSignerFromPrivateKey(relayer.privateKey, "qor");
const tx = await client.connectTx(signer);

// Decode the message from the request, then broadcast it (relayer pays fees).
const result = await tx.signAndBroadcast([msgExecute], { fee });
console.log(result.transactionHash);

Une version exécutable de bout en bout (avec une clé ed25519 locale tenant le rôle de Phantom) est l'exemple authenticator-spend.

Dépenser depuis MetaMask (voie EVM)

Une clé MetaMask est liée par son adresse ETH de 20 octets (schéma secp256k1) avec registerEthAuthenticatorMsg, et autorise les dépenses avec une signature personal_sign EIP-191 de 65 octets sur le même type de condensat.

1) Le propriétaire lie l'adresse MetaMask (signé par le propriétaire, hybride) :

import { registerEthAuthenticatorMsg } from "@qorechain/sdk";

const [ethAddress] = await window.ethereum.request({
method: "eth_requestAccounts",
params: [],
});

const register = registerEthAuthenticatorMsg({
owner: canonicalAccount,
ethAddress, // 0x-hex 20-byte address = the authenticator pubkey
permissions: ["evm"], // EVM lane only
expiryUnix: Math.floor(Date.now() / 1000) + 24 * 3600, // 24 h session
label: "metamask",
});
// broadcast owner-signed (hybrid), like any other message

2) MetaMask autorise un transfert EVMbuildMetaMaskExecuteEvm construit le condensat, demande personal_sign (EIP-191) au fournisseur et renvoie un MsgExecuteEVM prêt pour le relayeur :

import { buildMetaMaskExecuteEvm } from "@qorechain/sdk";

const msgExecute = await buildMetaMaskExecuteEvm({
provider: window.ethereum, // any EIP-1193 provider
address: ethAddress, // the linked 20-byte address (0x-hex)
relayer: relayerAddress,
chainId: "qorechain-vladi",
account: canonicalAccount, // the PQC-required owner
to: "0xRecipient…", // 0x-hex recipient
value: "1000000000000000000",// decimal wei string (EVM lane: 18 decimals)
gasLimit: 100000,
nonce: evmNonce, // the account's CURRENT EVM nonce — do NOT +1
});
// hand `msgExecute` to the relayer, exactly as in the Phantom flow

buildMetaMaskExecuteCosmos fonctionne de la même manière pour la voie Native (to: "qor1…", amount: "100uqor", nonce = la séquence par authentificateur). Il existe des composeurs bas niveau correspondants — executeEvmMsg, executeCosmosMsg, registerEthAuthenticatorMsg, revokeAuthenticatorMsg, rotatePqcKeyMsg — si vous gérez vous-même les clés et les signatures.

Sign-bytes (à l'octet près)

Deux fonctions utilitaires d'octets : BE64(n) est un entier big-endian de 8 octets ; LP(bytes) est BE64(len) ‖ bytes (préfixé par la longueur).

Voie EVMevmAuthSignBytes({ chainId, account, pubkey, to, value, data, nonce }) renvoie un condensat de 32 octets :

sha256( "qorechain-evm-auth-v1"
‖ LP(chainId) ‖ LP(account) ‖ LP(pubkey)
‖ LP(to) ‖ LP(value) ‖ LP(data) ‖ BE64(nonce) )

to est le destinataire en hexadécimal 0x, value la chaîne décimale en wei, data le calldata brut.

Voie NativecosmosAuthSignBytes({ chainId, account, pubkey, to, amount, nonce }) renvoie un condensat de 32 octets :

sha256( "qorechain-cosmos-auth-v1"
‖ LP(chainId) ‖ LP(account) ‖ LP(pubkey)
‖ LP(to) ‖ LP(amount) ‖ BE64(nonce) )

amount est la chaîne canonique à une seule pièce (par ex. 100uqor).

RotationrotationSignBytes(chainId, algorithmId, account, oldPub, newPub) renvoie la chaîne que les deux clés signent (son encodage UTF-8) :

qorechain-pqc-rotate-v1|<chainId>|<algorithmId>|<account>|<oldHex>|<newHex>

Nonces

  • MsgExecuteEVM.nonce = le nonce EVM actuel du compte (le relayeur est un compte différent et n'incrémente pas le nonce du propriétaire, donc n'ajoutez pas 1).
  • MsgExecuteCosmos.nonce = la séquence par authentificateur pour (account, pubkey) — un compteur du store distinct de la séquence propre au compte, incrémenté à chaque dépense réussie sur la voie Native.

Se tromper de nonce provoque un rejet pour rejeu (code 11 de abstractaccount, voir ci-dessous).

// EVM lane: the account's current nonce, straight from the EVM JSON-RPC.
const evmNonce = await client.evm.call<string>("eth_getTransactionCount", [
account0x,
"latest",
]);

Le schéma de permissions

La chaîne publie la taxonomie canonique des permissions d'authentificateur afin que les clients valident les portées sans coder les chaînes en dur, et détectent toute dérive via schema_version :

// REST (LCD):
const schema = await client.rest.getPermissionSchema();

schema.schema_version; // bumps on any taxonomy/mapping change
schema.permissions; // ["send", "evm", "svm", "all", ...]
schema.msg_permissions; // { "/qorechain.abstractaccount.v1.MsgExecuteEVM": "evm", ... }
schema.key_management_msgs; // typeURLs NEVER delegable to a linked key

La route REST est GET /qorechain/abstractaccount/v1/permission_schema ; le client de requêtes gRPC typé expose les mêmes données via clients.abstractaccount.permissionSchema(). Le module sert également /config, /accounts et /accounts/{address}.

Codes d'erreur

Les échecs se décodent via decodeTxError avec un kind lisible :

CodespaceCodeKind
abstractaccount5spending_limit_exceeded
abstractaccount6session_key_expired
abstractaccount10permission_denied
abstractaccount11authenticator_replay
pqc21hybrid_verify_failed
import { decodeTxError } from "@qorechain/sdk";

const decoded = decodeTxError({
code: result.code,
codespace: result.codespace,
rawLog: result.rawLog,
});

switch (decoded.kind) {
case "spending_limit_exceeded": // over the per-tx or daily SpendingRule
break;
case "session_key_expired": // expiryUnix passed — re-register the key
break;
case "permission_denied": // scope missing — check the permission_schema
break;
case "authenticator_replay": // wrong nonce — refetch and re-sign
break;
case "hybrid_verify_failed": // ML-DSA sig did not verify (see note below)
break;
}

hybrid_verify_failed signifie le plus souvent une signature ML-DSA-87 hedged (non déterministe) — la chaîne n'accepte que les signatures déterministes. C'est aussi ce que vous voyez si un SDK antérieur à 0.6.1 a encodé l'extension hybride en JSON (mettez à niveau — voir Comptes et signature PQC).

Rotation de clé

Faites tourner la clé ML-DSA-87 d'un compte vers une nouvelle clé du même algorithme — par exemple pour migrer une clé héritée dérivée du chain-bridge (shake256(mnemonic)) vers la clé canonique liée à l'adresse (shake256("qorechain:pqc:v1|addr|mnemonic")) :

import { rotatePqcKeyMsgFromMnemonic, derivePqcLegacy } from "@qorechain/sdk";

const { msg, oldKeypair, newKeypair } = rotatePqcKeyMsgFromMnemonic({
account,
mnemonic,
chainId: "qorechain-vladi",
// oldDerivation: "bridge" (legacy), newDerivation: "adapter" (canonical) by default
});
// broadcast `msg` BY the account, cosigned (hybrid) with the OLD key —
// both keys dual-sign the rotation bytes (old proves ownership, new proves control).

derivePqcLegacy(mnemonic) reproduit à elle seule la paire de clés héritée quand vous en avez besoin (par ex. pour continuer à signer jusqu'à ce que la rotation soit effective).

Et ensuite