@qorechain/wallet-adapter 0.1.7 → 0.2.1

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 CHANGED
@@ -24,14 +24,94 @@ Mirrors the chain's own `qorechaind tx pqc cosign`:
24
24
 
25
25
  ```
26
26
  B0 = TxBody{messages, memo, timeoutHeight} // no extension
27
- sigP = ML-DSA-87.sign( frame(B0, authInfoBytes) ) // adapter does this
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
- where `frame(b0, auth) = BE32(len b0) b0 ‖ BE32(len auth) ‖ auth`, the extension
34
- type URL is `/qorechain.pqc.v1.PQCHybridSignature`, and algorithm `1` = ML-DSA-87.
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.2.0, testnet 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 v2
50
+ (`qorechain-vladi` mainnet, `qorechain-diana` testnet) verify v1 until the
51
+ upgrade plan that carries the switch is applied on them and v2 from then on; they
52
+ upgrade at different heights. Today the testnet verifies v2 and **mainnet stays
53
+ on v1 until its own upgrade**. Any other chain verifies v2 from its first block.
54
+
55
+ **The switch ships under two plan names.** The release is `v3.2.0`, but the
56
+ testnet already took the same handler under the earlier name `v3.1.98` and keeps
57
+ that record forever, so the chain registers both (`SIGN_BYTES_V2_UPGRADES =
58
+ ['v3.2.0', 'v3.1.98']`). A client must ask for **every** name and sign v2 if the
59
+ numeric height of **any** of them is > 0. Asking for one name only resolves v1 on
60
+ a network that upgraded under the other, and every hybrid transaction is then
61
+ refused with `pqc` code 21.
62
+
63
+ `signBytesVersion` (on `QoreChainSigner`, per `signHybrid` call, and on
64
+ `signHybridEth`) takes:
65
+
66
+ - `'auto'` (default) — a non-legacy chain signs v2 with no network call. On
67
+ `qorechain-vladi` / `qorechain-diana` the adapter asks the network
68
+ `GET {rest}/cosmos/upgrade/v1beta1/applied_plan/{name}` for every name in
69
+ `SIGN_BYTES_V2_UPGRADES` (`v3.2.0`, then `v3.1.98`) and signs v2 iff the
70
+ returned height of any of them is > 0 (compared numerically: a network that has
71
+ not taken a plan answers `{"height":"0"}` or `{}`). The names are asked in
72
+ order and the first positive height wins, so a network on the current release
73
+ costs one request and one that upgraded under the earlier name costs two.
74
+ The answer is cached per (rest, chain-id) for 60 s. **Pass `rest` (the LCD URL)**;
75
+ without it, or if a query fails, signing throws instead of guessing.
76
+ - `'v1'` / `'v2'` — used as given, no network call.
77
+
78
+ **Upgrading to 0.2.1.** 0.2.0 asked for `v3.1.98` alone, which resolves v1 on a
79
+ mainnet that upgraded as `v3.2.0` and gets every hybrid transaction refused with
80
+ `pqc` code 21. Upgrade before the mainnet upgrade height; nothing else in the
81
+ resolver changed and no call site needs touching.
82
+
83
+ **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.
84
+
85
+ Signed results are the usual `TxRaw` `Uint8Array`, with `.signBytesVersion`
86
+ (`'v1' | 'v2'`) set to the form actually used.
87
+
88
+ ### Retry once on a sign-bytes refusal (caller side)
89
+
90
+ The adapter only signs; you broadcast. A network can upgrade while a wallet is
91
+ open, so when the version was `'auto'`, handle a refusal with `pqc` code 21
92
+ ("hybrid PQC signature verification failed") by re-resolving once:
93
+
94
+ ```js
95
+ import { QoreChainSigner, isHybridSignBytesRejection } from '@qorechain/wallet-adapter';
96
+
97
+ const signer = new QoreChainSigner({ wallet, chainId, address, pubkeySecp256k1,
98
+ accountNumber, pqc, rest: lcdUrl /* signBytesVersion: 'auto' is the default */ });
99
+
100
+ let txBytes = await signer.signHybrid({ messages, fee, sequence });
101
+ try {
102
+ await client.broadcastTx(txBytes); // cosmjs throws BroadcastTxError / returns {code, rawLog}
103
+ } catch (err) {
104
+ if (!isHybridSignBytesRejection(err)) throw err; // only codespace "pqc" code 21
105
+ await signer.refreshSignBytesVersion(); // bypasses the cache
106
+ txBytes = await signer.signHybrid({ messages, fee, sequence });
107
+ await client.broadcastTx(txBytes); // broadcast ONCE more; surface any error
108
+ }
109
+ ```
110
+
111
+ If you check a returned result instead of catching, pass it to
112
+ `isHybridSignBytesRejection(result)` the same way (`{ code, rawLog }` works).
113
+ Do not retry when you passed an explicit `'v1'`/`'v2'`, and do not treat code 21
114
+ from another codespace as this case.
35
115
 
36
116
  **Verified end-to-end:** an adapter-built tx (ML-DSA-87 via `@noble/post-quantum`
37
117
  + classical via a cosmjs signer standing in for Keplr) **committed with code 0**
@@ -59,6 +139,7 @@ const pqc = await derivePqcKeyFromWallet(window.keplr, 'qorechain-diana', accoun
59
139
  const adapter = new QoreChainSigner({
60
140
  wallet: window.keplr, chainId: 'qorechain-diana', address: account.address,
61
141
  pubkeySecp256k1: account.pubkey, accountNumber, pqc,
142
+ rest, // LCD URL: lets the adapter pick the sign-bytes form this network verifies
62
143
  });
63
144
  const txBytes = await adapter.signHybrid({ messages, fee, sequence });
64
145
  await fetch(`${rpc}`, { method:'POST', body: JSON.stringify({
@@ -72,7 +153,7 @@ await fetch(`${rpc}`, { method:'POST', body: JSON.stringify({
72
153
  | **Keplr** | `experimentalSuggestChain` + `signDirect` | ✅ supported |
73
154
  | **Leap / Cosmostation** | same `signDirect` interface | ✅ supported (any wallet exposing `signDirect`) |
74
155
  | **MetaMask** | uses QoreChain's **EVM** path (chainId 9800) — structurally PQC-exempt | ✅ works natively, no adapter needed |
75
- | **Phantom** | derive a unified account from a Phantom signature (`walletFromSeed`) | ✅ connect qor1/0x/svm, receive on any, spend on any (incl. hybrid PQC) |
156
+ | **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
157
 
77
158
  ## API
78
159
 
@@ -82,13 +163,18 @@ Wallet generation & unified addresses:
82
163
 
83
164
  eth-native Cosmos signing (chain ≥ v3.1.83):
84
165
  - `signClassicalEth({ key, chainId, accountNumber, sequence, messages, fee, memo?, timeoutHeight? })` → `TxRaw` bytes (classical, e.g. PQC key registration).
85
- - `signHybridEth({ ... })` → `TxRaw` bytes (eth_secp256k1 + ML-DSA-87 hybrid).
166
+ - `signHybridEth({ ..., signBytesVersion?, rest? })` → `TxRaw` bytes (eth_secp256k1 + ML-DSA-87 hybrid); throws on a legacy network with neither an explicit version nor `rest`.
86
167
  - `ETHSECP256K1_PUBKEY_TYPE` — the eth pubkey type URL.
87
168
 
88
169
  Keplr / any-signDirect adapter + PQC framing:
89
- - `QoreChainSigner#signHybrid({ messages, fee, sequence, memo?, timeoutHeight? })` `TxRaw` bytes.
170
+ - `new QoreChainSigner({ wallet, chainId, address, pubkeySecp256k1, accountNumber, pqc, rest?, signBytesVersion? = 'auto', fetch? })`.
171
+ - `QoreChainSigner#signHybrid({ messages, fee, sequence, memo?, timeoutHeight?, signBytesVersion? })` → `TxRaw` bytes with `.signBytesVersion`.
172
+ - `QoreChainSigner#refreshSignBytesVersion()` → re-resolves, bypassing the cache.
90
173
  - `derivePqcKeyFromWallet(wallet, chainId, address)` — deterministic ML-DSA-87 key from a wallet signature.
