@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 +33 -0
- package/package.json +3 -3
- package/src/cosmos.js +70 -25
- package/src/index.d.ts +4 -1
- package/src/index.js +7 -1
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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; //
|
|
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.
|
|
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 } });
|