@qorechain/chain-bridge 0.1.1 → 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
@@ -33,6 +33,7 @@ import { ChainBridge } from '@qorechain/chain-bridge';
33
33
  const bridge = new ChainBridge({
34
34
  cosmosRpc: process.env.QORE_RPC, // http://node:26657
35
35
  chainId: process.env.QORE_CHAIN_ID, // qorechain-diana
36
+ cosmosRest: process.env.QORE_LCD, // http://node:1317 — picks the PQC sign-bytes form
36
37
  evmRpc: process.env.QORE_EVM_RPC, // http://node:8545
37
38
  evmChainId: 9800,
38
39
  authorityMnemonic: process.env.LICENSE_AUTHORITY_MNEMONIC, // genesis license.authority
@@ -101,6 +102,38 @@ So the same mnemonic yields the **same** PQC key in every tool — recover it wi
101
102
  > and falls back to it automatically when a canonical signature is rejected.
102
103
  > Recover a legacy key with `recover-key … --derivation bridge`.
103
104
 
105
+ ## Hybrid sign-bytes form (v1 / v2, chain v3.2.0 / testnet v3.1.98)
106
+
107
+ The ML-DSA-87 signature covers one of two byte forms, and a network accepts
108
+ exactly one at any height:
109
+
110
+ ```
111
+ v1 (legacy): BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A
112
+ v2: "qorechain-pqc-hybrid-v2" ‖ BE64(len chainId) ‖ chainId ‖ BE32(len B0) ‖ B0 ‖ BE32(len A) ‖ A
113
+ ```
114
+
115
+ `qorechain-vladi` (mainnet) and `qorechain-diana` (testnet) verify v1 until the
116
+ upgrade that carries the switch is applied on them and v2 afterwards; they upgrade
117
+ at different heights (testnet is on v2; **mainnet stays on v1 until its own
118
+ upgrade**). Any other chain verifies v2 from its first block.
119
+
120
+ **Two plan names carry the same switch:** the release is `v3.2.0`, which is the
121
+ name mainnet applies, while the testnet already applied it as `v3.1.98` and keeps
122
+ that record. Either one applied means the network verifies v2, so the resolver
123
+ asks for both.
124
+
125
+ - `signBytesVersion: 'auto'` (default) asks `cosmosRest`
126
+ (`/cosmos/upgrade/v1beta1/applied_plan/v3.2.0`, then `/applied_plan/v3.1.98`;
127
+ v2 iff the numeric height of either is > 0, stopping at the first positive one)
128
+ and caches the answer for 60 s. On those two networks **`cosmosRest` is
129
+ required** — without it, or if a query fails, hybrid calls throw instead of guessing.
130
+ - `signBytesVersion: 'v1' | 'v2'` pins the form (no query, no retry).
131
+ - With `'auto'`, a refusal with `pqc` code 21 ("hybrid PQC signature verification
132
+ failed") re-resolves once and, if the network's answer changed, re-signs and
133
+ broadcasts once more. Only then does the legacy-key fallback (see the legacy note above) apply.
134
+
135
+ Hybrid results carry `signBytesVersion`, the form used.
136
+
104
137
  ## Decimals
105
138
 
106
139
  - Cosmos lane (`uqor`): 6 decimals. `sendTokens` amounts are in `uqor`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qorechain/chain-bridge",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Server-side, autonomous bridge between Web2 backends (dashboards, exchanges) and the QoreChain on-chain modules. Grants licenses on-chain (PQC-signed), deploys contracts on the 3 VMs, registers light nodes, and moves QOR — all from a backend, no CLI.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -15,7 +15,7 @@
15
15
  "README.md"
16
16
  ],
17
17
  "scripts": {
18
- "test": "node --test test/"
18
+ "test": "node --test"
19
19
  },
20
20
  "engines": {
21
21
  "node": ">=18"
@@ -24,7 +24,7 @@
24
24
  "@cosmjs/proto-signing": "^0.32.0",
25
25
  "@cosmjs/stargate": "^0.32.0",
26
26
  "@qorechain/pqc": "^0.1.1",
27
- "@qorechain/wallet-adapter": "^0.1.4",
27
+ "@qorechain/wallet-adapter": "^0.2.1",
28
28
  "cosmjs-types": "^0.9.0",
29
29
  "ethers": "^6.13.0"
30
30
  },
package/src/cosmos.js CHANGED
@@ -20,7 +20,8 @@ import { TxBody, AuthInfo, TxRaw, SignerInfo, ModeInfo, Fee } from 'cosmjs-types
20
20
  import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing.js';
21
21
  import { PubKey } from 'cosmjs-types/cosmos/crypto/secp256k1/keys.js';
22
22
  import {
23
- frame, encodePqcHybridSignature, HYBRID_SIG_TYPE_URL, ALGORITHM_ML_DSA_87,
23
+ hybridSignBytes, resolveSignBytesVersion, isHybridSignBytesRejection,
24
+ encodePqcHybridSignature, HYBRID_SIG_TYPE_URL, ALGORITHM_ML_DSA_87,
24
25
  walletFromMnemonic, signClassicalEth, signHybridEth,
25
26
  } from '@qorechain/wallet-adapter';
26
27
  import { mldsa, shake256 } from '@qorechain/pqc';
@@ -61,9 +62,12 @@ export async function signerContext(mnemonic, prefix = 'qor', { keyType = 'secp2
61
62
  const pqc = mldsa.keygen(shake256(new TextEncoder().encode(`qorechain:pqc:v1|${acct.address}|${mnemonic}`), 32));
62
63
  // LEGACY fallback: pre-fix chain-bridge derived coin-118 PQC keys as
63
64
  // shake256(mnemonic) (no address binding). Accounts registered that way still
64
- // hold that key on-chain, and the chain cannot rotate a key within the same
65
- // algorithm so keep the legacy key available; _submitHybridAuto falls back to
66
- // it when the canonical signature is rejected.
65
+ // hold that key on-chain, so keep the legacy key available; _submitHybridAuto
66
+ // falls back to it when the canonical signature is rejected (code 21).
67
+ // NOTE: since chain v3.1.85 such an account CAN be migrated to the canonical
68
+ // key once, via MsgRotatePQCKey (tx pqc rotate-key / the wallet-adapter's
69
+ // rotatePqcKeyMsgFromMnemonic). After that the canonical signature matches and
70
+ // this fallback is never taken — the fallback stays only for un-rotated accounts.
67
71
  const pqcLegacy = mldsa.keygen(shake256(new TextEncoder().encode(mnemonic), 32));
68
72
  return { wallet, address: acct.address, pubkeySecp256k1: acct.pubkey, pqc, pqcLegacy };
69
73
  }
@@ -97,6 +101,7 @@ function assertOk(res, what) {
97
101
  const err = new Error(`${what} failed: code ${res.code}: ${res.rawLog || ''}`);
98
102
  err.code = res.code;
99
103
  err.rawLog = res.rawLog;
104
+ if (res.codespace) err.codespace = res.codespace;
100
105
  err.txHash = res.transactionHash;
101
106
  throw err;
102
107
  }
@@ -118,26 +123,65 @@ export function findEventAttr(events, eventType, attrKey) {
118
123
  return undefined;
119
124
  }
120
125
 
126
+ // Sign-and-broadcast with the per-network sign-bytes form and the single retry.
127
+ //
128
+ // `sign(version)` builds TxRaw bytes for 'v1' | 'v2'; `broadcast(txBytes)` sends
129
+ // them and returns a result or throws. The form is resolved with the wallet
130
+ // adapter's resolveSignBytesVersion (explicit 'v1'/'v2' as given; 'auto' asks
131
+ // `rest`). When the form was 'auto' and the chain refuses the hybrid signature
132
+ // (codespace "pqc", code 21), the resolver is force-refreshed and, if the answer
133
+ // changed, the tx is re-signed and broadcast ONCE more; any other outcome is
134
+ // surfaced. Errors and results carry `signBytesVersion`.
135
+ export async function broadcastHybridWithRetry({ chainId, rest, signBytesVersion = 'auto', fetch, sign, broadcast }) {
136
+ const mode = signBytesVersion ?? 'auto';
137
+ const resolveOpts = { chainId, rest, signBytesVersion: mode, ...(fetch ? { fetch } : {}) };
138
+ const version = await resolveSignBytesVersion(resolveOpts);
139
+ const tag = (x, v) => { if (x && typeof x === 'object') x.signBytesVersion = v; return x; };
140
+ try {
141
+ return tag(await broadcast(await sign(version)), version);
142
+ } catch (err) {
143
+ tag(err, version);
144
+ if (mode !== 'auto' || !isHybridSignBytesRejection(err)) throw err;
145
+ const fresh = await resolveSignBytesVersion({ ...resolveOpts, forceRefresh: true });
146
+ if (fresh === version) throw err; // the form was not the problem
147
+ try {
148
+ return tag(await broadcast(await sign(fresh)), fresh);
149
+ } catch (err2) {
150
+ throw tag(err2, fresh);
151
+ }
152
+ }
153
+ }
154
+
121
155
  // Hybrid (PQC + classical) broadcast. messages: [{ typeUrl, value:Uint8Array }].
122
156
  // Mirrors the chain's `tx pqc cosign`:
123
157
  // B0 = TxBody{messages, memo, timeoutHeight} (no extension)
124
- // sigP = ML-DSA-87(deterministic).sign( frame(B0, authInfo) )
158
+ // v = 'v1' | 'v2' — the sign-bytes form this network verifies (see above)
159
+ // sigP = ML-DSA-87(deterministic).sign( hybridSignBytes(v, chainId, B0, authInfo) )
125
160
  // body = TxBody{...B0, extensionOptions:[PQCHybridSignature{1, sigP}]}
126
161
  // sigC = wallet.signDirect( SignDoc{body, authInfo, chainId, accountNumber} )
127
162
  // tx = TxRaw{ body, authInfo, [sigC] }
128
- export async function submitHybrid({ ctx, rpc, chainId, messages, gasLimit, gasPrice, memo = '', what = 'tx' }) {
163
+ // `rest` (LCD URL) is required for signBytesVersion 'auto' on qorechain-vladi /
164
+ // qorechain-diana; without it (and without an explicit version) this throws.
165
+ export async function submitHybrid({
166
+ ctx, rpc, chainId, messages, gasLimit, gasPrice, memo = '', what = 'tx',
167
+ rest, signBytesVersion = 'auto', fetch,
168
+ }) {
129
169
  return withClient(rpc, async (client) => {
130
170
  const acct = await client.getAccount(ctx.address);
131
171
  if (!acct) throw new Error(`account ${ctx.address} not found on-chain — fund it first`);
172
+ const broadcast = async (txBytes) => assertOk(await client.broadcastTx(txBytes), what);
132
173
 
133
174
  // eth-native path: delegate the whole build+sign to the wallet SDK (keccak
134
175
  // secp256k1 + ethsecp256k1 pubkey + ML-DSA-87 hybrid), then broadcast.
135
176
  if (ctx.ethNative) {
136
- const txBytes = await signHybridEth({
137
- key: ctx.ethKey, chainId, accountNumber: acct.accountNumber,
138
- messages, fee: feeFor(gasLimit, gasPrice), sequence: acct.sequence, memo,
177
+ return broadcastHybridWithRetry({
178
+ chainId, rest, signBytesVersion, fetch, broadcast,
179
+ sign: (version) => signHybridEth({
180
+ key: ctx.ethKey, chainId, accountNumber: acct.accountNumber,
181
+ messages, fee: feeFor(gasLimit, gasPrice), sequence: acct.sequence, memo,
182
+ signBytesVersion: version,
183
+ }),
139
184
  });
140
- return assertOk(await client.broadcastTx(txBytes), what);
141
185
  }
142
186
 
143
187
  const pubAny = {
@@ -153,23 +197,24 @@ export async function submitHybrid({ ctx, rpc, chainId, messages, gasLimit, gasP
153
197
  fee: Fee.fromPartial(feeFor(gasLimit, gasPrice)),
154
198
  });
155
199
  const authInfoBytes = AuthInfo.encode(authInfo).finish();
156
-
157
200
  const b0 = TxBody.encode(TxBody.fromPartial({ messages, memo, timeoutHeight: 0n })).finish();
158
- // @qorechain/pqc >=0.1.1 signs deterministically (FIPS-204 §3.4) by default,
159
- // as QoreChain's PQC ante verifier requires.
160
- const pqcSig = mldsa.sign(ctx.pqc.secretKey, frame(b0, authInfoBytes));
161
- const bodyBytes = TxBody.encode(TxBody.fromPartial({
162
- messages, memo, timeoutHeight: 0n,
163
- extensionOptions: [{ typeUrl: HYBRID_SIG_TYPE_URL, value: encodePqcHybridSignature(ALGORITHM_ML_DSA_87, pqcSig) }],
164
- })).finish();
165
201
 
166
- const { signature } = await ctx.wallet.signDirect(ctx.address, {
167
- bodyBytes, authInfoBytes, chainId, accountNumber: BigInt(acct.accountNumber),
168
- });
169
- const classicalSig = typeof signature.signature === 'string'
170
- ? Uint8Array.from(Buffer.from(signature.signature, 'base64')) : signature.signature;
171
- const txBytes = TxRaw.encode(TxRaw.fromPartial({ bodyBytes, authInfoBytes, signatures: [classicalSig] })).finish();
172
- return assertOk(await client.broadcastTx(txBytes), what);
202
+ const sign = async (version) => {
203
+ // @qorechain/pqc >=0.1.1 signs deterministically (FIPS-204 §3.4) by default,
204
+ // as QoreChain's PQC ante verifier requires.
205
+ const pqcSig = mldsa.sign(ctx.pqc.secretKey, hybridSignBytes(version, chainId, b0, authInfoBytes));
206
+ const bodyBytes = TxBody.encode(TxBody.fromPartial({
207
+ messages, memo, timeoutHeight: 0n,
208
+ extensionOptions: [{ typeUrl: HYBRID_SIG_TYPE_URL, value: encodePqcHybridSignature(ALGORITHM_ML_DSA_87, pqcSig) }],
209
+ })).finish();
210
+ const { signature } = await ctx.wallet.signDirect(ctx.address, {
211
+ bodyBytes, authInfoBytes, chainId, accountNumber: BigInt(acct.accountNumber),
212
+ });
213
+ const classicalSig = typeof signature.signature === 'string'
214
+ ? Uint8Array.from(Buffer.from(signature.signature, 'base64')) : signature.signature;
215
+ return TxRaw.encode(TxRaw.fromPartial({ bodyBytes, authInfoBytes, signatures: [classicalSig] })).finish();
216
+ };
217
+ return broadcastHybridWithRetry({ chainId, rest, signBytesVersion, fetch, sign, broadcast });
173
218
  });
174
219
  }
175
220
 
package/src/index.d.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  export interface ChainBridgeConfig {
4
4
  cosmosRpc: string; // e.g. http://node:26657
5
5
  chainId: string; // e.g. qorechain-diana
6
- cosmosRest?: string; // optional REST/LCD (:1317)
6
+ cosmosRest?: string; // REST/LCD (:1317); required on qorechain-vladi / qorechain-diana for signBytesVersion 'auto'
7
+ signBytesVersion?: 'auto' | 'v1' | 'v2'; // hybrid PQC sign-bytes form, default 'auto' (asks cosmosRest)
8
+ fetch?: typeof globalThis.fetch; // optional fetch used by the sign-bytes resolver
7
9
  evmRpc?: string; // EVM JSON-RPC (:8545)
8
10
  evmChainId?: number; // 9800 testnet / 9801 mainnet
9
11
  prefix?: string; // bech32 prefix, default 'qor'
@@ -20,6 +22,7 @@ export interface TxResult {
20
22
  code: number;
21
23
  height?: number;
22
24
  gasUsed?: bigint;
25
+ signBytesVersion?: 'v1' | 'v2'; // hybrid sign-bytes form used (hybrid txs only)
23
26
  }
24
27
 
25
28
  export interface GrantSetResult {
package/src/index.js CHANGED
@@ -106,6 +106,10 @@ export class ChainBridge {
106
106
  const args = {
107
107
  ctx, rpc: this.cfg.cosmosRpc, chainId: this.cfg.chainId,
108
108
  messages, gasLimit, gasPrice: this.cfg.gasPrice, memo, what,
109
+ // Sign-bytes form (v1/v2) per network: 'auto' asks cosmosRest; submitHybrid
110
+ // already re-resolves and retries once on a sign-bytes refusal.
111
+ rest: this.cfg.cosmosRest, signBytesVersion: this.cfg.signBytesVersion || 'auto',
112
+ ...(this.cfg.fetch ? { fetch: this.cfg.fetch } : {}),
109
113
  };
110
114
  try {
111
115
  return await submitHybrid(args);
@@ -113,7 +117,9 @@ export class ChainBridge {
113
117
  const log = String(err.rawLog || err.message || '');
114
118
  // (a) Legacy-derivation account: its on-chain PQC key was registered with the
115
119
  // old shake256(mnemonic) derivation, so the canonical signature is rejected
116
- // (code 21). Retry with the legacy key. Checked FIRST PQC_MISSING is broad
120
+ // (code 21). Retry with the legacy key. (A wrong sign-bytes form also yields
121
+ // code 21, but submitHybrid has already re-resolved the form and retried
122
+ // once, so reaching here means the form was right and the key is not.) Checked FIRST — PQC_MISSING is broad
117
123
  // and would otherwise also match this "…verification failed" message.
118
124
  if (ctx.pqcLegacy && !ctx.usedLegacy && /verification failed/i.test(log)) {
119
125
  return submitHybrid({ ...args, ctx: { ...ctx, pqc: ctx.pqcLegacy, usedLegacy: true } });