91
- - `frame(b0, auth)` — QoreChain hybrid sign-bytes framing; `encodePqcHybridSignature(algId, sig)` proto encoder for the extension.
174
+ - `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).
175
+ - `signBytesVersionFor(chainId, v2AppliedHeight)`, `resolveSignBytesVersion({ chainId, rest?, signBytesVersion?, fetch?, ttlMs?, forceRefresh? })`, `clearSignBytesCache()`, `isHybridSignBytesRejection(errOrResult)`.
176
+ - Constants `HYBRID_SIGN_BYTES_V2_DOMAIN`, `SIGN_BYTES_V2_UPGRADES` (`["v3.2.0", "v3.1.98"]` — every plan name that switches a network to v2), `SIGN_BYTES_V2_UPGRADE` (`"v3.2.0"`, the primary name = `SIGN_BYTES_V2_UPGRADES[0]`), `LEGACY_SIGN_BYTES_CHAINS`.
177
+ - `encodePqcHybridSignature(algId, sig)` — proto encoder for the extension.
92
178
  - `qoreChainInfo({ chainId?, rpc, rest })` — Keplr chain descriptor; `qoreEvmChainParams(...)` / `addQoreEvmToWallet(provider, opts)` — MetaMask (EIP-3085) EVM descriptor.
93
179
 
94
180
  ## License
@@ -122,22 +208,31 @@ under which the chain reads one `x/bank` balance. The account can sign EVM txs
122
208
  `addressesFrom20(bytes20)` / `qoreAddresses({cosmos|evm|hex})` derive the three
123
209
  encodings from a known account (for explorers / backends).
124
210
 
125
- ### Derive from a seed (Phantom & other non-mnemonic flows)
126
-
127
- `walletFromSeed(seed32)` builds the same unified wallet from any 32 bytes (the
128
- seed becomes the secp256k1 key). Because a wallet's signature over a fixed message
129
- is deterministic, you can derive **one canonical QoreChain account from a Phantom
130
- (ed25519) signature** — a Phantom user connects once and gets three usable
131
- QoreChain addresses they fully control:
132
-
133
- ```js
134
- import { walletFromSeed } from "@qorechain/wallet-adapter";
135
- import { shake256 } from "@qorechain/pqc";
136
-
137
- const msg = new TextEncoder().encode("QoreChain unified account derivation v1");
138
- const { signature } = await window.solana.signMessage(msg); // Phantom, deterministic
139
- const w = await walletFromSeed(shake256(signature, 32)); // → qor1 / 0x / svm + pqc
140
- ```
211
+ ### Derive from a seed (non-mnemonic flows)
212
+
213
+ `walletFromSeed(seed32)` builds the same unified wallet from any 32 bytes. **The
214
+ seed becomes the secp256k1 private key**, so it must come from something secret
215
+ and stay secret: a CSPRNG, or a KDF over material only the user holds.
216
+
217
+ > **Do not derive the seed from a wallet signature.** Versions of this README up
218
+ > to 0.1.7 showed a Phantom "connect → three addresses" recipe built on
219
+ > `shake256(signature)` over a fixed message. That recipe is unsound and has
220
+ > been withdrawn. Signature schemes like ed25519 are deterministic (RFC 8032)
221
+ > and the message was public, so the signature is a *constant* that any website
222
+ > can ask the same wallet to reproduce — and whoever obtains it reconstructs the
223
+ > account's entire key material, classical and ML-DSA-87, with nothing to
224
+ > revoke. Changing the message does not fix it: any message an attacker can also
225
+ > request yields the same key.
226
+ >
227
+ > If you followed that recipe, treat every account derived from it as
228
+ > compromised and move the funds.
229
+
230
+ To let an external wallet (Phantom, MetaMask, …) authorise spending on a
231
+ QoreChain account, register its key as an **authenticator** instead:
232
+ `MsgRegisterAuthenticator`, with an explicit permission set and a `SpendingRule`.
233
+ The external key then signs authorisations for an account it never owned, and it
234
+ can be revoked. See the authenticator execution lanes (`MsgExecuteEVM` /
235
+ `MsgExecuteCosmos`, chain ≥ v3.1.85).
141
236
 
142
237
  ## eth-native Cosmos signing (requires chain ≥ v3.1.83)
143
238
 
@@ -161,7 +256,8 @@ const regTx = await signClassicalEth({ key, chainId, accountNumber, sequence,
161
256
 
162
257
  // 2) thereafter: hybrid eth_secp256k1 + ML-DSA-87 (e.g. a bank MsgSend)
163
258
  const sendTx = await signHybridEth({ key, chainId, accountNumber, sequence,
164
- messages: [{ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: msgSendBytes }], fee });
259
+ messages: [{ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: msgSendBytes }], fee,
260
+ rest: lcdUrl /* picks v1/v2 for this network; or signBytesVersion: 'v1' | 'v2' */ });
165
261
  ```
