diff --git a/packages/extension-base/package.json b/packages/extension-base/package.json index b47b6328..712baa56 100644 --- a/packages/extension-base/package.json +++ b/packages/extension-base/package.json @@ -34,7 +34,7 @@ "@polkadot/ui-settings": "^3.16.7", "@polkadot/util": "^14.0.3", "@polkadot/util-crypto": "^14.0.3", - "@quantus/codec": "^0.2.0", + "@quantus/codec": "^0.4.0", "@quantus/crypto": "^0.1.1", "eventemitter3": "^5.0.1", "rxjs": "^7.8.1", diff --git a/packages/extension-chains/package.json b/packages/extension-chains/package.json index aeb13a0e..209d0acb 100644 --- a/packages/extension-chains/package.json +++ b/packages/extension-chains/package.json @@ -25,7 +25,7 @@ "@polkadot/networks": "^14.0.3", "@polkadot/util": "^14.0.3", "@polkadot/util-crypto": "^14.0.3", - "@quantus/codec": "^0.2.0", + "@quantus/codec": "^0.4.0", "tslib": "^2.8.1" }, "peerDependencies": { diff --git a/scripts/tier1/cases.mjs b/scripts/tier1/cases.mjs new file mode 100644 index 00000000..2c30e91a --- /dev/null +++ b/scripts/tier1/cases.mjs @@ -0,0 +1,151 @@ +// Copyright 2019-2026 @polkadot/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// quantus/extension#7 tier 1, the cases that prove the plumbing rather than the +// happy path. `submit.mjs` shows one transfer working; this asks the chain the +// questions whose answers cannot be checked locally. +// +// 1. the empty signing context is REJECTED +// 2. a payload over 256 bytes is signed as its BLAKE2b hash and dispatches +// 3. an ML-DSA-65 account signs and is accepted (submit.mjs covers 87) +// 4. an account exported to JSON and re-imported still signs +// +// The first is the important one. Every other failure here shows up as a decode +// error somewhere; a signature under the wrong context is well-formed, verifies +// against its own key, and is refused only by the runtime. + +import { contextForSpec } from '@quantus/crypto'; + +import { Keyring } from '@polkadot/keyring'; +import { u8aToHex } from '@polkadot/util'; +import { cryptoWaitReady, mnemonicGenerate } from '@polkadot/util-crypto'; + +import { build, connect, freeBalance, nextNonce, submitAndInclude, transfer } from './chain.mjs'; + +const ENDPOINT = process.env.QUANTUS_WS || 'wss://a1-heisenberg.quantus.cat'; +const ALICE_SEED = `0x${'00'.repeat(32)}`; +const AMOUNT = 1_000_000_000n; // 0.001 +// Far more than the ~0.008 a 5.3 KiB ML-DSA-65 extrinsic costs, and comfortably +// over the existential deposit. +const FUNDING = 50_000_000_000_000n; + +let failures = 0; + +function report (name, ok, detail) { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); + + if (!ok) { + failures++; + } +} + +await cryptoWaitReady(); + +const chain = await connect(ENDPOINT); +const { runtime, specVersion } = chain; +const context = contextForSpec(specVersion); + +console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`); +console.log(`context ${new TextDecoder().decode(context)}`); + +const keyring = new Keyring({ ss58Format: 189, type: 'dilithium87' }); +const alice = keyring.createFromUri(ALICE_SEED, { name: 'crystal_alice' }, 'dilithium87'); +const fresh = keyring.createFromUri(mnemonicGenerate(12), { name: 'tier1-65' }, 'dilithium65'); + +console.log(`alice ${alice.address}`); +console.log(`ml-dsa-65 ${fresh.address}`); +console.log(''); + +// --- 1. the empty signing context is rejected ------------------------------ +// No block needed: the node verifies the signature before accepting into the +// pool, which is exactly why this is worth asserting. +{ + const nonce = await nextNonce(chain, alice.address); + const { hex } = build(chain, alice, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: new Uint8Array() }); + + try { + await chain.rpc('author_submitExtrinsic', [hex]); + report('the empty signing context is rejected', false, 'the chain ACCEPTED it'); + } catch (error) { + report('the empty signing context is rejected', /bad signature/i.test(error.message), error.message); + } +} + +// --- 2. a payload past the hashing threshold, which also funds the 65 account +{ + const nonce = await nextNonce(chain, alice.address); + const send = (to, value) => ({ + Balances: { transfer_keep_alive: { dest: { Id: u8aToHex(to) }, value: value.toString() } } + }); + // Five transfers in one batch_all, to push the signing payload over 256 bytes + // so the BLAKE2b branch is the one that runs. A call nested inside a call also + // exercises the codec at a depth a plain transfer never reaches. + const call = runtime.encodeCall('Utility', 'batch_all', { + calls: [ + send(fresh.addressRaw, FUNDING), + send(alice.addressRaw, AMOUNT), + send(alice.addressRaw, AMOUNT), + send(alice.addressRaw, AMOUNT), + send(alice.addressRaw, AMOUNT) + ] + }); + const { hashed, hex, payload } = build(chain, alice, call, nonce, { signingContext: context }); + + report('a payload over 256 bytes is hashed before signing', hashed, `${payload.length} bytes -> blake2 32`); + + try { + const { height } = await submitAndInclude(chain, hex); + // `batch_all` is atomic, so "included" and "did what it said" are different + // claims. The balance is the one that matters. + const funded = await freeBalance(chain, fresh.address); + + report('a batch_all over 256 bytes dispatches', funded === FUNDING, `block ${height}, ${fresh.address} holds ${funded}`); + } catch (error) { + report('a batch_all over 256 bytes dispatches', false, error.message); + } +} + +// --- 3. an ML-DSA-65 account signs ----------------------------------------- +{ + const nonce = await nextNonce(chain, fresh.address); + const { hex } = build(chain, fresh, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context }); + + try { + const before = await freeBalance(chain, alice.address); + const { height } = await submitAndInclude(chain, hex); + const moved = await freeBalance(chain, alice.address) - before; + + report('an ML-DSA-65 account signs and is accepted', moved === AMOUNT, `block ${height}, moved ${moved}`); + } catch (error) { + report('an ML-DSA-65 account signs and is accepted', false, error.message); + } +} + +// --- 4. exported to JSON, re-imported, still signs ------------------------- +{ + const password = 'tier1-round-trip'; + const json = fresh.toJson(password); + const reimported = keyring.createFromJson(json); + + reimported.decodePkcs8(password); + + const sameAddress = reimported.address === fresh.address; + const nonce = await nextNonce(chain, reimported.address); + const { hex } = build(chain, reimported, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context }); + + try { + const before = await freeBalance(chain, alice.address); + const { height } = await submitAndInclude(chain, hex); + const moved = await freeBalance(chain, alice.address) - before; + + report('an account exported to JSON and re-imported still signs', sameAddress && moved === AMOUNT, `block ${height}, address preserved=${sameAddress}, moved ${moved}`); + } catch (error) { + report('an account exported to JSON and re-imported still signs', false, error.message); + } +} + +await chain.disconnect(); + +console.log(''); +console.log(failures ? `${failures} case(s) failed` : 'all cases passed'); +process.exit(failures ? 1 : 0); diff --git a/scripts/tier1/chain.mjs b/scripts/tier1/chain.mjs new file mode 100644 index 00000000..b0738a55 --- /dev/null +++ b/scripts/tier1/chain.mjs @@ -0,0 +1,151 @@ +// Copyright 2019-2026 @polkadot/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// The plumbing shared by the tier-1 scripts: a JSON-RPC transport, a runtime +// loaded from the chain's own metadata, and the three operations that need to be +// right — build an extrinsic, wait for it to be *included*, and read a balance. +// +// Nothing here uses @polkadot/api. WsProvider is a JSON-RPC client and decodes +// nothing; @quantus/codec produces and reads every byte. See quantus/api#1. + +import { Runtime } from '@quantus/codec'; + +import { WsProvider } from '@polkadot/rpc-provider'; +import { hexToU8a, u8aToHex } from '@polkadot/util'; +import { blake2AsU8a, decodeAddress } from '@polkadot/util-crypto'; + +/** + * Substrate's own rule, from `unchecked_extrinsic.rs`: a signing payload longer + * than 256 bytes is signed as its BLAKE2b-256 hash, otherwise as-is. + */ +export const HASH_ABOVE = 256; + +export async function connect (endpoint) { + const provider = new WsProvider(endpoint); + + await provider.isReady; + + const rpc = (method, params = []) => provider.send(method, params); + const [version, genesisHash, metadataHex] = await Promise.all([ + rpc('state_getRuntimeVersion'), + rpc('chain_getBlockHash', ['0x0']), + rpc('state_getMetadata') + ]); + const runtime = Runtime.fromMetadata(hexToU8a(metadataHex)); + + return { + disconnect: () => provider.disconnect(), + genesisHash, + rawMetadata: metadataHex, + rpc, + runtime, + specName: version.specName, + specVersion: version.specVersion, + transactionVersion: version.transactionVersion + }; +} + +/** + * Build, sign and encode one extrinsic, with an immortal era. + * + * `signingContext` is exposed so a caller can get it deliberately wrong — that + * is a case worth proving, because a signature under the wrong FIPS 204 context + * is well-formed, verifies against its own key, and is refused only by the chain. + */ +export function build (chain, pair, call, nonce, { signingContext } = {}) { + const { genesisHash, runtime, specVersion, transactionVersion } = chain; + const values = runtime.standardExtensions({ + blockHash: genesisHash, + eraHex: '0x00', + genesisHash, + nonce, + specVersion, + transactionVersion + }); + const extra = runtime.encodeExtra(values); + const payload = runtime.signerPayload(call, values); + const toSign = payload.length > HASH_ABOVE ? blake2AsU8a(payload) : payload; + const signature = pair.sign(toSign, { context: signingContext, withType: true }); + + return { + extra, + hashed: payload.length > HASH_ABOVE, + hex: u8aToHex(runtime.encodeExtrinsic({ Id: u8aToHex(pair.addressRaw) }, signature, extra, call)), + payload, + signature + }; +} + +/** + * Submit, and wait until the extrinsic is in a block. + * + * **Not** by watching the sender's nonce. `system_accountNextIndex` counts + * pending pool transactions, so it advances the moment a transaction is + * accepted — which is before it has done anything. Waiting on it and then + * reading a balance shows the state from before the dispatch, and the result is + * a working transfer that reports itself as having moved nothing. That cost a + * confusing run: two `batch_all` calls were recorded as reverted when both had + * funded their target with exactly what they were asked to. + * + * So this looks for the extrinsic's own bytes in the blocks that arrive, which + * also exercises the decoder against real chain data — every Quantus block opens + * with a bare-v5 timestamp inherent that `@polkadot/api` cannot read at all. + */ +export async function submitAndInclude (chain, hex, { timeoutMs = 10 * 60 * 1000 } = {}) { + const { rpc } = chain; + + await rpc('author_submitExtrinsic', [hex]); + + const deadline = Date.now() + timeoutMs; + + let height = parseInt((await rpc('chain_getHeader')).number, 16); + + while (Date.now() < deadline) { + const head = parseInt((await rpc('chain_getHeader')).number, 16); + + while (height <= head) { + const blockHash = await rpc('chain_getBlockHash', [`0x${height.toString(16)}`]); + const block = await rpc('chain_getBlock', [blockHash]); + const index = block.block.extrinsics.indexOf(hex); + + if (index !== -1) { + return { blockHash, height, index }; + } + + height++; + } + + await new Promise((resolve) => setTimeout(resolve, 6_000)); + } + + throw new Error('accepted into the pool but not included before the deadline'); +} + +/** An account's free balance, from `System::Account`. */ +export async function freeBalance (chain, address) { + const { rpc, runtime } = chain; + const target = runtime.storageTarget('System', 'Account', [u8aToHex(decodeAddress(address))]); + const raw = await rpc('state_getStorage', [target.key]); + // `null` from the node means the entry is unset, and for a `Default` entry + // that means the declared default — an account nobody has funded reads as a + // zero balance, not as an error. + const account = runtime.decodeStorage(target.valueTy, hexToU8a(raw ?? target.default)); + + return BigInt(account.data.free); +} + +/** + * The next usable nonce, pending pool transactions included — which is what a + * signer wants, and is exactly not what an inclusion check wants. + */ +export async function nextNonce (chain, address) { + return parseInt(await chain.rpc('system_accountNextIndex', [address]), 10); +} + +/** A `Balances.transfer_keep_alive` call. */ +export function transfer (chain, to, value) { + return chain.runtime.encodeCall('Balances', 'transfer_keep_alive', { + dest: { Id: u8aToHex(decodeAddress(to)) }, + value: value.toString() + }); +} diff --git a/scripts/tier1/submit.mjs b/scripts/tier1/submit.mjs index 455a9007..c6cf6e7e 100644 --- a/scripts/tier1/submit.mjs +++ b/scripts/tier1/submit.mjs @@ -4,28 +4,28 @@ // quantus/extension#7 tier 1: sign a real extrinsic with the forked keyring and // submit it to a real node. No browser, no extension, no dapp. // -// Nothing here uses @polkadot/api. Its codec cannot read or write this chain — -// quantus/api#1 has the evidence — so every byte on the wire is produced by +// This is the happy path, printed at every step. `cases.mjs` next to it is the +// set that proves the plumbing — the empty signing context being rejected, a +// payload past the BLAKE2b threshold, an ML-DSA-65 account, a JSON round trip. +// +// Nothing here uses @polkadot/api. Its codec can neither read nor write this +// chain — quantus/api#1 has the evidence — so every byte is produced by // @quantus/codec against metadata the node generated by running -// `Metadata_metadata` against the runtime WASM. `WsProvider` appears only as a -// JSON-RPC transport; no type in this file is decoded by it. +// `Metadata_metadata` against the runtime WASM. -import { Runtime } from '@quantus/codec'; import { contextForSpec } from '@quantus/crypto'; import { Keyring } from '@polkadot/keyring'; -import { WsProvider } from '@polkadot/rpc-provider'; import { u8aToHex } from '@polkadot/util'; -import { blake2AsU8a, cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto'; +import { cryptoWaitReady } from '@polkadot/util-crypto'; + +import { build, connect, freeBalance, nextNonce, submitAndInclude, transfer } from './chain.mjs'; const ENDPOINT = process.env.QUANTUS_WS || 'wss://a1-heisenberg.quantus.cat'; // crystal_alice: seed = 32 zero bytes, ML-DSA-87. Published in the chain's own // source, funded at dev genesis, and assumed compromised by everyone. const SEED = `0x${'00'.repeat(32)}`; const TYPE = 'dilithium87'; -// Substrate's own rule, from `unchecked_extrinsic.rs`: a signing payload longer -// than 256 bytes is signed as its BLAKE2b-256 hash, otherwise as-is. -const HASH_ABOVE = 256; await cryptoWaitReady(); @@ -34,140 +34,65 @@ const pair = keyring.createFromUri(SEED, { name: 'crystal_alice' }, TYPE); console.log(`signer ${pair.address}`); -const provider = new WsProvider(ENDPOINT); +const chain = await connect(ENDPOINT); +const { genesisHash, runtime, specVersion } = chain; -await provider.isReady; - -const [version, genesisHash, metadataHex] = await Promise.all([ - provider.send('state_getRuntimeVersion', []), - provider.send('chain_getBlockHash', ['0x0']), - provider.send('state_getMetadata', []) -]); - -const specVersion = version.specVersion; -const transactionVersion = version.transactionVersion; -const runtime = Runtime.fromMetadata(Uint8Array.from(Buffer.from(metadataHex.slice(2), 'hex'))); - -console.log(`chain ${version.specName} spec ${specVersion} tx ${transactionVersion}`); +console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`); console.log(`genesis ${genesisHash}`); console.log(`extrinsic v${runtime.extrinsicVersion}`); const dest = process.env.QUANTUS_DEST || 'qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y'; // crystal_bob const amount = 1_000_000_000n; // 0.001 -const destId = u8aToHex(decodeAddress(dest)); -const nonce = parseInt(await provider.send('system_accountNextIndex', [pair.address]), 10); +const nonce = await nextNonce(chain, pair.address); +const before = await freeBalance(chain, dest); console.log(`recipient ${dest}`); +console.log(`before ${before}`); console.log(`nonce ${nonce}`); -const call = runtime.encodeCall('Balances', 'transfer_keep_alive', { - dest: { Id: destId }, - value: amount.toString() -}); +const call = transfer(chain, dest, amount); console.log(`call ${u8aToHex(call)}`); -// An immortal era, so `CheckMortality`'s implicit is the genesis hash and there -// is no birth block to agree with the node about. Mortality is a hardening step, -// not part of proving the fork can spend. -const values = runtime.standardExtensions({ - blockHash: genesisHash, - genesisHash, - nonce, - specVersion, - transactionVersion -}); - -// Every extension the runtime declares as non-empty and `standardExtensions` +// Every extension the runtime declares as non-empty that `standardExtensions` // does not cover. Reported rather than skipped: skipping is the polkadot-js // failure mode this whole package exists to avoid. const unmet = runtime .signedExtensions() - .filter((e) => (e.needsExtra || e.needsAdditional) && !values[e.identifier]); + .filter((e) => (e.needsExtra || e.needsAdditional) && !['ChargeTransactionPayment', 'CheckGenesis', 'CheckMetadataHash', 'CheckMortality', 'CheckNonce', 'CheckSpecVersion', 'CheckTxVersion'].includes(e.identifier)); if (unmet.length) { throw new Error(`runtime declares extensions this harness cannot fill: ${unmet.map((e) => e.identifier).join(', ')}`); } -const extra = runtime.encodeExtra(values); -const payload = runtime.signerPayload(call, values); -const toSign = payload.length > HASH_ABOVE ? blake2AsU8a(payload) : payload; const context = contextForSpec(specVersion); +const { extra, hashed, hex, payload, signature } = build(chain, pair, call, nonce, { signingContext: context }); console.log(`extra ${u8aToHex(extra)}`); -console.log(`payload ${payload.length} bytes -> signing ${toSign.length}`); +console.log(`payload ${payload.length} bytes -> signing ${hashed ? '32 (blake2)' : payload.length}`); console.log(`context ${context.length ? new TextDecoder().decode(context) : '(empty)'}`); - -const signature = pair.sign(toSign, { context, withType: true }); - console.log(`signature ${signature.length} bytes, variant 0x${signature[0].toString(16).padStart(2, '0')}`); - -const extrinsic = runtime.encodeExtrinsic({ Id: u8aToHex(pair.addressRaw) }, signature, extra, call); - -console.log(`extrinsic ${extrinsic.length} bytes`); +console.log(`extrinsic ${(hex.length - 2) / 2} bytes`); // Decoding what we are about to send, with the same runtime that built it. A -// round trip that fails here is a bug in this package; one that succeeds and is -// still rejected is a disagreement with the chain, and the two are worth telling -// apart before the node is involved. -const readBack = runtime.decodeExtrinsic(extrinsic); +// round trip that fails here is a bug in the codec; one that succeeds and is +// still rejected is a disagreement with the chain, and telling those apart is +// most of the work. +const readBack = runtime.decodeExtrinsic(Uint8Array.from(Buffer.from(hex.slice(2), 'hex'))); console.log(`round trip v${readBack.version} signed=${readBack.signed} ${JSON.stringify(readBack.call)}`); -const hash = await provider.send('author_submitExtrinsic', [u8aToHex(extrinsic)]); +const { blockHash, height, index } = await submitAndInclude(chain, hex); -// Acceptance into the pool already means the signature verified: an extrinsic -// the runtime cannot check is rejected right here, as `1010: Invalid -// Transaction: Transaction has a bad signature`. -console.log(`accepted ${hash}`); +console.log(`included block ${height} index ${index} (${blockHash})`); -// Inclusion, though, is the claim worth making, so wait for the chain to put it -// in a block and then read that block back with the same runtime — which also -// exercises the thing @polkadot/api cannot do at all: every Quantus block opens -// with a timestamp inherent whose preamble byte is `0x05`, bare and version 5, -// while this signed extrinsic is `0x84`, signed and version 4. -const DEADLINE = Date.now() + 10 * 60 * 1000; +const after = await freeBalance(chain, dest); -let height = parseInt((await provider.send('chain_getHeader', [])).number, 16); -let found = null; +console.log(`after ${after}`); +console.log(`delta ${after - before} (expected ${amount})`); -while (!found && Date.now() < DEADLINE) { - const head = parseInt((await provider.send('chain_getHeader', [])).number, 16); +await chain.disconnect(); - while (height <= head && !found) { - const blockHash = await provider.send('chain_getBlockHash', [`0x${height.toString(16)}`]); - const block = await provider.send('chain_getBlock', [blockHash]); - - for (const [index, raw] of block.block.extrinsics.entries()) { - const decoded = runtime.decodeExtrinsic(Uint8Array.from(Buffer.from(raw.slice(2), 'hex'))); - - if (raw === u8aToHex(extrinsic)) { - found = { blockHash, decoded, height, index }; - } - } - - height++; - } - - if (!found) { - await new Promise((resolve) => setTimeout(resolve, 10_000)); - } +if (after - before !== amount) { + throw new Error(`recipient balance moved by ${after - before}, expected ${amount}`); } - -if (!found) { - throw new Error('accepted into the pool but not included within ten minutes'); -} - -console.log(`included block ${found.height} index ${found.index} (${found.blockHash})`); -console.log(`decoded ${JSON.stringify(found.decoded.call)}`); -console.log(`signature ${JSON.stringify(found.decoded.signature).slice(0, 60)}…`); - -const after = parseInt(await provider.send('system_accountNextIndex', [pair.address]), 10); - -console.log(`nonce ${nonce} -> ${after}`); - -if (after !== nonce + 1) { - throw new Error(`nonce did not advance: expected ${nonce + 1}, got ${after}`); -} - -await provider.disconnect(); diff --git a/yarn.lock b/yarn.lock index 3443dfa0..995965ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -982,7 +982,7 @@ __metadata: "@polkadot/ui-settings": "npm:^3.16.7" "@polkadot/util": "npm:^14.0.3" "@polkadot/util-crypto": "npm:^14.0.3" - "@quantus/codec": "npm:^0.2.0" + "@quantus/codec": "npm:^0.4.0" "@quantus/crypto": "npm:^0.1.1" eventemitter3: "npm:^5.0.1" rxjs: "npm:^7.8.1" @@ -998,7 +998,7 @@ __metadata: "@polkadot/networks": "npm:^14.0.3" "@polkadot/util": "npm:^14.0.3" "@polkadot/util-crypto": "npm:^14.0.3" - "@quantus/codec": "npm:^0.2.0" + "@quantus/codec": "npm:^0.4.0" tslib: "npm:^2.8.1" peerDependencies: "@polkadot/api": "*" @@ -1604,13 +1604,13 @@ __metadata: languageName: node linkType: hard -"@quantus/codec@npm:^0.2.0": - version: 0.2.0 - resolution: "@quantus/codec@npm:0.2.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.2.0%2Fcodec-0.2.0.tgz" +"@quantus/codec@npm:^0.4.0": + version: 0.4.0 + resolution: "@quantus/codec@npm:0.4.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.4.0%2Fcodec-0.4.0.tgz" dependencies: fflate: "npm:^0.8.2" tslib: "npm:^2.7.0" - checksum: 10/ef03f65170153ac3f389677e1793dee65803ca8fc03e3e48e3c47885461f79b8bd020052b2fff500437de4b96afaa200045ef88c26f3647f1c04496fc2c22823 + checksum: 10/9327551cf97a5bf0a54d5cf1ef9703efc94e85138f24346d597fe4860570d451a3301db0dc1f7a382bc760491ff3ea823cecf384d07e0559f766bafb9ec19b46 languageName: node linkType: hard