@qorechain/wallet-adapter 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -24
- package/package.json +1 -1
- package/src/authenticator.js +314 -0
- package/src/framing.js +5 -11
- package/src/index.d.ts +75 -5
- package/src/index.js +57 -11
- package/src/phantom.js +32 -5
- package/src/sign-eth.js +19 -5
- package/src/signbytes.js +173 -0
- package/src/wallet.js +19 -4
package/README.md
CHANGED
|
@@ -24,14 +24,77 @@ Mirrors the chain's own `qorechaind tx pqc cosign`:
|
|
|
24
24
|
|
|
25
25
|
```
|
|
26
26
|
B0 = TxBody{messages, memo, timeoutHeight} // no extension
|
|
27
|
-
|
|
27
|
+
v = resolveSignBytesVersion({ chainId, rest }) // 'v1' | 'v2', per network
|
|
28
|
+
sigP = ML-DSA-87.sign( hybridSignBytes(v, chainId, B0, authInfoBytes) ) // adapter does this
|
|
28
29
|
body = TxBody{ ...B0, extensionOptions:[ PQCHybridSignature{1, sigP} ] }
|
|
29
30
|
sigC = wallet.signDirect( SignDoc{ body, authInfo, chainId, accountNumber } )
|
|
30
31
|
tx = TxRaw{ body, authInfo, [sigC] }
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
The extension type URL is `/qorechain.pqc.v1.PQCHybridSignature` and algorithm
|
|
35
|
+
`1` = ML-DSA-87. The sign-bytes form `v` is chosen per network — see below.
|
|
36
|
+
|
|
37
|
+
## Hybrid sign-bytes: v1 and v2 (chain v3.1.98)
|
|
38
|
+
|
|
39
|
+
The ML-DSA key signs one of two byte forms (B0 = body without the PQC extension,
|
|
40
|
+
A = AuthInfo bytes):
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
v1 (legacy): BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A
|
|
44
|
+
v2: "qorechain-pqc-hybrid-v2" ‖ BE64(len chainId) ‖ chainId ‖ BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
v2 adds a domain tag (a signature the key made in any other context can never be
|
|
48
|
+
valid transaction sign-bytes) and binds the chain-id. **A network accepts exactly
|
|
49
|
+
one form at any height.** The networks that existed before chain v3.1.98
|
|
50
|
+
(`qorechain-vladi` mainnet, `qorechain-diana` testnet) verify v1 until the
|
|
51
|
+
`v3.1.98` upgrade plan is applied on them and v2 from then on; they upgrade at
|
|
52
|
+
different heights. Today the testnet verifies v2 and **mainnet stays on v1 until
|
|
53
|
+
its own upgrade**. Any other chain verifies v2 from its first block.
|
|
54
|
+
|
|
55
|
+
`signBytesVersion` (on `QoreChainSigner`, per `signHybrid` call, and on
|
|
56
|
+
`signHybridEth`) takes:
|
|
57
|
+
|
|
58
|
+
- `'auto'` (default) — a non-legacy chain signs v2 with no network call. On
|
|
59
|
+
`qorechain-vladi` / `qorechain-diana` the adapter asks the network
|
|
60
|
+
`GET {rest}/cosmos/upgrade/v1beta1/applied_plan/v3.1.98` and signs v2 iff the
|
|
61
|
+
returned height is > 0 (compared numerically: mainnet answers `{"height":"0"}`).
|
|
62
|
+
The answer is cached per (rest, chain-id) for 60 s. **Pass `rest` (the LCD URL)**;
|
|
63
|
+
without it, or if the query fails, signing throws instead of guessing.
|
|
64
|
+
- `'v1'` / `'v2'` — used as given, no network call.
|
|
65
|
+
|
|
66
|
+
**Upgrading to 0.2.0.** `rest` is optional in the TypeScript types, so a caller that forgets it compiles cleanly and only fails at runtime on `qorechain-vladi` / `qorechain-diana`. Cover your wiring with a runtime test, not just a type check. In unit tests, pass `signBytesVersion: "v1"` or `"v2"` explicitly (or inject `fetch`): `"auto"` asks the network, so a test that omits it silently depends on a live node.
|
|
67
|
+
|
|
68
|
+
Signed results are the usual `TxRaw` `Uint8Array`, with `.signBytesVersion`
|
|
69
|
+
(`'v1' | 'v2'`) set to the form actually used.
|
|
70
|
+
|
|
71
|
+
### Retry once on a sign-bytes refusal (caller side)
|
|
72
|
+
|
|
73
|
+
The adapter only signs; you broadcast. A network can upgrade while a wallet is
|
|
74
|
+
open, so when the version was `'auto'`, handle a refusal with `pqc` code 21
|
|
75
|
+
("hybrid PQC signature verification failed") by re-resolving once:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import { QoreChainSigner, isHybridSignBytesRejection } from '@qorechain/wallet-adapter';
|
|
79
|
+
|
|
80
|
+
const signer = new QoreChainSigner({ wallet, chainId, address, pubkeySecp256k1,
|
|
81
|
+
accountNumber, pqc, rest: lcdUrl /* signBytesVersion: 'auto' is the default */ });
|
|
82
|
+
|
|
83
|
+
let txBytes = await signer.signHybrid({ messages, fee, sequence });
|
|
84
|
+
try {
|
|
85
|
+
await client.broadcastTx(txBytes); // cosmjs throws BroadcastTxError / returns {code, rawLog}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (!isHybridSignBytesRejection(err)) throw err; // only codespace "pqc" code 21
|
|
88
|
+
await signer.refreshSignBytesVersion(); // bypasses the cache
|
|
89
|
+
txBytes = await signer.signHybrid({ messages, fee, sequence });
|
|
90
|
+
await client.broadcastTx(txBytes); // broadcast ONCE more; surface any error
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If you check a returned result instead of catching, pass it to
|
|
95
|
+
`isHybridSignBytesRejection(result)` the same way (`{ code, rawLog }` works).
|
|
96
|
+
Do not retry when you passed an explicit `'v1'`/`'v2'`, and do not treat code 21
|
|
97
|
+
from another codespace as this case.
|
|
35
98
|
|
|
36
99
|
**Verified end-to-end:** an adapter-built tx (ML-DSA-87 via `@noble/post-quantum`
|
|
37
100
|
+ classical via a cosmjs signer standing in for Keplr) **committed with code 0**
|
|
@@ -59,6 +122,7 @@ const pqc = await derivePqcKeyFromWallet(window.keplr, 'qorechain-diana', accoun
|
|
|
59
122
|
const adapter = new QoreChainSigner({
|
|
60
123
|
wallet: window.keplr, chainId: 'qorechain-diana', address: account.address,
|
|
61
124
|
pubkeySecp256k1: account.pubkey, accountNumber, pqc,
|
|
125
|
+
rest, // LCD URL: lets the adapter pick the sign-bytes form this network verifies
|
|
62
126
|
});
|
|
63
127
|
const txBytes = await adapter.signHybrid({ messages, fee, sequence });
|
|
64
128
|
await fetch(`${rpc}`, { method:'POST', body: JSON.stringify({
|
|
@@ -72,7 +136,7 @@ await fetch(`${rpc}`, { method:'POST', body: JSON.stringify({
|
|
|
72
136
|
| **Keplr** | `experimentalSuggestChain` + `signDirect` | ✅ supported |
|
|
73
137
|
| **Leap / Cosmostation** | same `signDirect` interface | ✅ supported (any wallet exposing `signDirect`) |
|
|
74
138
|
| **MetaMask** | uses QoreChain's **EVM** path (chainId 9800) — structurally PQC-exempt | ✅ works natively, no adapter needed |
|
|
75
|
-
| **Phantom** |
|
|
139
|
+
| **Phantom** | register the Phantom key as an authenticator on a QoreChain account | ✅ authorises spending under a permission set + SpendingRule, and is revocable. The old signature-derived recipe is withdrawn — see "Derive from a seed" |
|
|
76
140
|
|
|
77
141
|
## API
|
|
78
142
|
|
|
@@ -82,13 +146,18 @@ Wallet generation & unified addresses:
|
|
|
82
146
|
|
|
83
147
|
eth-native Cosmos signing (chain ≥ v3.1.83):
|
|
84
148
|
- `signClassicalEth({ key, chainId, accountNumber, sequence, messages, fee, memo?, timeoutHeight? })` → `TxRaw` bytes (classical, e.g. PQC key registration).
|
|
85
|
-
- `signHybridEth({
|
|
149
|
+
- `signHybridEth({ ..., signBytesVersion?, rest? })` → `TxRaw` bytes (eth_secp256k1 + ML-DSA-87 hybrid); throws on a legacy network with neither an explicit version nor `rest`.
|
|
86
150
|
- `ETHSECP256K1_PUBKEY_TYPE` — the eth pubkey type URL.
|
|
87
151
|
|
|
88
152
|
Keplr / any-signDirect adapter + PQC framing:
|
|
89
|
-
- `QoreChainSigner
|
|
153
|
+
- `new QoreChainSigner({ wallet, chainId, address, pubkeySecp256k1, accountNumber, pqc, rest?, signBytesVersion? = 'auto', fetch? })`.
|
|
154
|
+
- `QoreChainSigner#signHybrid({ messages, fee, sequence, memo?, timeoutHeight?, signBytesVersion? })` → `TxRaw` bytes with `.signBytesVersion`.
|
|
155
|
+
- `QoreChainSigner#refreshSignBytesVersion()` → re-resolves, bypassing the cache.
|
|
90
156
|
- `derivePqcKeyFromWallet(wallet, chainId, address)` — deterministic ML-DSA-87 key from a wallet signature.
|
|
91
|
-
- `
|
|
157
|
+
- `hybridSignBytesV1(b0, auth)`, `hybridSignBytesV2(chainId, b0, auth)`, `hybridSignBytes(version, chainId, b0, auth)` — the two sign-bytes forms and a dispatcher (version required; the old implicit `frame()` is removed).
|
|
158
|
+
- `signBytesVersionFor(chainId, v2AppliedHeight)`, `resolveSignBytesVersion({ chainId, rest?, signBytesVersion?, fetch?, ttlMs?, forceRefresh? })`, `clearSignBytesCache()`, `isHybridSignBytesRejection(errOrResult)`.
|
|
159
|
+
- Constants `HYBRID_SIGN_BYTES_V2_DOMAIN`, `SIGN_BYTES_V2_UPGRADE` (`"v3.1.98"`), `LEGACY_SIGN_BYTES_CHAINS`.
|
|
160
|
+
- `encodePqcHybridSignature(algId, sig)` — proto encoder for the extension.
|
|
92
161
|
- `qoreChainInfo({ chainId?, rpc, rest })` — Keplr chain descriptor; `qoreEvmChainParams(...)` / `addQoreEvmToWallet(provider, opts)` — MetaMask (EIP-3085) EVM descriptor.
|
|
93
162
|
|
|
94
163
|
## License
|
|
@@ -122,22 +191,31 @@ under which the chain reads one `x/bank` balance. The account can sign EVM txs
|
|
|
122
191
|
`addressesFrom20(bytes20)` / `qoreAddresses({cosmos|evm|hex})` derive the three
|
|
123
192
|
encodings from a known account (for explorers / backends).
|
|
124
193
|
|
|
125
|
-
### Derive from a seed (
|
|
126
|
-
|
|
127
|
-
`walletFromSeed(seed32)` builds the same unified wallet from any 32 bytes
|
|
128
|
-
seed becomes the secp256k1 key
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
194
|
+
### Derive from a seed (non-mnemonic flows)
|
|
195
|
+
|
|
196
|
+
`walletFromSeed(seed32)` builds the same unified wallet from any 32 bytes. **The
|
|
197
|
+
seed becomes the secp256k1 private key**, so it must come from something secret
|
|
198
|
+
and stay secret: a CSPRNG, or a KDF over material only the user holds.
|
|
199
|
+
|
|
200
|
+
> **Do not derive the seed from a wallet signature.** Versions of this README up
|
|
201
|
+
> to 0.1.7 showed a Phantom "connect → three addresses" recipe built on
|
|
202
|
+
> `shake256(signature)` over a fixed message. That recipe is unsound and has
|
|
203
|
+
> been withdrawn. Signature schemes like ed25519 are deterministic (RFC 8032)
|
|
204
|
+
> and the message was public, so the signature is a *constant* that any website
|
|
205
|
+
> can ask the same wallet to reproduce — and whoever obtains it reconstructs the
|
|
206
|
+
> account's entire key material, classical and ML-DSA-87, with nothing to
|
|
207
|
+
> revoke. Changing the message does not fix it: any message an attacker can also
|
|
208
|
+
> request yields the same key.
|
|
209
|
+
>
|
|
210
|
+
> If you followed that recipe, treat every account derived from it as
|
|
211
|
+
> compromised and move the funds.
|
|
212
|
+
|
|
213
|
+
To let an external wallet (Phantom, MetaMask, …) authorise spending on a
|
|
214
|
+
QoreChain account, register its key as an **authenticator** instead:
|
|
215
|
+
`MsgRegisterAuthenticator`, with an explicit permission set and a `SpendingRule`.
|
|
216
|
+
The external key then signs authorisations for an account it never owned, and it
|
|
217
|
+
can be revoked. See the authenticator execution lanes (`MsgExecuteEVM` /
|
|
218
|
+
`MsgExecuteCosmos`, chain ≥ v3.1.85).
|
|
141
219
|
|
|
142
220
|
## eth-native Cosmos signing (requires chain ≥ v3.1.83)
|
|
143
221
|
|
|
@@ -161,7 +239,8 @@ const regTx = await signClassicalEth({ key, chainId, accountNumber, sequence,
|
|
|
161
239
|
|
|
162
240
|
// 2) thereafter: hybrid eth_secp256k1 + ML-DSA-87 (e.g. a bank MsgSend)
|
|
163
241
|
const sendTx = await signHybridEth({ key, chainId, accountNumber, sequence,
|
|
164
|
-
messages: [{ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: msgSendBytes }], fee
|
|
242
|
+
messages: [{ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: msgSendBytes }], fee,
|
|
243
|
+
rest: lcdUrl /* picks v1/v2 for this network; or signBytesVersion: 'v1' | 'v2' */ });
|
|
165
244
|
```
|
|
166
245
|
|
|
167
246
|
> **Requires QoreChain ≥ v3.1.83** — that release registers the `eth_secp256k1`
|
|
@@ -170,3 +249,52 @@ const sendTx = await signHybridEth({ key, chainId, accountNumber, sequence,
|
|
|
170
249
|
> `@qorechain/chain-bridge` wraps this server-side (`keyType: 'eth_secp256k1'`,
|
|
171
250
|
> auto-registers the PQC key on first send). **Proven live** on QoreChain: register
|
|
172
251
|
> (code 0) + hybrid send (code 0) + an EVM transfer from the same key, one balance.
|
|
252
|
+
|
|
253
|
+
## Authenticator lanes + key rotation (v3.1.85)
|
|
254
|
+
|
|
255
|
+
Let a linked external key (Phantom ed25519 / a secp256k1 key) **spend from the
|
|
256
|
+
one unified PQC-required account** under least-privilege, spend-limited terms —
|
|
257
|
+
via a relayer, with no ML-DSA co-signature from the external key. Owner links the
|
|
258
|
+
key once (`registerAuthenticatorMsg`, hybrid-signed); thereafter the external key
|
|
259
|
+
authorizes actions on three lanes:
|
|
260
|
+
|
|
261
|
+
- **SVM** — `buildPhantomSvmEnvelope` / `buildPhantomTransfer` (post to `sendTransaction`).
|
|
262
|
+
- **EVM** — `buildPhantomExecuteEvm` → `MsgExecuteEVM` (relayer broadcasts).
|
|
263
|
+
- **Native** — `buildPhantomExecuteCosmos` → `MsgExecuteCosmos` (relayer broadcasts).
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
import { buildPhantomExecuteEvm, buildPhantomExecuteCosmos } from "@qorechain/wallet-adapter";
|
|
267
|
+
|
|
268
|
+
// nonce = the account's CURRENT EVM nonce (eth_getTransactionCount(account0x)).
|
|
269
|
+
// The relayer is a DIFFERENT account than the owner, so it does NOT pre-increment it.
|
|
270
|
+
const evmMsg = await buildPhantomExecuteEvm({
|
|
271
|
+
wallet: phantom, relayer: relayerAddr, chainId, account: qor1,
|
|
272
|
+
to: "0x…", value: "100000000000000000" /* wei */, nonce });
|
|
273
|
+
|
|
274
|
+
const sendMsg = await buildPhantomExecuteCosmos({
|
|
275
|
+
wallet: phantom, relayer: relayerAddr, chainId, account: qor1,
|
|
276
|
+
to: "qor1…", amount: "250uqor", nonce: authSeq /* per-authenticator sequence */ });
|
|
277
|
+
// → hand each msg to your relayer; it broadcasts with its own hybrid-PQC signature.
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Errors (codespace `abstractaccount`): `5` spending-limit, `6` session-key expired
|
|
281
|
+
(render "re-link"), `10` permission-denied, `11` replay. Fetch the live scope
|
|
282
|
+
taxonomy over REST: `GET /qorechain/abstractaccount/v1/permission_schema`.
|
|
283
|
+
|
|
284
|
+
**Key rotation** (`MsgRotatePQCKey`) — migrate a legacy chain-bridge key
|
|
285
|
+
(`shake256(mnemonic)`) to the canonical address-bound key of the SAME algorithm:
|
|
286
|
+
|
|
287
|
+
```js
|
|
288
|
+
import { rotatePqcKeyMsgFromMnemonic } from "@qorechain/wallet-adapter";
|
|
289
|
+
const { msg, oldKeypair } = rotatePqcKeyMsgFromMnemonic({ account: qor1, mnemonic, chainId });
|
|
290
|
+
// broadcast `msg` from the account, cosigned (hybrid) with `oldKeypair` (still the
|
|
291
|
+
// registered key until the rotation lands) — e.g. a QoreChainSigner whose pqc=oldKeypair.
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
For a **MetaMask / EVM** key use `registerEthAuthenticatorMsg` (link by 0x address) + `buildMetaMaskExecuteEvm` / `buildMetaMaskExecuteCosmos` — the key signs the digest with `personal_sign` (EIP-191) and the chain verifies by ecrecover, so no raw pubkey is needed. Live-proven: a MetaMask-signed EVM transfer from the unified account committed on QoreChain.
|
|
295
|
+
|
|
296
|
+
> **Requires QoreChain ≥ v3.1.85.** Auth sign-bytes (`evmAuthSignBytes`,
|
|
297
|
+
> `cosmosAuthSignBytes`) are rebuilt byte-for-byte from the chain and guarded by
|
|
298
|
+
> tests. For a **secp256k1** authenticator the chain uses cosmos `VerifySignature`
|
|
299
|
+
> (sha256-based), NOT MetaMask `personal_sign` — sign the digest with a
|
|
300
|
+
> cosmos-style secp256k1 signer, not `personal_sign`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qorechain/wallet-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Drop-in adapter to add QoreChain to any Cosmos wallet (Keplr, Leap, Cosmostation, …) and sign its PQC-required transactions. The wallet signs an ordinary SIGN_MODE_DIRECT SignDoc; the adapter layers a standard FIPS-204 ML-DSA-87 hybrid signature into the tx body, so no wallet code changes are needed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// @qorechain/wallet-adapter — v3.1.85 authenticator lanes (EVM + Native/Cosmos)
|
|
2
|
+
// and same-algorithm PQC key rotation.
|
|
3
|
+
//
|
|
4
|
+
// v3.1.84 introduced the SVM authenticator lane (see phantom.js). v3.1.85 adds
|
|
5
|
+
// two more lanes so a linked external key (Phantom ed25519 / a secp256k1 key)
|
|
6
|
+
// can spend from the ONE unified PQC-required account under least-privilege,
|
|
7
|
+
// spend-limited, revocable terms — via a relayer, WITHOUT the external key ever
|
|
8
|
+
// producing an ML-DSA co-signature:
|
|
9
|
+
//
|
|
10
|
+
// • EVM lane — MsgExecuteEVM: EVM call/transfer from the account's 0x addr.
|
|
11
|
+
// • Native lane — MsgExecuteCosmos: bank send from the account (Cosmos).
|
|
12
|
+
//
|
|
13
|
+
// The relayer submits + pays fees (its own hybrid-PQC signature satisfies the
|
|
14
|
+
// ante on the envelope); the authenticator's signature over the domain-separated,
|
|
15
|
+
// replay-bound sign-bytes IS the authorization. The digests below are rebuilt
|
|
16
|
+
// BYTE-FOR-BYTE from the chain (x/abstractaccount/types/{evm,cosmos}_sign.go) — a
|
|
17
|
+
// mismatch is rejected on-chain (codespace abstractaccount, code 11 replay / 10
|
|
18
|
+
// permission / 5 spending-limit / 6 session-expired).
|
|
19
|
+
//
|
|
20
|
+
// Chain signature check (keeper.VerifyAuthenticatorSignature): for scheme
|
|
21
|
+
// "ed25519" it is ed25519.Verify(pubkey, digest, sig) — so a Phantom
|
|
22
|
+
// `signMessage(digest)` matches directly (the builders below). For "secp256k1"
|
|
23
|
+
// the chain uses cosmos secp256k1 VerifySignature (sha256-based, NOT MetaMask
|
|
24
|
+
// personal_sign), so a MetaMask personal_sign will NOT verify — provide the
|
|
25
|
+
// digest to a cosmos-style secp256k1 signer instead.
|
|
26
|
+
|
|
27
|
+
import { mldsa, shake256 } from '@qorechain/pqc';
|
|
28
|
+
|
|
29
|
+
// ---- byte helpers (match the chain's binary.BigEndian + length-prefix framing) ----
|
|
30
|
+
|
|
31
|
+
const enc = new TextEncoder();
|
|
32
|
+
|
|
33
|
+
function be64(n) {
|
|
34
|
+
const b = new Uint8Array(8);
|
|
35
|
+
let v = BigInt(n);
|
|
36
|
+
for (let i = 7; i >= 0; i--) { b[i] = Number(v & 0xffn); v >>= 8n; }
|
|
37
|
+
return b;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function concat(parts) {
|
|
41
|
+
let len = 0; for (const p of parts) len += p.length;
|
|
42
|
+
const out = new Uint8Array(len); let o = 0;
|
|
43
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// length-prefixed field: BE64(len) ‖ bytes
|
|
48
|
+
function lp(bytes) { return concat([be64(bytes.length), bytes]); }
|
|
49
|
+
|
|
50
|
+
function toBytes(x) {
|
|
51
|
+
if (x instanceof Uint8Array) return x;
|
|
52
|
+
if (typeof x === 'string') return enc.encode(x);
|
|
53
|
+
return new Uint8Array(x);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function sha256(bytes) {
|
|
57
|
+
if (typeof globalThis.crypto?.subtle?.digest === 'function') {
|
|
58
|
+
return new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes));
|
|
59
|
+
}
|
|
60
|
+
const { createHash } = await import('crypto');
|
|
61
|
+
return new Uint8Array(createHash('sha256').update(bytes).digest());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toHexLower(bytes) {
|
|
65
|
+
let s = ''; for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- sign-bytes (the digest an authenticator signs) ----
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* evmAuthSignBytes rebuilds the 32-byte digest the chain re-derives for a
|
|
73
|
+
* MsgExecuteEVM (types.EVMAuthSignBytes):
|
|
74
|
+
* sha256( "qorechain-evm-auth-v1"
|
|
75
|
+
* ‖ LP(chainId) ‖ LP(account) ‖ LP(pubkey)
|
|
76
|
+
* ‖ LP(to) ‖ LP(value) ‖ LP(data) ‖ BE64(nonce) )
|
|
77
|
+
* `to` is the 0x-hex recipient string, `value` is the decimal wei (aqor) string,
|
|
78
|
+
* `data` is the raw calldata (Uint8Array). `pubkey` is the authenticator's raw
|
|
79
|
+
* public key (32 bytes for ed25519). Returns Uint8Array(32) — what the wallet signs.
|
|
80
|
+
*
|
|
81
|
+
* NONCE: the account's CURRENT EVM nonce (eth_getTransactionCount(account0x)).
|
|
82
|
+
* In production the relayer is a DIFFERENT account than the owner, so the relayer
|
|
83
|
+
* envelope does NOT bump the account's nonce — use the current value as-is. (Only
|
|
84
|
+
* if relayer === owner does the envelope pre-increment it, needing current+1.)
|
|
85
|
+
*/
|
|
86
|
+
export async function evmAuthSignBytes({ chainId, account, pubkey, to = '', value = '0', data = new Uint8Array(0), nonce }) {
|
|
87
|
+
const body = concat([
|
|
88
|
+
enc.encode('qorechain-evm-auth-v1'),
|
|
89
|
+
lp(toBytes(chainId)), lp(toBytes(account)), lp(toBytes(pubkey)),
|
|
90
|
+
lp(toBytes(to)), lp(toBytes(value)), lp(toBytes(data)),
|
|
91
|
+
be64(nonce),
|
|
92
|
+
]);
|
|
93
|
+
return sha256(body);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* cosmosAuthSignBytes rebuilds the 32-byte digest the chain re-derives for a
|
|
98
|
+
* MsgExecuteCosmos (types.CosmosAuthSignBytes):
|
|
99
|
+
* sha256( "qorechain-cosmos-auth-v1"
|
|
100
|
+
* ‖ LP(chainId) ‖ LP(account) ‖ LP(pubkey)
|
|
101
|
+
* ‖ LP(to) ‖ LP(amount) ‖ BE64(nonce) )
|
|
102
|
+
* `to` is the bech32 recipient, `amount` is the CANONICAL sdk.Coins string
|
|
103
|
+
* (sorted, e.g. "100uqor"). Returns Uint8Array(32).
|
|
104
|
+
*
|
|
105
|
+
* NONCE: the per-authenticator sequence for (account, pubkey) — a store counter
|
|
106
|
+
* distinct from the account's own sequence, incremented on each successful
|
|
107
|
+
* Native-lane spend. (Query it from the chain / track it client-side.)
|
|
108
|
+
*/
|
|
109
|
+
export async function cosmosAuthSignBytes({ chainId, account, pubkey, to, amount, nonce }) {
|
|
110
|
+
const body = concat([
|
|
111
|
+
enc.encode('qorechain-cosmos-auth-v1'),
|
|
112
|
+
lp(toBytes(chainId)), lp(toBytes(account)), lp(toBytes(pubkey)),
|
|
113
|
+
lp(toBytes(to)), lp(toBytes(amount)),
|
|
114
|
+
be64(nonce),
|
|
115
|
+
]);
|
|
116
|
+
return sha256(body);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* rotationSignBytes returns the domain-separated STRING both the old and the new
|
|
121
|
+
* key sign for a MsgRotatePQCKey (types.RotationSignBytes):
|
|
122
|
+
* "qorechain-pqc-rotate-v1|<chainId>|<algorithmId>|<account>|<oldHex>|<newHex>"
|
|
123
|
+
* oldHex/newHex are lowercase hex of the public keys. Sign `utf8(this string)`.
|
|
124
|
+
*/
|
|
125
|
+
export function rotationSignBytes(chainId, algorithmId, account, oldPub, newPub) {
|
|
126
|
+
return `qorechain-pqc-rotate-v1|${chainId}|${algorithmId}|${account}|${toHexLower(oldPub)}|${toHexLower(newPub)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- message composers (shapes for cosmjs registry / *.fromPartial) ----
|
|
130
|
+
|
|
131
|
+
/** parse a single-coin amount string like "100uqor" → [{denom,amount}]. */
|
|
132
|
+
function parseCoins(amount) {
|
|
133
|
+
const m = /^([0-9]+)([a-zA-Z][a-zA-Z0-9/:._-]*)$/.exec(String(amount).trim());
|
|
134
|
+
if (!m) throw new Error(`invalid amount "${amount}" (expected e.g. "100uqor")`);
|
|
135
|
+
return [{ denom: m[2], amount: m[1] }];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** MsgExecuteEVM — the relayer broadcasts this (it is the message `relayer`/fee payer). */
|
|
139
|
+
export function executeEvmMsg({ relayer, account, scheme, pubkey, signature, to = '', value = '0', data = new Uint8Array(0), gasLimit, nonce }) {
|
|
140
|
+
return {
|
|
141
|
+
typeUrl: '/qorechain.abstractaccount.v1.MsgExecuteEVM',
|
|
142
|
+
value: {
|
|
143
|
+
relayer, account, scheme,
|
|
144
|
+
pubkey: toBytes(pubkey), signature: toBytes(signature),
|
|
145
|
+
to, value, data: toBytes(data),
|
|
146
|
+
gasLimit: BigInt(gasLimit), nonce: BigInt(nonce),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** MsgExecuteCosmos — the relayer broadcasts this. `amount` is "100uqor"-style. */
|
|
152
|
+
export function executeCosmosMsg({ relayer, account, scheme, pubkey, signature, to, amount, nonce }) {
|
|
153
|
+
return {
|
|
154
|
+
typeUrl: '/qorechain.abstractaccount.v1.MsgExecuteCosmos',
|
|
155
|
+
value: {
|
|
156
|
+
relayer, account, scheme,
|
|
157
|
+
pubkey: toBytes(pubkey), signature: toBytes(signature),
|
|
158
|
+
to, amount: parseCoins(amount), nonce: BigInt(nonce),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** MsgRevokeAuthenticator — owner-signed; instantly disables a linked key. */
|
|
164
|
+
export function revokeAuthenticatorMsg({ owner, account = owner, scheme, pubkey }) {
|
|
165
|
+
return {
|
|
166
|
+
typeUrl: '/qorechain.abstractaccount.v1.MsgRevokeAuthenticator',
|
|
167
|
+
value: { owner, accountAddress: account, scheme, pubkey: toBytes(pubkey) },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** MsgRotatePQCKey — sender-signed (hybrid, with the OLD key); dual-signed payload. */
|
|
172
|
+
export function rotatePqcKeyMsg({ sender, oldPublicKey, newPublicKey, oldSignature, newSignature }) {
|
|
173
|
+
return {
|
|
174
|
+
typeUrl: '/qorechain.pqc.v1.MsgRotatePQCKey',
|
|
175
|
+
value: {
|
|
176
|
+
sender,
|
|
177
|
+
oldPublicKey: toBytes(oldPublicKey), newPublicKey: toBytes(newPublicKey),
|
|
178
|
+
oldSignature: toBytes(oldSignature), newSignature: toBytes(newSignature),
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- Phantom (ed25519) envelope builders for the EVM + Native lanes ----
|
|
184
|
+
|
|
185
|
+
function walletPubkey(wallet) {
|
|
186
|
+
return wallet.publicKey?.toBytes ? wallet.publicKey.toBytes() : new Uint8Array(wallet.publicKey);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* buildPhantomExecuteEvm signs the EVM auth digest with a Phantom-style ed25519
|
|
191
|
+
* wallet and returns a MsgExecuteEVM ready for the relayer to broadcast. The
|
|
192
|
+
* relayer address is the fee payer (a DIFFERENT account than `account`).
|
|
193
|
+
*/
|
|
194
|
+
export async function buildPhantomExecuteEvm({ wallet, relayer, chainId, account, to = '', value = '0', data = new Uint8Array(0), gasLimit = 100000, nonce }) {
|
|
195
|
+
const pubkey = walletPubkey(wallet);
|
|
196
|
+
const digest = await evmAuthSignBytes({ chainId, account, pubkey, to, value, data, nonce });
|
|
197
|
+
const { signature } = await wallet.signMessage(digest);
|
|
198
|
+
return executeEvmMsg({ relayer, account, scheme: 'ed25519', pubkey, signature, to, value, data, gasLimit, nonce });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* buildPhantomExecuteCosmos signs the Native auth digest with a Phantom-style
|
|
203
|
+
* ed25519 wallet and returns a MsgExecuteCosmos ready for the relayer. `amount`
|
|
204
|
+
* is a single-coin string like "100uqor".
|
|
205
|
+
*/
|
|
206
|
+
export async function buildPhantomExecuteCosmos({ wallet, relayer, chainId, account, to, amount, nonce }) {
|
|
207
|
+
const pubkey = walletPubkey(wallet);
|
|
208
|
+
const digest = await cosmosAuthSignBytes({ chainId, account, pubkey, to, amount, nonce });
|
|
209
|
+
const { signature } = await wallet.signMessage(digest);
|
|
210
|
+
return executeCosmosMsg({ relayer, account, scheme: 'ed25519', pubkey, signature, to, amount, nonce });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- MetaMask (EIP-191 personal_sign / secp256k1) envelope builders ----
|
|
214
|
+
//
|
|
215
|
+
// A browser EVM wallet exposes only its 20-byte address + `personal_sign`, never
|
|
216
|
+
// the raw public key, so the account is linked by its ETH ADDRESS (scheme
|
|
217
|
+
// "secp256k1", 20-byte pubkey). The chain verifies with EIP-191 + ecrecover
|
|
218
|
+
// (v3.1.85). The digest the wallet signs is the SAME one the Phantom/cosmos
|
|
219
|
+
// paths use — only the signing scheme differs.
|
|
220
|
+
|
|
221
|
+
function hexToBytes(hex) {
|
|
222
|
+
hex = String(hex).replace(/^0x/, '');
|
|
223
|
+
const o = new Uint8Array(hex.length / 2);
|
|
224
|
+
for (let i = 0; i < o.length; i++) o[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
225
|
+
return o;
|
|
226
|
+
}
|
|
227
|
+
function bytesToHex0x(b) { let s = '0x'; for (const x of b) s += x.toString(16).padStart(2, '0'); return s; }
|
|
228
|
+
|
|
229
|
+
// personal_sign over the 32-byte digest via an EIP-1193 provider (e.g. MetaMask).
|
|
230
|
+
async function ethPersonalSign(provider, address, digest) {
|
|
231
|
+
const sigHex = await provider.request({ method: 'personal_sign', params: [bytesToHex0x(digest), address] });
|
|
232
|
+
return hexToBytes(sigHex); // 65 bytes r‖s‖v (v = 27/28)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* registerEthAuthenticatorMsg builds the owner-signed MsgRegisterAuthenticator
|
|
237
|
+
* that links a MetaMask / EVM key (by its 0x address) to the owner's account.
|
|
238
|
+
* `ethAddress` is the 0x-hex 20-byte address.
|
|
239
|
+
*/
|
|
240
|
+
export function registerEthAuthenticatorMsg({ owner, account = owner, ethAddress, permissions = ['evm'], expiryUnix, label = 'metamask' }) {
|
|
241
|
+
return {
|
|
242
|
+
typeUrl: '/qorechain.abstractaccount.v1.MsgRegisterAuthenticator',
|
|
243
|
+
value: {
|
|
244
|
+
owner, accountAddress: account, scheme: 'secp256k1',
|
|
245
|
+
pubkey: hexToBytes(ethAddress), permissions, expiryUnix: BigInt(expiryUnix), label,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** buildMetaMaskExecuteEvm: MetaMask (EIP-191) → MsgExecuteEVM ready for the relayer. */
|
|
251
|
+
export async function buildMetaMaskExecuteEvm({ provider, address, relayer, chainId, account, to = '', value = '0', data = new Uint8Array(0), gasLimit = 100000, nonce }) {
|
|
252
|
+
const pubkey = hexToBytes(address); // 20-byte eth address = the authenticator pubkey
|
|
253
|
+
const digest = await evmAuthSignBytes({ chainId, account, pubkey, to, value, data, nonce });
|
|
254
|
+
const signature = await ethPersonalSign(provider, address, digest);
|
|
255
|
+
return executeEvmMsg({ relayer, account, scheme: 'secp256k1', pubkey, signature, to, value, data, gasLimit, nonce });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** buildMetaMaskExecuteCosmos: MetaMask (EIP-191) → MsgExecuteCosmos ready for the relayer. */
|
|
259
|
+
export async function buildMetaMaskExecuteCosmos({ provider, address, relayer, chainId, account, to, amount, nonce }) {
|
|
260
|
+
const pubkey = hexToBytes(address);
|
|
261
|
+
const digest = await cosmosAuthSignBytes({ chainId, account, pubkey, to, amount, nonce });
|
|
262
|
+
const signature = await ethPersonalSign(provider, address, digest);
|
|
263
|
+
return executeCosmosMsg({ relayer, account, scheme: 'secp256k1', pubkey, signature, to, amount, nonce });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- key rotation (legacy → canonical migration) ----
|
|
267
|
+
|
|
268
|
+
const CANONICAL = 'adapter'; // shake256("qorechain:pqc:v1|addr|mnemonic") (SDK/wallet-adapter)
|
|
269
|
+
const LEGACY = 'bridge'; // shake256(mnemonic) (chain-bridge/faucet-api)
|
|
270
|
+
|
|
271
|
+
function derivePqcByScheme(scheme, account, mnemonic) {
|
|
272
|
+
if (scheme === LEGACY || scheme === 'mnemonic-only') {
|
|
273
|
+
return mldsa.keygen(shake256(enc.encode(mnemonic), 32));
|
|
274
|
+
}
|
|
275
|
+
if (scheme === CANONICAL || scheme === '' || scheme === undefined) {
|
|
276
|
+
return mldsa.keygen(shake256(enc.encode(`qorechain:pqc:v1|${account}|${mnemonic}`), 32));
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`unknown derivation "${scheme}" (use adapter|bridge)`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* rotatePqcKeyMsgFromMnemonic builds a MsgRotatePQCKey that rotates an account's
|
|
283
|
+
* ML-DSA-87 key (SAME algorithm) from one derivation to another — the canonical
|
|
284
|
+
* use is migrating a LEGACY chain-bridge key (`shake256(mnemonic)`) to the
|
|
285
|
+
* canonical address-bound key (`shake256("qorechain:pqc:v1|addr|mnemonic")`), so
|
|
286
|
+
* a wallet whose key was registered by a backend can move to the standard
|
|
287
|
+
* derivation. Both keys dual-sign the domain-separated rotation bytes.
|
|
288
|
+
*
|
|
289
|
+
* The returned message must be broadcast BY the account, cosigned (hybrid) with
|
|
290
|
+
* the OLD key (it is still the registered key until the rotation lands) — i.e.
|
|
291
|
+
* sign the envelope with a QoreChainSigner whose `pqc` is the OLD keypair.
|
|
292
|
+
*
|
|
293
|
+
* @returns {{ msg: object, oldKeypair: {publicKey,secretKey}, newKeypair: {publicKey,secretKey} }}
|
|
294
|
+
*/
|
|
295
|
+
export function rotatePqcKeyMsgFromMnemonic({ account, mnemonic, chainId, algorithmId = 1, oldDerivation = LEGACY, newDerivation = CANONICAL }) {
|
|
296
|
+
const oldKp = derivePqcByScheme(oldDerivation, account, mnemonic);
|
|
297
|
+
const newKp = derivePqcByScheme(newDerivation, account, mnemonic);
|
|
298
|
+
if (toHexLower(oldKp.publicKey) === toHexLower(newKp.publicKey)) {
|
|
299
|
+
throw new Error('old and new derivations produce the same key — rotation would be a no-op');
|
|
300
|
+
}
|
|
301
|
+
const sb = enc.encode(rotationSignBytes(chainId, algorithmId, account, oldKp.publicKey, newKp.publicKey));
|
|
302
|
+
const msg = rotatePqcKeyMsg({
|
|
303
|
+
sender: account,
|
|
304
|
+
oldPublicKey: oldKp.publicKey, newPublicKey: newKp.publicKey,
|
|
305
|
+
oldSignature: mldsa.sign(oldKp.secretKey, sb),
|
|
306
|
+
newSignature: mldsa.sign(newKp.secretKey, sb),
|
|
307
|
+
});
|
|
308
|
+
return { msg, oldKeypair: oldKp, newKeypair: newKp };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** derivePqcLegacy exposes the LEGACY (chain-bridge) derivation for a mnemonic. */
|
|
312
|
+
export function derivePqcLegacy(mnemonic) {
|
|
313
|
+
return mldsa.keygen(shake256(enc.encode(mnemonic), 32));
|
|
314
|
+
}
|
package/src/framing.js
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
// Pure, dependency-free QoreChain PQC tx-extension
|
|
1
|
+
// Pure, dependency-free QoreChain PQC tx-extension encoding (the chain-matching bits).
|
|
2
|
+
//
|
|
3
|
+
// The bytes the ML-DSA key signs are built in ./signbytes.js, which carries both
|
|
4
|
+
// forms (v1 legacy / v2) and the per-network resolver. There is deliberately no
|
|
5
|
+
// helper here that picks a form implicitly: every caller passes the version.
|
|
2
6
|
export const HYBRID_SIG_TYPE_URL = '/qorechain.pqc.v1.PQCHybridSignature';
|
|
3
7
|
export const ALGORITHM_ML_DSA_87 = 1; // chain AlgorithmDilithium5 == FIPS-204 ML-DSA-87
|
|
4
8
|
|
|
5
|
-
export function frame(b0, auth) {
|
|
6
|
-
const out = new Uint8Array(4 + b0.length + 4 + auth.length);
|
|
7
|
-
const dv = new DataView(out.buffer);
|
|
8
|
-
dv.setUint32(0, b0.length, false);
|
|
9
|
-
out.set(b0, 4);
|
|
10
|
-
dv.setUint32(4 + b0.length, auth.length, false);
|
|
11
|
-
out.set(auth, 8 + b0.length);
|
|
12
|
-
return out;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
9
|
export function encodePqcHybridSignature(algorithmId, sig) {
|
|
16
10
|
const varint = (n) => { const b = []; while (n > 0x7f) { b.push((n & 0x7f) | 0x80); n >>>= 7; } b.push(n); return b; };
|
|
17
11
|
return Uint8Array.from([0x08, ...varint(algorithmId), 0x12, ...varint(sig.length), ...sig]);
|
package/src/index.d.ts
CHANGED
|
@@ -36,8 +36,11 @@ export interface EthSignArgs {
|
|
|
36
36
|
}
|
|
37
37
|
/** Classical-only eth_secp256k1 Cosmos tx (e.g. the bootstrap MsgRegisterPQCKeyV2). */
|
|
38
38
|
export function signClassicalEth(args: EthSignArgs): Promise<Uint8Array>;
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Hybrid eth_secp256k1 + ML-DSA-87 Cosmos tx (key.pqc required). Throws on a
|
|
41
|
+
* legacy network when neither an explicit signBytesVersion nor `rest` is given.
|
|
42
|
+
*/
|
|
43
|
+
export function signHybridEth(args: EthSignArgs & { signBytesVersion?: SignBytesVersionOption; rest?: string; fetch?: typeof globalThis.fetch }): Promise<SignedTxBytes>;
|
|
41
44
|
|
|
42
45
|
// --- EVM network descriptors (EIP-3085 / MetaMask) ---
|
|
43
46
|
export function qoreEvmChainParams(opts?: { evmChainId?: number; rpcUrl?: string; wsUrl?: string; explorerUrl?: string; testnet?: boolean }): any;
|
|
@@ -53,11 +56,78 @@ export function authSignBytes(p: { programId: string; accounts: SvmAccountMeta[]
|
|
|
53
56
|
export function buildPhantomSvmEnvelope(p: { wallet: any; programId?: string; accounts: SvmAccountMeta[]; data: Uint8Array; recentBlockhashHex: string }): Promise<any>;
|
|
54
57
|
export function buildPhantomTransfer(p: { wallet: any; fromSvmAddr: string; toSvmAddr: string; lamports: number | bigint; recentBlockhashHex: string }): Promise<any>;
|
|
55
58
|
export function registerAuthenticatorMsg(p: { owner: string; phantomPubkey: Uint8Array; permissions?: string[]; expiryUnix: number | bigint; label?: string }): { typeUrl: string; value: any };
|
|
56
|
-
|
|
59
|
+
// --- Per-network hybrid PQC sign-bytes (v1 legacy / v2, chain v3.1.98) ---
|
|
60
|
+
export type SignBytesVersion = 'v1' | 'v2';
|
|
61
|
+
export type SignBytesVersionOption = SignBytesVersion | 'auto';
|
|
62
|
+
export const HYBRID_SIGN_BYTES_V2_DOMAIN: 'qorechain-pqc-hybrid-v2';
|
|
63
|
+
export const SIGN_BYTES_V2_UPGRADE: 'v3.1.98';
|
|
64
|
+
export const LEGACY_SIGN_BYTES_CHAINS: readonly string[];
|
|
65
|
+
/** v1: BE32(len b0) ‖ b0 ‖ BE32(len authInfo) ‖ authInfo. */
|
|
66
|
+
export function hybridSignBytesV1(b0: Uint8Array, authInfo: Uint8Array): Uint8Array;
|
|
67
|
+
/** v2: domain ‖ BE64(len chainId) ‖ chainId ‖ BE32(len b0) ‖ b0 ‖ BE32(len authInfo) ‖ authInfo. */
|
|
68
|
+
export function hybridSignBytesV2(chainId: string, b0: Uint8Array, authInfo: Uint8Array): Uint8Array;
|
|
69
|
+
/** Version-dispatching builder; `version` is required. */
|
|
70
|
+
export function hybridSignBytes(version: SignBytesVersion, chainId: string, b0: Uint8Array, authInfo: Uint8Array): Uint8Array;
|
|
71
|
+
/** Mirror of the chain's SignBytesVersionFor; the height is compared numerically ("0" → not applied). */
|
|
72
|
+
export function signBytesVersionFor(chainId: string, v2AppliedHeight: string | number | bigint | null | undefined): SignBytesVersion;
|
|
73
|
+
export function resolveSignBytesVersion(opts: {
|
|
74
|
+
chainId: string;
|
|
75
|
+
rest?: string;
|
|
76
|
+
signBytesVersion?: SignBytesVersionOption;
|
|
77
|
+
fetch?: typeof globalThis.fetch;
|
|
78
|
+
ttlMs?: number;
|
|
79
|
+
forceRefresh?: boolean;
|
|
80
|
+
}): Promise<SignBytesVersion>;
|
|
81
|
+
export function clearSignBytesCache(): void;
|
|
82
|
+
/** True iff the error/result is the chain refusing the hybrid PQC signature (codespace "pqc", code 21). */
|
|
83
|
+
export function isHybridSignBytesRejection(errOrResult: unknown): boolean;
|
|
84
|
+
/** TxRaw bytes plus the sign-bytes form that was used. */
|
|
85
|
+
export type SignedTxBytes = Uint8Array & { signBytesVersion: SignBytesVersion };
|
|
57
86
|
export function encodePqcHybridSignature(algorithmId: number, sig: Uint8Array): Uint8Array;
|
|
58
87
|
export function derivePqcKeyFromWallet(wallet: any, chainId: string, address: string, domain?: string): Promise<{ publicKey: Uint8Array; secretKey: Uint8Array }>;
|
|
59
88
|
export function qoreChainInfo(opts?: { chainId?: string; rpc?: string; rest?: string }): any;
|
|
60
89
|
export class QoreChainSigner {
|
|
61
|
-
constructor(opts: {
|
|
62
|
-
|
|
90
|
+
constructor(opts: {
|
|
91
|
+
wallet: any; chainId: string; address: string; pubkeySecp256k1: Uint8Array; accountNumber: number | bigint;
|
|
92
|
+
pqc: { publicKey: Uint8Array; secretKey: Uint8Array };
|
|
93
|
+
/** LCD URL; required to auto-resolve the sign-bytes form on qorechain-vladi / qorechain-diana. */
|
|
94
|
+
rest?: string;
|
|
95
|
+
/** Default 'auto'. */
|
|
96
|
+
signBytesVersion?: SignBytesVersionOption;
|
|
97
|
+
fetch?: typeof globalThis.fetch;
|
|
98
|
+
});
|
|
99
|
+
signHybrid(opts: { messages: any[]; fee: any; memo?: string; sequence: number | bigint; timeoutHeight?: bigint; signBytesVersion?: SignBytesVersionOption }): Promise<SignedTxBytes>;
|
|
100
|
+
/** Re-resolve bypassing the cache; returns the new form. */
|
|
101
|
+
refreshSignBytesVersion(): Promise<SignBytesVersion>;
|
|
63
102
|
}
|
|
103
|
+
|
|
104
|
+
// --- v3.1.85 authenticator lanes (EVM + Native/Cosmos) + PQC key rotation (requires chain >= v3.1.85) ---
|
|
105
|
+
export interface PqcKeypairFull { publicKey: Uint8Array; secretKey: Uint8Array; }
|
|
106
|
+
/** 32-byte digest an authenticator signs to authorize a MsgExecuteEVM. */
|
|
107
|
+
export function evmAuthSignBytes(p: { chainId: string; account: string; pubkey: Uint8Array; to?: string; value?: string; data?: Uint8Array; nonce: number | bigint }): Promise<Uint8Array>;
|
|
108
|
+
/** 32-byte digest an authenticator signs to authorize a MsgExecuteCosmos. */
|
|
109
|
+
export function cosmosAuthSignBytes(p: { chainId: string; account: string; pubkey: Uint8Array; to: string; amount: string; nonce: number | bigint }): Promise<Uint8Array>;
|
|
110
|
+
/** Domain-separated STRING both keys sign for a MsgRotatePQCKey (sign utf8 of it). */
|
|
111
|
+
export function rotationSignBytes(chainId: string, algorithmId: number, account: string, oldPub: Uint8Array, newPub: Uint8Array): string;
|
|
112
|
+
/** MsgExecuteEVM (relayer broadcasts + pays fees). */
|
|
113
|
+
export function executeEvmMsg(p: { relayer: string; account: string; scheme: string; pubkey: Uint8Array; signature: Uint8Array; to?: string; value?: string; data?: Uint8Array; gasLimit: number | bigint; nonce: number | bigint }): { typeUrl: string; value: any };
|
|
114
|
+
/** MsgExecuteCosmos (relayer broadcasts). `amount` is a single-coin string e.g. "100uqor". */
|
|
115
|
+
export function executeCosmosMsg(p: { relayer: string; account: string; scheme: string; pubkey: Uint8Array; signature: Uint8Array; to: string; amount: string; nonce: number | bigint }): { typeUrl: string; value: any };
|
|
116
|
+
/** MsgRevokeAuthenticator (owner-signed) — instantly disables a linked key. */
|
|
117
|
+
export function revokeAuthenticatorMsg(p: { owner: string; account?: string; scheme: string; pubkey: Uint8Array }): { typeUrl: string; value: any };
|
|
118
|
+
/** MsgRotatePQCKey (sender-signed hybrid with the OLD key). */
|
|
119
|
+
export function rotatePqcKeyMsg(p: { sender: string; oldPublicKey: Uint8Array; newPublicKey: Uint8Array; oldSignature: Uint8Array; newSignature: Uint8Array }): { typeUrl: string; value: any };
|
|
120
|
+
/** Phantom (ed25519) → MsgExecuteEVM ready for the relayer. */
|
|
121
|
+
export function buildPhantomExecuteEvm(p: { wallet: any; relayer: string; chainId: string; account: string; to?: string; value?: string; data?: Uint8Array; gasLimit?: number | bigint; nonce: number | bigint }): Promise<{ typeUrl: string; value: any }>;
|
|
122
|
+
/** Phantom (ed25519) → MsgExecuteCosmos ready for the relayer. */
|
|
123
|
+
export function buildPhantomExecuteCosmos(p: { wallet: any; relayer: string; chainId: string; account: string; to: string; amount: string; nonce: number | bigint }): Promise<{ typeUrl: string; value: any }>;
|
|
124
|
+
/** Build a MsgRotatePQCKey to migrate a key between derivations (default legacy→canonical). Broadcast cosigned with the OLD keypair. */
|
|
125
|
+
export function rotatePqcKeyMsgFromMnemonic(p: { account: string; mnemonic: string; chainId: string; algorithmId?: number; oldDerivation?: 'adapter' | 'bridge'; newDerivation?: 'adapter' | 'bridge' }): { msg: { typeUrl: string; value: any }; oldKeypair: PqcKeypairFull; newKeypair: PqcKeypairFull };
|
|
126
|
+
/** The LEGACY (chain-bridge) ML-DSA-87 derivation `shake256(mnemonic)`. */
|
|
127
|
+
export function derivePqcLegacy(mnemonic: string): PqcKeypairFull;
|
|
128
|
+
/** Link a MetaMask / EVM key (by 0x address) as a scoped authenticator (owner-signed). */
|
|
129
|
+
export function registerEthAuthenticatorMsg(p: { owner: string; account?: string; ethAddress: string; permissions?: string[]; expiryUnix: number | bigint; label?: string }): { typeUrl: string; value: any };
|
|
130
|
+
/** MetaMask (EIP-191 personal_sign) → MsgExecuteEVM ready for the relayer. `provider` is EIP-1193. */
|
|
131
|
+
export function buildMetaMaskExecuteEvm(p: { provider: any; address: string; relayer: string; chainId: string; account: string; to?: string; value?: string; data?: Uint8Array; gasLimit?: number | bigint; nonce: number | bigint }): Promise<{ typeUrl: string; value: any }>;
|
|
132
|
+
/** MetaMask (EIP-191 personal_sign) → MsgExecuteCosmos ready for the relayer. */
|
|
133
|
+
export function buildMetaMaskExecuteCosmos(p: { provider: any; address: string; relayer: string; chainId: string; account: string; to: string; amount: string; nonce: number | bigint }): Promise<{ typeUrl: string; value: any }>;
|
package/src/index.js
CHANGED
|
@@ -14,16 +14,27 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Protocol (mirrors the chain's `tx pqc cosign`):
|
|
16
16
|
// B0 = TxBody{messages, memo, timeoutHeight} (no extension)
|
|
17
|
-
//
|
|
17
|
+
// v = resolveSignBytesVersion({chainId, rest}) // 'v1' | 'v2', per network
|
|
18
|
+
// sigP = ML-DSA-87.sign( hybridSignBytes(v, chainId, B0, authInfoBytes) )
|
|
18
19
|
// body = TxBody{...B0, extensionOptions:[PQCHybridSignature{1, sigP}]}
|
|
19
20
|
// sigC = wallet.signDirect( SignDoc{body, authInfo, chainId, accountNumber} )
|
|
20
21
|
// tx = TxRaw{ body, authInfo, [sigC] }
|
|
21
22
|
//
|
|
22
|
-
//
|
|
23
|
+
// v1 = BE32(len b0) ‖ b0 ‖ BE32(len auth) ‖ auth
|
|
24
|
+
// v2 = "qorechain-pqc-hybrid-v2" ‖ BE64(len chainId) ‖ chainId ‖ v1-layout
|
|
25
|
+
// A network accepts exactly one form at a time; see ./signbytes.js.
|
|
23
26
|
|
|
24
27
|
import { mldsa, shake256 } from '@qorechain/pqc';
|
|
25
|
-
import {
|
|
26
|
-
|
|
28
|
+
import { encodePqcHybridSignature, HYBRID_SIG_TYPE_URL, ALGORITHM_ML_DSA_87 } from './framing.js';
|
|
29
|
+
import { hybridSignBytes, resolveSignBytesVersion } from './signbytes.js';
|
|
30
|
+
export { encodePqcHybridSignature, HYBRID_SIG_TYPE_URL, ALGORITHM_ML_DSA_87 };
|
|
31
|
+
// Per-network hybrid sign-bytes (v1 legacy / v2) + resolver + rejection detector.
|
|
32
|
+
export {
|
|
33
|
+
HYBRID_SIGN_BYTES_V2_DOMAIN, SIGN_BYTES_V2_UPGRADE, LEGACY_SIGN_BYTES_CHAINS,
|
|
34
|
+
hybridSignBytesV1, hybridSignBytesV2, hybridSignBytes,
|
|
35
|
+
signBytesVersionFor, resolveSignBytesVersion, clearSignBytesCache,
|
|
36
|
+
isHybridSignBytesRejection,
|
|
37
|
+
} from './signbytes.js';
|
|
27
38
|
// Phantom / any-ed25519-wallet support: drive the one unified account from Phantom.
|
|
28
39
|
export {
|
|
29
40
|
base58Encode, base58Decode, SYSTEM_PROGRAM_ID, systemTransferData,
|
|
@@ -37,6 +48,14 @@ export {
|
|
|
37
48
|
export {
|
|
38
49
|
signClassicalEth, signHybridEth, ETHSECP256K1_PUBKEY_TYPE,
|
|
39
50
|
} from './sign-eth.js';
|
|
51
|
+
// v3.1.85 authenticator lanes (EVM + Native/Cosmos) + PQC key rotation.
|
|
52
|
+
export {
|
|
53
|
+
evmAuthSignBytes, cosmosAuthSignBytes, rotationSignBytes,
|
|
54
|
+
executeEvmMsg, executeCosmosMsg, revokeAuthenticatorMsg, rotatePqcKeyMsg,
|
|
55
|
+
buildPhantomExecuteEvm, buildPhantomExecuteCosmos,
|
|
56
|
+
registerEthAuthenticatorMsg, buildMetaMaskExecuteEvm, buildMetaMaskExecuteCosmos,
|
|
57
|
+
rotatePqcKeyMsgFromMnemonic, derivePqcLegacy,
|
|
58
|
+
} from './authenticator.js';
|
|
40
59
|
import { TxBody, AuthInfo, TxRaw, SignerInfo, ModeInfo, Fee } from 'cosmjs-types/cosmos/tx/v1beta1/tx.js';
|
|
41
60
|
import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing.js';
|
|
42
61
|
import { SignDoc } from 'cosmjs-types/cosmos/tx/v1beta1/tx.js';
|
|
@@ -56,12 +75,37 @@ export async function derivePqcKeyFromWallet(wallet, chainId, address, domain =
|
|
|
56
75
|
export class QoreChainSigner {
|
|
57
76
|
// wallet: a Keplr-like object exposing signDirect(chainId, signer, signDoc) and
|
|
58
77
|
// (optionally) signArbitrary(...). pqc: { publicKey, secretKey } ML-DSA-87.
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
// rest: the network's LCD URL; needed to auto-resolve the sign-bytes form on
|
|
79
|
+
// qorechain-vladi / qorechain-diana.
|
|
80
|
+
// signBytesVersion: 'auto' (default) | 'v1' | 'v2'.
|
|
81
|
+
// fetch: optional fetch implementation for the resolver (defaults to globalThis.fetch).
|
|
82
|
+
constructor({ wallet, chainId, address, pubkeySecp256k1, accountNumber, pqc, rest, signBytesVersion = 'auto', fetch }) {
|
|
83
|
+
Object.assign(this, { wallet, chainId, address, pubkeySecp256k1, accountNumber, pqc, rest, signBytesVersion });
|
|
84
|
+
if (fetch) this.fetch = fetch;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_resolve(signBytesVersion, forceRefresh = false) {
|
|
88
|
+
return resolveSignBytesVersion({
|
|
89
|
+
chainId: this.chainId, rest: this.rest,
|
|
90
|
+
signBytesVersion: signBytesVersion ?? this.signBytesVersion ?? 'auto',
|
|
91
|
+
forceRefresh,
|
|
92
|
+
...(this.fetch ? { fetch: this.fetch } : {}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Re-ask the network (bypassing the cache) which form it verifies. Call this
|
|
97
|
+
// after a broadcast is refused with isHybridSignBytesRejection(err), then sign
|
|
98
|
+
// again and broadcast once more. With an explicit version it returns that version.
|
|
99
|
+
async refreshSignBytesVersion() {
|
|
100
|
+
return this._resolve(undefined, true);
|
|
61
101
|
}
|
|
62
102
|
|
|
63
|
-
// Build + hybrid-sign + return TxRaw bytes ready to broadcast.
|
|
64
|
-
|
|
103
|
+
// Build + hybrid-sign + return TxRaw bytes ready to broadcast. The returned
|
|
104
|
+
// Uint8Array also carries `.signBytesVersion` ('v1' | 'v2'), the form used.
|
|
105
|
+
// `signBytesVersion` overrides the signer's setting for this call.
|
|
106
|
+
async signHybrid({ messages, fee, memo = '', sequence, timeoutHeight = 0n, signBytesVersion }) {
|
|
107
|
+
const version = await this._resolve(signBytesVersion);
|
|
108
|
+
|
|
65
109
|
// 1. AuthInfo: single DIRECT signer (secp256k1) + fee.
|
|
66
110
|
const pubAny = {
|
|
67
111
|
typeUrl: '/cosmos.crypto.secp256k1.PubKey',
|
|
@@ -80,8 +124,8 @@ export class QoreChainSigner {
|
|
|
80
124
|
// 2. B0 = body without the PQC extension.
|
|
81
125
|
const b0 = TxBody.encode(TxBody.fromPartial({ messages, memo, timeoutHeight })).finish();
|
|
82
126
|
|
|
83
|
-
// 3. ML-DSA-87 sign the
|
|
84
|
-
const pqcSig = mldsa.sign(this.pqc.secretKey,
|
|
127
|
+
// 3. ML-DSA-87 sign the hybrid sign-bytes in the form this network verifies.
|
|
128
|
+
const pqcSig = mldsa.sign(this.pqc.secretKey, hybridSignBytes(version, this.chainId, b0, authInfoBytes));
|
|
85
129
|
|
|
86
130
|
// 4. body WITH the PQC hybrid extension.
|
|
87
131
|
const bodyWithExt = TxBody.encode(TxBody.fromPartial({
|
|
@@ -100,9 +144,11 @@ export class QoreChainSigner {
|
|
|
100
144
|
? Uint8Array.from(Buffer.from(signature.signature, 'base64')) : signature.signature;
|
|
101
145
|
|
|
102
146
|
// 6. Assemble TxRaw.
|
|
103
|
-
|
|
147
|
+
const txRaw = TxRaw.encode(TxRaw.fromPartial({
|
|
104
148
|
bodyBytes: bodyWithExt, authInfoBytes, signatures: [classicalSig],
|
|
105
149
|
})).finish();
|
|
150
|
+
txRaw.signBytesVersion = version;
|
|
151
|
+
return txRaw;
|
|
106
152
|
}
|
|
107
153
|
}
|
|
108
154
|
|
package/src/phantom.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
// blockhash, so a signature cannot be replayed for a different action/account or
|
|
12
12
|
// (via the blockhash window) indefinitely. This mirrors the on-chain
|
|
13
13
|
// `resolveEnvelopeSigner` / `authSignBytes` in x/svm/rpc.
|
|
14
|
+
//
|
|
15
|
+
// NOTE: the SVM lane is currently closed on both mainnet and testnet (and the
|
|
16
|
+
// authenticator module is disabled), so nothing signed here is accepted yet. When
|
|
17
|
+
// the lane reopens it verifies only the v2 digest ("qorechain-svm-auth-v2") built below.
|
|
14
18
|
|
|
15
19
|
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
16
20
|
const B58MAP = (() => { const m = {}; for (let i = 0; i < B58.length; i++) m[B58[i]] = i; return m; })();
|
|
@@ -64,23 +68,46 @@ export function systemTransferData(lamports) {
|
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
70
|
* authSignBytes rebuilds, byte-for-byte, the digest the node signs-checks:
|
|
67
|
-
* sha256( "qorechain-svm-auth-
|
|
68
|
-
* Σ[ addr(32) ‖ flags ]
|
|
71
|
+
* sha256( "qorechain-svm-auth-v2" ‖ programId(32) ‖
|
|
72
|
+
* BE64(accounts.length) ‖ Σ[ addr(32) ‖ flags(1) ] ‖
|
|
73
|
+
* BE64(data.length) ‖ data ‖
|
|
74
|
+
* BE64(blockhash.length) ‖ blockhash )
|
|
69
75
|
* where flags = (isSigner?1) | (isWritable?2). All addresses are decoded to
|
|
70
76
|
* their raw 32 bytes; recentBlockhash is the getLatestBlockhash HEX string.
|
|
71
77
|
* Returns a 32-byte Uint8Array (what the wallet actually signs).
|
|
78
|
+
*
|
|
79
|
+
* v1 had no count and no length framing, so the encoding was not injective: the
|
|
80
|
+
* boundary between the fixed-size account entries and `data` was ambiguous (N
|
|
81
|
+
* accounts plus D hashed the same as N-1 accounts plus addr‖flags‖D), and bytes
|
|
82
|
+
* could move between `data` and the blockhash. One approval therefore authorised
|
|
83
|
+
* more than one account set. The count and length prefixes make every field
|
|
84
|
+
* self-delimiting. The tag is bumped so a v1 client fails closed against a v2
|
|
85
|
+
* node rather than signing something ambiguous; keep this in lockstep with
|
|
86
|
+
* qorechain-core/x/svm/types/auth_sign.go.
|
|
72
87
|
*/
|
|
73
88
|
export async function authSignBytes({ programId, accounts, data, recentBlockhashHex }) {
|
|
74
89
|
const parts = [];
|
|
75
90
|
const enc = new TextEncoder();
|
|
76
|
-
|
|
91
|
+
// Big-endian uint64, matching the chain's other sign-bytes helpers.
|
|
92
|
+
const be64 = (n) => {
|
|
93
|
+
const b = new Uint8Array(8);
|
|
94
|
+
let v = BigInt(n);
|
|
95
|
+
for (let i = 7; i >= 0; i--) { b[i] = Number(v & 0xffn); v >>= 8n; }
|
|
96
|
+
return b;
|
|
97
|
+
};
|
|
98
|
+
parts.push(enc.encode('qorechain-svm-auth-v2'));
|
|
77
99
|
parts.push(base58Decode(programId));
|
|
100
|
+
parts.push(be64(accounts.length));
|
|
78
101
|
for (const a of accounts) {
|
|
79
102
|
parts.push(base58Decode(a.pubkey));
|
|
80
103
|
parts.push(new Uint8Array([(a.isSigner ? 1 : 0) | (a.isWritable ? 2 : 0)]));
|
|
81
104
|
}
|
|
82
|
-
|
|
83
|
-
parts.push(
|
|
105
|
+
const dataBytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
106
|
+
parts.push(be64(dataBytes.length));
|
|
107
|
+
parts.push(dataBytes);
|
|
108
|
+
const bhBytes = hexToBytes(recentBlockhashHex);
|
|
109
|
+
parts.push(be64(bhBytes.length));
|
|
110
|
+
parts.push(bhBytes);
|
|
84
111
|
let len = 0; for (const p of parts) len += p.length;
|
|
85
112
|
const buf = new Uint8Array(len); let o = 0; for (const p of parts) { buf.set(p, o); o += p.length; }
|
|
86
113
|
return sha256(buf);
|
package/src/sign-eth.js
CHANGED
|
@@ -16,7 +16,8 @@ import { TxBody, AuthInfo, TxRaw, SignerInfo, ModeInfo, Fee, SignDoc } from 'cos
|
|
|
16
16
|
import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing.js';
|
|
17
17
|
import { PubKey } from 'cosmjs-types/cosmos/crypto/secp256k1/keys.js';
|
|
18
18
|
import { mldsa } from '@qorechain/pqc';
|
|
19
|
-
import {
|
|
19
|
+
import { encodePqcHybridSignature, HYBRID_SIG_TYPE_URL, ALGORITHM_ML_DSA_87 } from './framing.js';
|
|
20
|
+
import { hybridSignBytes, resolveSignBytesVersion } from './signbytes.js';
|
|
20
21
|
|
|
21
22
|
// cosmos/evm eth_secp256k1 pubkey type. Wire shape is identical to the cosmos
|
|
22
23
|
// secp256k1 PubKey ({1: bytes key}), only the typeUrl differs.
|
|
@@ -72,13 +73,24 @@ export async function signClassicalEth({ key, chainId, accountNumber, messages,
|
|
|
72
73
|
/**
|
|
73
74
|
* Hybrid eth_secp256k1 + ML-DSA-87 Cosmos tx. `key` must include `pqc`
|
|
74
75
|
* ({publicKey, secretKey}) as produced by generateQoreWallet/walletFromMnemonic.
|
|
76
|
+
*
|
|
77
|
+
* Sign-bytes form: `signBytesVersion` 'v1' | 'v2' is used as given; 'auto'
|
|
78
|
+
* (default) asks the network via `rest` (LCD URL). On qorechain-vladi /
|
|
79
|
+
* qorechain-diana with neither an explicit version nor `rest` this THROWS rather
|
|
80
|
+
* than guess. The returned Uint8Array carries `.signBytesVersion`.
|
|
75
81
|
*/
|
|
76
|
-
export async function signHybridEth({
|
|
82
|
+
export async function signHybridEth({
|
|
83
|
+
key, chainId, accountNumber, messages, fee, sequence, memo = '', timeoutHeight = 0n,
|
|
84
|
+
signBytesVersion = 'auto', rest, fetch,
|
|
85
|
+
}) {
|
|
86
|
+
const version = await resolveSignBytesVersion({
|
|
87
|
+
chainId, rest, signBytesVersion, ...(fetch ? { fetch } : {}),
|
|
88
|
+
});
|
|
77
89
|
const authInfoBytes = buildAuthInfo(key.pubkey, sequence, fee);
|
|
78
90
|
// B0 = body without the PQC extension.
|
|
79
91
|
const b0 = TxBody.encode(TxBody.fromPartial({ messages, memo, timeoutHeight })).finish();
|
|
80
|
-
// ML-DSA-87 over
|
|
81
|
-
const pqcSig = mldsa.sign(key.pqc.secretKey,
|
|
92
|
+
// ML-DSA-87 over the hybrid sign-bytes in the form this network verifies.
|
|
93
|
+
const pqcSig = mldsa.sign(key.pqc.secretKey, hybridSignBytes(version, chainId, b0, authInfoBytes));
|
|
82
94
|
const bodyWithExt = TxBody.encode(TxBody.fromPartial({
|
|
83
95
|
messages, memo, timeoutHeight,
|
|
84
96
|
extensionOptions: [{ typeUrl: HYBRID_SIG_TYPE_URL, value: encodePqcHybridSignature(ALGORITHM_ML_DSA_87, pqcSig) }],
|
|
@@ -87,5 +99,7 @@ export async function signHybridEth({ key, chainId, accountNumber, messages, fee
|
|
|
87
99
|
bodyBytes: bodyWithExt, authInfoBytes, chainId, accountNumber: BigInt(accountNumber),
|
|
88
100
|
})).finish();
|
|
89
101
|
const classical = await ethSign(signBytes, key.privateKey);
|
|
90
|
-
|
|
102
|
+
const txRaw = TxRaw.encode(TxRaw.fromPartial({ bodyBytes: bodyWithExt, authInfoBytes, signatures: [classical] })).finish();
|
|
103
|
+
txRaw.signBytesVersion = version;
|
|
104
|
+
return txRaw;
|
|
91
105
|
}
|
package/src/signbytes.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @qorechain/wallet-adapter — per-network hybrid PQC sign-bytes (v1 / v2).
|
|
2
|
+
//
|
|
3
|
+
// The ML-DSA-87 key in a hybrid transaction signs one of two byte forms, and a
|
|
4
|
+
// network accepts EXACTLY ONE of them at any height (no overlap, by design):
|
|
5
|
+
//
|
|
6
|
+
// v1 (legacy): BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A
|
|
7
|
+
// v2: "qorechain-pqc-hybrid-v2" ‖ BE64(len chainId) ‖ chainId ‖
|
|
8
|
+
// BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A
|
|
9
|
+
//
|
|
10
|
+
// B0 = TxBody WITHOUT the PQC extension option, A = AuthInfo bytes verbatim.
|
|
11
|
+
// Byte-identical to the chain's x/pqc/types.HybridSignBytesLegacy / HybridSignBytes.
|
|
12
|
+
//
|
|
13
|
+
// Which form to sign: a network that existed before chain release v3.1.98
|
|
14
|
+
// (qorechain-vladi mainnet, qorechain-diana testnet) verifies v1 until the
|
|
15
|
+
// "v3.1.98" upgrade plan is applied on it, and v2 from then on. Any other chain
|
|
16
|
+
// verifies v2 from its first block. The two networks upgrade at different
|
|
17
|
+
// heights, so a client must ASK the target network (applied_plan) rather than
|
|
18
|
+
// hardcode a form. That is what resolveSignBytesVersion does.
|
|
19
|
+
|
|
20
|
+
export const HYBRID_SIGN_BYTES_V2_DOMAIN = 'qorechain-pqc-hybrid-v2';
|
|
21
|
+
export const SIGN_BYTES_V2_UPGRADE = 'v3.1.98';
|
|
22
|
+
export const LEGACY_SIGN_BYTES_CHAINS = Object.freeze(['qorechain-vladi', 'qorechain-diana']);
|
|
23
|
+
|
|
24
|
+
const te = new TextEncoder();
|
|
25
|
+
|
|
26
|
+
function assertBytes(name, v) {
|
|
27
|
+
if (!(v instanceof Uint8Array)) throw new TypeError(`${name} must be a Uint8Array`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** v1 (legacy) hybrid sign-bytes: BE32(len b0) ‖ b0 ‖ BE32(len authInfo) ‖ authInfo. */
|
|
31
|
+
export function hybridSignBytesV1(b0, authInfo) {
|
|
32
|
+
assertBytes('b0', b0);
|
|
33
|
+
assertBytes('authInfo', authInfo);
|
|
34
|
+
const out = new Uint8Array(4 + b0.length + 4 + authInfo.length);
|
|
35
|
+
const dv = new DataView(out.buffer);
|
|
36
|
+
let o = 0;
|
|
37
|
+
dv.setUint32(o, b0.length, false); o += 4;
|
|
38
|
+
out.set(b0, o); o += b0.length;
|
|
39
|
+
dv.setUint32(o, authInfo.length, false); o += 4;
|
|
40
|
+
out.set(authInfo, o);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** v2 hybrid sign-bytes: domain ‖ BE64(len chainId) ‖ chainId ‖ BE32(len b0) ‖ b0 ‖ BE32(len authInfo) ‖ authInfo. */
|
|
45
|
+
export function hybridSignBytesV2(chainId, b0, authInfo) {
|
|
46
|
+
if (typeof chainId !== 'string' || chainId.length === 0) {
|
|
47
|
+
throw new Error('hybridSignBytesV2: chainId is required (v2 sign-bytes bind the chain-id)');
|
|
48
|
+
}
|
|
49
|
+
assertBytes('b0', b0);
|
|
50
|
+
assertBytes('authInfo', authInfo);
|
|
51
|
+
const domain = te.encode(HYBRID_SIGN_BYTES_V2_DOMAIN);
|
|
52
|
+
const cid = te.encode(chainId);
|
|
53
|
+
const out = new Uint8Array(domain.length + 8 + cid.length + 4 + b0.length + 4 + authInfo.length);
|
|
54
|
+
const dv = new DataView(out.buffer);
|
|
55
|
+
let o = 0;
|
|
56
|
+
out.set(domain, o); o += domain.length;
|
|
57
|
+
dv.setBigUint64(o, BigInt(cid.length), false); o += 8;
|
|
58
|
+
out.set(cid, o); o += cid.length;
|
|
59
|
+
dv.setUint32(o, b0.length, false); o += 4;
|
|
60
|
+
out.set(b0, o); o += b0.length;
|
|
61
|
+
dv.setUint32(o, authInfo.length, false); o += 4;
|
|
62
|
+
out.set(authInfo, o);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertVersion(version, fn) {
|
|
67
|
+
if (version !== 'v1' && version !== 'v2') {
|
|
68
|
+
throw new Error(`${fn}: version must be 'v1' or 'v2', got ${JSON.stringify(version)} (resolve it with resolveSignBytesVersion first)`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Version-dispatching hybrid sign-bytes. `version` is REQUIRED ('v1' | 'v2'); there is no implicit form. */
|
|
73
|
+
export function hybridSignBytes(version, chainId, b0, authInfo) {
|
|
74
|
+
assertVersion(version, 'hybridSignBytes');
|
|
75
|
+
return version === 'v2' ? hybridSignBytesV2(chainId, b0, authInfo) : hybridSignBytesV1(b0, authInfo);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toHeight(h) {
|
|
79
|
+
if (h === undefined || h === null || h === '') return 0n;
|
|
80
|
+
if (typeof h === 'bigint') return h;
|
|
81
|
+
if (typeof h === 'number') {
|
|
82
|
+
if (!Number.isFinite(h)) throw new Error(`invalid applied height ${h}`);
|
|
83
|
+
return BigInt(Math.trunc(h));
|
|
84
|
+
}
|
|
85
|
+
return BigInt(String(h)); // throws on garbage, never guesses
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The form a client must sign for `chainId`, given the height at which the
|
|
90
|
+
* v3.1.98 plan was applied on it (0 / "0" / missing = not applied). Mirrors the
|
|
91
|
+
* chain's SignBytesVersionFor. Heights are compared NUMERICALLY: the node returns
|
|
92
|
+
* the height as a string, and "0" is truthy.
|
|
93
|
+
*/
|
|
94
|
+
export function signBytesVersionFor(chainId, v2AppliedHeight) {
|
|
95
|
+
if (toHeight(v2AppliedHeight) > 0n) return 'v2';
|
|
96
|
+
return LEGACY_SIGN_BYTES_CHAINS.includes(chainId) ? 'v1' : 'v2';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cache = new Map(); // `${rest}|${chainId}` -> { version, at }
|
|
100
|
+
|
|
101
|
+
/** Drop every cached resolver answer. */
|
|
102
|
+
export function clearSignBytesCache() { cache.clear(); }
|
|
103
|
+
|
|
104
|
+
function normRest(rest) { return String(rest).replace(/\/+$/, ''); }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the hybrid sign-bytes form for a network.
|
|
108
|
+
* - 'v1' | 'v2' are returned as-is (no network).
|
|
109
|
+
* - 'auto' (default): a chain that is not a legacy network gets 'v2' with no
|
|
110
|
+
* HTTP; a legacy network is asked `GET {rest}/cosmos/upgrade/v1beta1/applied_plan/v3.1.98`
|
|
111
|
+
* and signs v2 iff the returned height > 0. Answers are cached per
|
|
112
|
+
* (rest, chainId) for `ttlMs`; `forceRefresh` bypasses the cache.
|
|
113
|
+
* Throws (never guesses) when a legacy network has no `rest` or the query fails.
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveSignBytesVersion({
|
|
116
|
+
chainId, rest, signBytesVersion = 'auto', fetch = globalThis.fetch, ttlMs = 60_000, forceRefresh = false,
|
|
117
|
+
} = {}) {
|
|
118
|
+
const mode = signBytesVersion ?? 'auto';
|
|
119
|
+
if (mode === 'v1' || mode === 'v2') return mode;
|
|
120
|
+
if (mode !== 'auto') {
|
|
121
|
+
throw new Error(`signBytesVersion must be 'auto', 'v1' or 'v2', got ${JSON.stringify(signBytesVersion)}`);
|
|
122
|
+
}
|
|
123
|
+
if (typeof chainId !== 'string' || chainId.length === 0) {
|
|
124
|
+
throw new Error('resolveSignBytesVersion: chainId is required');
|
|
125
|
+
}
|
|
126
|
+
if (!LEGACY_SIGN_BYTES_CHAINS.includes(chainId)) return 'v2';
|
|
127
|
+
|
|
128
|
+
const hint = `pass \`rest\` (the network's LCD URL) or an explicit signBytesVersion 'v1' | 'v2'`;
|
|
129
|
+
if (!rest) {
|
|
130
|
+
throw new Error(`Cannot choose the hybrid sign-bytes form for ${chainId} without asking the network: ${hint}.`);
|
|
131
|
+
}
|
|
132
|
+
const base = normRest(rest);
|
|
133
|
+
const key = `${base}|${chainId}`;
|
|
134
|
+
if (!forceRefresh) {
|
|
135
|
+
const hit = cache.get(key);
|
|
136
|
+
if (hit && Date.now() - hit.at < ttlMs) return hit.version;
|
|
137
|
+
}
|
|
138
|
+
if (typeof fetch !== 'function') {
|
|
139
|
+
throw new Error(`Cannot query ${base}: no fetch implementation available; ${hint}.`);
|
|
140
|
+
}
|
|
141
|
+
const url = `${base}/cosmos/upgrade/v1beta1/applied_plan/${SIGN_BYTES_V2_UPGRADE}`;
|
|
142
|
+
let version;
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
145
|
+
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : 'no response'}`);
|
|
146
|
+
const body = await res.json();
|
|
147
|
+
version = signBytesVersionFor(chainId, body?.height ?? '0');
|
|
148
|
+
} catch (e) {
|
|
149
|
+
throw new Error(`Cannot determine the hybrid sign-bytes form for ${chainId} from ${url} (${e && e.message ? e.message : e}); ${hint}.`);
|
|
150
|
+
}
|
|
151
|
+
cache.set(key, { version, at: Date.now() });
|
|
152
|
+
return version;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const REJECTION_TEXT = 'hybrid PQC signature verification failed';
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* True iff a broadcast failure is the chain refusing the hybrid PQC signature
|
|
159
|
+
* (x/pqc ErrHybridSigInvalid: codespace "pqc", code 21) — the signal that the
|
|
160
|
+
* wrong sign-bytes form was used. Accepts a cosmjs BroadcastTxError
|
|
161
|
+
* ({ code, codespace, log }), a DeliverTxResponse-like ({ code, rawLog }), a
|
|
162
|
+
* plain Error, or a string. Code 21 from another codespace does NOT match.
|
|
163
|
+
*/
|
|
164
|
+
export function isHybridSignBytesRejection(errOrResult) {
|
|
165
|
+
if (!errOrResult) return false;
|
|
166
|
+
if (typeof errOrResult === 'string') return errOrResult.includes(REJECTION_TEXT);
|
|
167
|
+
const x = errOrResult;
|
|
168
|
+
if (x.codespace === 'pqc' && Number(x.code) === 21) return true;
|
|
169
|
+
for (const f of [x.log, x.rawLog, x.raw_log, x.message]) {
|
|
170
|
+
if (typeof f === 'string' && f.includes(REJECTION_TEXT)) return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
package/src/wallet.js
CHANGED
|
@@ -89,10 +89,25 @@ export async function walletFromMnemonic(mnemonic) {
|
|
|
89
89
|
/**
|
|
90
90
|
* Derive a unified QoreChain wallet directly from a 32-byte seed (no mnemonic).
|
|
91
91
|
*
|
|
92
|
-
* The seed
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
92
|
+
* The seed IS the secp256k1 private key. It must therefore come from something
|
|
93
|
+
* that is secret and stays secret — a CSPRNG, or a KDF over material only the
|
|
94
|
+
* user holds.
|
|
95
|
+
*
|
|
96
|
+
* DO NOT derive it from a wallet signature. Earlier versions of this comment
|
|
97
|
+
* recommended `shake256(phantomSignature)` for a "connect → 3 addresses" flow.
|
|
98
|
+
* That is unsound: signature schemes like ed25519 are deterministic (RFC 8032),
|
|
99
|
+
* and the message such flows sign is fixed and public, so the signature is a
|
|
100
|
+
* constant that any site can ask the same wallet to reproduce. Whoever obtains
|
|
101
|
+
* it reconstructs this account's entire key material, classical and ML-DSA-87.
|
|
102
|
+
* Changing the message does not help — any message an attacker can also request
|
|
103
|
+
* yields the same key.
|
|
104
|
+
*
|
|
105
|
+
* To let an external wallet authorise spending, register its key as an
|
|
106
|
+
* AUTHENTICATOR on a QoreChain account (MsgRegisterAuthenticator, with a
|
|
107
|
+
* permission set and a SpendingRule) instead of pretending a signature is a
|
|
108
|
+
* seed. Reported as QSR-2026-0049 and QSR-2026-0176.
|
|
109
|
+
*
|
|
110
|
+
* Same 20-byte identity model as
|
|
96
111
|
* `walletFromMnemonic`: qor1 / 0x / svm all resolve to the same account and the
|
|
97
112
|
* same balance, and the key signs on every interface (incl. hybrid PQC).
|
|
98
113
|
*
|