166
262
 
167
263
  > **Requires QoreChain ≥ v3.1.83** — that release registers the `eth_secp256k1`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qorechain/wallet-adapter",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
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",
package/src/framing.js CHANGED
@@ -1,17 +1,11 @@
1
- // Pure, dependency-free QoreChain PQC tx-extension framing (the chain-matching bits).
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
- /** Hybrid eth_secp256k1 + ML-DSA-87 Cosmos tx (key.pqc required). */
40
- export function signHybridEth(args: EthSignArgs): Promise<Uint8Array>;
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,13 +56,52 @@ 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
- export function frame(b0: Uint8Array, auth: Uint8Array): Uint8Array;
59
+ // --- Per-network hybrid PQC sign-bytes (v1 legacy / v2, chain v3.2.0 / testnet 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
+ /** Every upgrade plan name that switches a network to v2, most recent first. */
64
+ export const SIGN_BYTES_V2_UPGRADES: readonly ['v3.2.0', 'v3.1.98'];
65
+ /** The primary (current release) plan name. */
66
+ export const SIGN_BYTES_V2_UPGRADE: 'v3.2.0';
67
+ export const LEGACY_SIGN_BYTES_CHAINS: readonly string[];
68
+ /** v1: BE32(len b0) ‖ b0 ‖ BE32(len authInfo) ‖ authInfo. */
69
+ export function hybridSignBytesV1(b0: Uint8Array, authInfo: Uint8Array): Uint8Array;
70
+ /** v2: domain ‖ BE64(len chainId) ‖ chainId ‖ BE32(len b0) ‖ b0 ‖ BE32(len authInfo) ‖ authInfo. */
71
+ export function hybridSignBytesV2(chainId: string, b0: Uint8Array, authInfo: Uint8Array): Uint8Array;
72
+ /** Version-dispatching builder; `version` is required. */
73
+ export function hybridSignBytes(version: SignBytesVersion, chainId: string, b0: Uint8Array, authInfo: Uint8Array): Uint8Array;
74
+ /** Mirror of the chain's SignBytesVersionFor; pass the greatest applied height over SIGN_BYTES_V2_UPGRADES. The height is compared numerically ("0" → not applied). */
75
+ export function signBytesVersionFor(chainId: string, v2AppliedHeight: string | number | bigint | null | undefined): SignBytesVersion;
76
+ export function resolveSignBytesVersion(opts: {
77
+ chainId: string;
78
+ rest?: string;
79
+ signBytesVersion?: SignBytesVersionOption;
80
+ fetch?: typeof globalThis.fetch;
81
+ ttlMs?: number;
82
+ forceRefresh?: boolean;
83
+ }): Promise<SignBytesVersion>;
84
+ export function clearSignBytesCache(): void;
85
+ /** True iff the error/result is the chain refusing the hybrid PQC signature (codespace "pqc", code 21). */
86
+ export function isHybridSignBytesRejection(errOrResult: unknown): boolean;
87
+ /** TxRaw bytes plus the sign-bytes form that was used. */
88
+ export type SignedTxBytes = Uint8Array & { signBytesVersion: SignBytesVersion };
57
89
  export function encodePqcHybridSignature(algorithmId: number, sig: Uint8Array): Uint8Array;
58
90
  export function derivePqcKeyFromWallet(wallet: any, chainId: string, address: string, domain?: string): Promise<{ publicKey: Uint8Array; secretKey: Uint8Array }>;
59
91
  export function qoreChainInfo(opts?: { chainId?: string; rpc?: string; rest?: string }): any;
60
92
  export class QoreChainSigner {
61
- constructor(opts: { wallet: any; chainId: string; address: string; pubkeySecp256k1: Uint8Array; accountNumber: number | bigint; pqc: { publicKey: Uint8Array; secretKey: Uint8Array } });
62
- signHybrid(opts: { messages: any[]; fee: any; memo?: string; sequence: number | bigint; timeoutHeight?: bigint }): Promise<Uint8Array>;
93
+ constructor(opts: {
94
+ wallet: any; chainId: string; address: string; pubkeySecp256k1: Uint8Array; accountNumber: number | bigint;
95
+ pqc: { publicKey: Uint8Array; secretKey: Uint8Array };
96
+ /** LCD URL; required to auto-resolve the sign-bytes form on qorechain-vladi / qorechain-diana. */
97
+ rest?: string;
98
+ /** Default 'auto'. */
99
+ signBytesVersion?: SignBytesVersionOption;
100
+ fetch?: typeof globalThis.fetch;
101
+ });
102
+ signHybrid(opts: { messages: any[]; fee: any; memo?: string; sequence: number | bigint; timeoutHeight?: bigint; signBytesVersion?: SignBytesVersionOption }): Promise<SignedTxBytes>;
103
+ /** Re-resolve bypassing the cache; returns the new form. */
104
+ refreshSignBytesVersion(): Promise<SignBytesVersion>;
63
105
  }
64
106
 
65
107
  // --- v3.1.85 authenticator lanes (EVM + Native/Cosmos) + PQC key rotation (requires chain >= v3.1.85) ---
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
- // sigP = ML-DSA-87.sign( frame(B0, authInfoBytes) ) // frame = below
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
- // where frame(b0, auth) = BE32(len b0) ‖ b0 ‖ BE32(len auth) ‖ auth.
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 { frame, encodePqcHybridSignature, HYBRID_SIG_TYPE_URL, ALGORITHM_ML_DSA_87 } from './framing.js';
26
- export { frame, encodePqcHybridSignature, HYBRID_SIG_TYPE_URL, ALGORITHM_ML_DSA_87 };
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, SIGN_BYTES_V2_UPGRADES, 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,
@@ -64,12 +75,37 @@ export async function derivePqcKeyFromWallet(wallet, chainId, address, domain =
64
75
  export class QoreChainSigner {
65
76
  // wallet: a Keplr-like object exposing signDirect(chainId, signer, signDoc) and
66
77
  // (optionally) signArbitrary(...). pqc: { publicKey, secretKey } ML-DSA-87.
67
- constructor({ wallet, chainId, address, pubkeySecp256k1, accountNumber, pqc }) {
68
- Object.assign(this, { wallet, chainId, address, pubkeySecp256k1, accountNumber, pqc });
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
+ });
69
94
  }
70
95
 
71
- // Build + hybrid-sign + return TxRaw bytes ready to broadcast.
72
- async signHybrid({ messages, fee, memo = '', sequence, timeoutHeight = 0n }) {
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);
101
+ }
102
+
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
+
73
109
  // 1. AuthInfo: single DIRECT signer (secp256k1) + fee.
74
110
  const pubAny = {
75
111
  typeUrl: '/cosmos.crypto.secp256k1.PubKey',
@@ -88,8 +124,8 @@ export class QoreChainSigner {
88
124
  // 2. B0 = body without the PQC extension.
89
125
  const b0 = TxBody.encode(TxBody.fromPartial({ messages, memo, timeoutHeight })).finish();
90
126
 
91
- // 3. ML-DSA-87 sign the framed (B0, authInfo).
92
- const pqcSig = mldsa.sign(this.pqc.secretKey, frame(b0, authInfoBytes));
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));
93
129
 
94
130
  // 4. body WITH the PQC hybrid extension.
95
131
  const bodyWithExt = TxBody.encode(TxBody.fromPartial({
@@ -108,9 +144,11 @@ export class QoreChainSigner {
108
144
  ? Uint8Array.from(Buffer.from(signature.signature, 'base64')) : signature.signature;
109
145
 
110
146
  // 6. Assemble TxRaw.
111
- return TxRaw.encode(TxRaw.fromPartial({
147
+ const txRaw = TxRaw.encode(TxRaw.fromPartial({
112
148
  bodyBytes: bodyWithExt, authInfoBytes, signatures: [classicalSig],
113
149
  })).finish();
150
+ txRaw.signBytesVersion = version;
151
+ return txRaw;
114
152
  }
115
153
  }
116
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-v1" ‖ programId(32) ‖
68
- * Σ[ addr(32) ‖ flags ] data recentBlockhash(32) )
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
- parts.push(enc.encode('qorechain-svm-auth-v1'));
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
- parts.push(data instanceof Uint8Array ? data : new Uint8Array(data));
83
- parts.push(hexToBytes(recentBlockhashHex));
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 { frame, encodePqcHybridSignature, HYBRID_SIG_TYPE_URL, ALGORITHM_ML_DSA_87 } from './framing.js';
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({ key, chainId, accountNumber, messages, fee, sequence, memo = '', timeoutHeight = 0n }) {
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 frame(B0, authInfo).
81
- const pqcSig = mldsa.sign(key.pqc.secretKey, frame(b0, authInfoBytes));
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
- return TxRaw.encode(TxRaw.fromPartial({ bodyBytes: bodyWithExt, authInfoBytes, signatures: [classical] })).finish();
102
+ const txRaw = TxRaw.encode(TxRaw.fromPartial({ bodyBytes: bodyWithExt, authInfoBytes, signatures: [classical] })).finish();
103
+ txRaw.signBytesVersion = version;
104
+ return txRaw;
91
105
  }
@@ -0,0 +1,196 @@
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 the release that introduced
14
+ // v2 (qorechain-vladi mainnet, qorechain-diana testnet) verifies v1 until that
15
+ // upgrade plan is applied on it, and v2 from then on. Any other chain verifies
16
+ // v2 from its first block. The two networks upgrade at different heights, so a
17
+ // client must ASK the target network (applied_plan) rather than hardcode a form.
18
+ // That is what resolveSignBytesVersion does.
19
+ //
20
+ // The switch ships under TWO plan names: the release is "v3.2.0", but the
21
+ // testnet already took the same handler under the earlier name "v3.1.98" and
22
+ // keeps that record forever. The chain registers both (x/pqc/types
23
+ // SignBytesV2Upgrades), so a client must ask for EVERY name and sign v2 if the
24
+ // numeric height of ANY of them is greater than zero. Asking for one name only
25
+ // resolves v1 on a network that upgraded under the other, and every hybrid
26
+ // transaction is then refused with pqc code 21.
27
+
28
+ export const HYBRID_SIGN_BYTES_V2_DOMAIN = 'qorechain-pqc-hybrid-v2';
29
+ /** Every upgrade plan name that switches a network to v2 sign-bytes, most recent first. */
30
+ export const SIGN_BYTES_V2_UPGRADES = Object.freeze(['v3.2.0', 'v3.1.98']);
31
+ /** The primary (current release) plan name; see SIGN_BYTES_V2_UPGRADES for all of them. */
32
+ export const SIGN_BYTES_V2_UPGRADE = SIGN_BYTES_V2_UPGRADES[0];
33
+ export const LEGACY_SIGN_BYTES_CHAINS = Object.freeze(['qorechain-vladi', 'qorechain-diana']);
34
+
35
+ const te = new TextEncoder();
36
+
37
+ function assertBytes(name, v) {
38
+ if (!(v instanceof Uint8Array)) throw new TypeError(`${name} must be a Uint8Array`);
39
+ }
40
+
41
+ /** v1 (legacy) hybrid sign-bytes: BE32(len b0) ‖ b0 ‖ BE32(len authInfo) ‖ authInfo. */
42
+ export function hybridSignBytesV1(b0, authInfo) {
43
+ assertBytes('b0', b0);
44
+ assertBytes('authInfo', authInfo);
45
+ const out = new Uint8Array(4 + b0.length + 4 + authInfo.length);
46
+ const dv = new DataView(out.buffer);
47
+ let o = 0;
48
+ dv.setUint32(o, b0.length, false); o += 4;
49
+ out.set(b0, o); o += b0.length;
50
+ dv.setUint32(o, authInfo.length, false); o += 4;
51
+ out.set(authInfo, o);
52
+ return out;
53
+ }
54
+
55
+ /** v2 hybrid sign-bytes: domain ‖ BE64(len chainId) ‖ chainId ‖ BE32(len b0) ‖ b0 ‖ BE32(len authInfo) ‖ authInfo. */
56
+ export function hybridSignBytesV2(chainId, b0, authInfo) {
57
+ if (typeof chainId !== 'string' || chainId.length === 0) {
58
+ throw new Error('hybridSignBytesV2: chainId is required (v2 sign-bytes bind the chain-id)');
59
+ }
60
+ assertBytes('b0', b0);
61
+ assertBytes('authInfo', authInfo);
62
+ const domain = te.encode(HYBRID_SIGN_BYTES_V2_DOMAIN);
63
+ const cid = te.encode(chainId);
64
+ const out = new Uint8Array(domain.length + 8 + cid.length + 4 + b0.length + 4 + authInfo.length);
65
+ const dv = new DataView(out.buffer);
66
+ let o = 0;
67
+ out.set(domain, o); o += domain.length;
68
+ dv.setBigUint64(o, BigInt(cid.length), false); o += 8;
69
+ out.set(cid, o); o += cid.length;
70
+ dv.setUint32(o, b0.length, false); o += 4;
71
+ out.set(b0, o); o += b0.length;
72
+ dv.setUint32(o, authInfo.length, false); o += 4;
73
+ out.set(authInfo, o);
74
+ return out;
75
+ }
76
+
77
+ function assertVersion(version, fn) {
78
+ if (version !== 'v1' && version !== 'v2') {
79
+ throw new Error(`${fn}: version must be 'v1' or 'v2', got ${JSON.stringify(version)} (resolve it with resolveSignBytesVersion first)`);
80
+ }
81
+ }
82
+
83
+ /** Version-dispatching hybrid sign-bytes. `version` is REQUIRED ('v1' | 'v2'); there is no implicit form. */
84
+ export function hybridSignBytes(version, chainId, b0, authInfo) {
85
+ assertVersion(version, 'hybridSignBytes');
86
+ return version === 'v2' ? hybridSignBytesV2(chainId, b0, authInfo) : hybridSignBytesV1(b0, authInfo);
87
+ }
88
+
89
+ function toHeight(h) {
90
+ if (h === undefined || h === null || h === '') return 0n;
91
+ if (typeof h === 'bigint') return h;
92
+ if (typeof h === 'number') {
93
+ if (!Number.isFinite(h)) throw new Error(`invalid applied height ${h}`);
94
+ return BigInt(Math.trunc(h));
95
+ }
96
+ return BigInt(String(h)); // throws on garbage, never guesses
97
+ }
98
+
99
+ /**
100
+ * The form a client must sign for `chainId`, given the height at which a v2
101
+ * sign-bytes upgrade plan was applied on it (0 / "0" / missing = none applied;
102
+ * pass the greatest height over SIGN_BYTES_V2_UPGRADES). Mirrors the chain's
103
+ * SignBytesVersionFor. Heights are compared NUMERICALLY: the node returns the
104
+ * height as a string, and "0" is truthy.
105
+ */
106
+ export function signBytesVersionFor(chainId, v2AppliedHeight) {
107
+ if (toHeight(v2AppliedHeight) > 0n) return 'v2';
108
+ return LEGACY_SIGN_BYTES_CHAINS.includes(chainId) ? 'v1' : 'v2';
109
+ }
110
+
111
+ const cache = new Map(); // `${rest}|${chainId}` -> { version, at }
112
+
113
+ /** Drop every cached resolver answer. */
114
+ export function clearSignBytesCache() { cache.clear(); }
115
+
116
+ function normRest(rest) { return String(rest).replace(/\/+$/, ''); }
117
+
118
+ /**
119
+ * Resolve the hybrid sign-bytes form for a network.
120
+ * - 'v1' | 'v2' are returned as-is (no network).
121
+ * - 'auto' (default): a chain that is not a legacy network gets 'v2' with no
122
+ * HTTP; a legacy network is asked `GET {rest}/cosmos/upgrade/v1beta1/applied_plan/{name}`
123
+ * for EVERY name in SIGN_BYTES_V2_UPGRADES and signs v2 iff the numeric
124
+ * height of ANY of them is > 0. The names are asked in order and the first
125
+ * positive height wins, so a network on the current release costs one
126
+ * request and one that upgraded under the earlier name costs two. Answers
127
+ * are cached per (rest, chainId) for `ttlMs`; `forceRefresh` bypasses the cache.
128
+ * Throws (never guesses) when a legacy network has no `rest` or a query fails.
129
+ */
130
+ export async function resolveSignBytesVersion({
131
+ chainId, rest, signBytesVersion = 'auto', fetch = globalThis.fetch, ttlMs = 60_000, forceRefresh = false,
132
+ } = {}) {
133
+ const mode = signBytesVersion ?? 'auto';
134
+ if (mode === 'v1' || mode === 'v2') return mode;
135
+ if (mode !== 'auto') {
136
+ throw new Error(`signBytesVersion must be 'auto', 'v1' or 'v2', got ${JSON.stringify(signBytesVersion)}`);
137
+ }
138
+ if (typeof chainId !== 'string' || chainId.length === 0) {
139
+ throw new Error('resolveSignBytesVersion: chainId is required');
140
+ }
141
+ if (!LEGACY_SIGN_BYTES_CHAINS.includes(chainId)) return 'v2';
142
+
143
+ const hint = `pass \`rest\` (the network's LCD URL) or an explicit signBytesVersion 'v1' | 'v2'`;
144
+ if (!rest) {
145
+ throw new Error(`Cannot choose the hybrid sign-bytes form for ${chainId} without asking the network: ${hint}.`);
146
+ }
147
+ const base = normRest(rest);
148
+ const key = `${base}|${chainId}`;
149
+ if (!forceRefresh) {
150
+ const hit = cache.get(key);
151
+ if (hit && Date.now() - hit.at < ttlMs) return hit.version;
152
+ }
153
+ if (typeof fetch !== 'function') {
154
+ throw new Error(`Cannot query ${base}: no fetch implementation available; ${hint}.`);
155
+ }
156
+ const plans = `${base}/cosmos/upgrade/v1beta1/applied_plan/{${SIGN_BYTES_V2_UPGRADES.join(',')}}`;
157
+ let version;
158
+ try {
159
+ // Ask for every plan name; the first positive height decides (v2). Only when
160
+ // ALL of them answer 0 / {} is the network still on v1.
161
+ let applied = 0n;
162
+ for (const name of SIGN_BYTES_V2_UPGRADES) {
163
+ const url = `${base}/cosmos/upgrade/v1beta1/applied_plan/${name}`;
164
+ const res = await fetch(url, { headers: { accept: 'application/json' } });
165
+ if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : 'no response'} for ${name}`);
166
+ const body = await res.json();
167
+ applied = toHeight(body?.height ?? '0');
168
+ if (applied > 0n) break;
169
+ }
170
+ version = signBytesVersionFor(chainId, applied);
171
+ } catch (e) {
172
+ throw new Error(`Cannot determine the hybrid sign-bytes form for ${chainId} from ${plans} (${e && e.message ? e.message : e}); ${hint}.`);
173
+ }
174
+ cache.set(key, { version, at: Date.now() });
175
+ return version;
176
+ }
177
+
178
+ const REJECTION_TEXT = 'hybrid PQC signature verification failed';
179
+
180
+ /**
181
+ * True iff a broadcast failure is the chain refusing the hybrid PQC signature
182
+ * (x/pqc ErrHybridSigInvalid: codespace "pqc", code 21) — the signal that the
183
+ * wrong sign-bytes form was used. Accepts a cosmjs BroadcastTxError
184
+ * ({ code, codespace, log }), a DeliverTxResponse-like ({ code, rawLog }), a
185
+ * plain Error, or a string. Code 21 from another codespace does NOT match.
186
+ */
187
+ export function isHybridSignBytesRejection(errOrResult) {
188
+ if (!errOrResult) return false;
189
+ if (typeof errOrResult === 'string') return errOrResult.includes(REJECTION_TEXT);
190
+ const x = errOrResult;
191
+ if (x.codespace === 'pqc' && Number(x.code) === 21) return true;
192
+ for (const f of [x.log, x.rawLog, x.raw_log, x.message]) {
193
+ if (typeof f === 'string' && f.includes(REJECTION_TEXT)) return true;
194
+ }
195
+ return false;
196
+ }
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 is used as the secp256k1 private key, so a caller can derive one
93
- * canonical eth-native account deterministically from any 32 bytes e.g.
94
- * `shake256(phantomSignature)` for the Phantom "connect → 3 addresses" flow, or
95
- * an HKDF/KDF output from another wallet. Same 20-byte identity model as
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
  *