diff --git a/apps/protocol/src/main.ts b/apps/protocol/src/main.ts index c432cdf9..4d278502 100644 --- a/apps/protocol/src/main.ts +++ b/apps/protocol/src/main.ts @@ -675,6 +675,7 @@ async function initDirectMode(): Promise { resolveOwner, resolveRootManifest, setResolverAssetHubProvider, + waitForPeopleFinalized, } = resolve; const { terminateSmoldot, onSmoldotFatal } = smoldotMod; @@ -718,6 +719,13 @@ async function initDirectMode(): Promise { onWarmup: async () => { getSmoldot(); await getRelayChain(); + // Warm People in the background so legacy-account auth reads do not race + // a cold parachain warp sync. Not needed for resolution, so do not await. + void waitForPeopleFinalized().catch((err: unknown) => { + log.warn( + `[dot.li protocol] People chain warm failed (retried on demand): ${String(err)}`, + ); + }); }, resolveDotName, resolveOwner, diff --git a/apps/protocol/src/protocol-shared-worker.ts b/apps/protocol/src/protocol-shared-worker.ts index 3289390c..5d00cbc7 100644 --- a/apps/protocol/src/protocol-shared-worker.ts +++ b/apps/protocol/src/protocol-shared-worker.ts @@ -30,6 +30,7 @@ import { resolveRootManifest, setResolverAssetHubProvider, waitForAssetHubFinalized, + waitForPeopleFinalized, } from "@dotli/resolver/resolve"; import { onSmoldotFatal } from "@dotli/resolver/smoldot"; import { m } from "@dotli/metrics/metrics"; @@ -214,6 +215,24 @@ async function presync(): Promise { port.postMessage(readyMsg); } pendingPorts.length = 0; + + // Warm the People chain in the background. Legacy-account auth reads the + // username -> account map on People, and on a cold start that read races + // the parachain warp sync (the source of the intermittent failures). Start + // syncing it now so it is ready by the time auth runs. People is not needed + // for resolution, so this must not gate the ready signal above. + swLog("Warming People chain in background..."); + void waitForPeopleFinalized((msg) => { + swLog(`People warm status: ${msg}`); + }) + .then(() => { + swLog("People chain warmed"); + }) + .catch((err: unknown) => { + swLog( + `People chain warm failed (retried on demand): ${serializeError(err)}`, + ); + }); } catch (err: unknown) { const msg = serializeError(err); swError(`Pre-sync failed: ${msg}`); diff --git a/bun.lock b/bun.lock index 4a61ef18..43bcab8e 100644 --- a/bun.lock +++ b/bun.lock @@ -104,11 +104,11 @@ "@dotli/resolver": "workspace:*", "@dotli/shared": "workspace:*", "@noble/hashes": "^2.2.0", - "@novasamatech/host-api": "0.8.7", - "@novasamatech/host-papp": "0.8.7", + "@novasamatech/host-api": "0.8.8", + "@novasamatech/host-papp": "0.8.8", "@novasamatech/sdk-statement": "^0.6.0", - "@novasamatech/statement-store": "0.8.7", - "@novasamatech/storage-adapter": "0.8.7", + "@novasamatech/statement-store": "0.8.8", + "@novasamatech/storage-adapter": "0.8.8", "@scure/sr25519": "^2.2.0", "neverthrow": "^8.2.0", "polkadot-api": "^2.1.6", @@ -298,9 +298,9 @@ "version": "0.5.0", "dependencies": { "@dotli/shared": "workspace:*", - "@novasamatech/host-api": "0.8.7", - "@novasamatech/host-container": "0.8.7", - "@novasamatech/host-papp": "0.8.7", + "@novasamatech/host-api": "0.8.8", + "@novasamatech/host-container": "0.8.8", + "@novasamatech/host-papp": "0.8.8", "nanoevents": "^9.1.0", }, "devDependencies": { @@ -328,11 +328,11 @@ "@dotli/storage": "workspace:*", "@dotli/truapi-debug": "workspace:*", "@noble/hashes": "^2.2.0", - "@novasamatech/host-api": "0.8.7", - "@novasamatech/host-container": "0.8.7", - "@novasamatech/host-papp": "0.8.7", + "@novasamatech/host-api": "0.8.8", + "@novasamatech/host-container": "0.8.8", + "@novasamatech/host-papp": "0.8.8", "@novasamatech/sdk-statement": "^0.6.0", - "@novasamatech/statement-store": "0.8.7", + "@novasamatech/statement-store": "0.8.8", "@polkadot-api/json-rpc-provider": "^0.2.0", "neverthrow": "^8.2.0", "polkadot-api": "^2.1.6", @@ -696,21 +696,21 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@novasamatech/host-api": ["@novasamatech/host-api@0.8.7", "", { "dependencies": { "@novasamatech/scale": "0.8.7", "nanoevents": "9.1.0", "nanoid": "5.1.11", "neverthrow": "^8.2.0", "scale-ts": "1.6.1" } }, "sha512-Kjvo5Y3Ad8EGDQPfEUnotS9hPl2SOBHNavw2+I8t+wQDPYepSTNIQZxzKOF2iS8JXhHTVuwIiaM1AM/OvfS8+A=="], + "@novasamatech/host-api": ["@novasamatech/host-api@0.8.8", "", { "dependencies": { "@novasamatech/scale": "0.8.8", "nanoevents": "9.1.0", "nanoid": "5.1.11", "neverthrow": "^8.2.0", "scale-ts": "1.6.1" } }, "sha512-dTO5tQGJnyy6SOZaR1Z6+yzH2CmJp8wcZScVF1HHqqeMe1uVWcioyTH6jf9Rw46U01uLaa5unembuNmJIOC2ew=="], - "@novasamatech/host-container": ["@novasamatech/host-container@0.8.7", "", { "dependencies": { "@noble/hashes": "2.2.0", "@novasamatech/host-api": "0.8.7", "@polkadot-api/substrate-client": "^0.7.0", "nanoevents": "9.1.0", "nanoid": "5.1.11", "neverthrow": "^8.2.0", "polkadot-api": ">=2" } }, "sha512-8c+1Dr7HvUxX67Z8TW2z6qb86PAK/54KkEknIz0aQSsN8OJFFVWKnr/QhrZFN0ztmj982atIZvfu3HpfIEcfHQ=="], + "@novasamatech/host-container": ["@novasamatech/host-container@0.8.8", "", { "dependencies": { "@noble/hashes": "2.2.0", "@novasamatech/host-api": "0.8.8", "@polkadot-api/substrate-client": "^0.7.0", "nanoevents": "9.1.0", "nanoid": "5.1.11", "neverthrow": "^8.2.0", "polkadot-api": ">=2" } }, "sha512-Caof2YgqJlr96r5mBFNkYQ7Gpfi6pytJdxj7Zqq1o2np8Gc3LzWz6DHLxmQvx64abu9rqR3m5sapCI8lUZrUSg=="], - "@novasamatech/host-papp": ["@novasamatech/host-papp@0.8.7", "", { "dependencies": { "@noble/ciphers": "2.2.0", "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", "@novasamatech/host-api": "0.8.7", "@novasamatech/scale": "0.8.7", "@novasamatech/statement-store": "0.8.7", "@novasamatech/storage-adapter": "0.8.7", "@polkadot-api/utils": "^0.4.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "nanoevents": "9.1.0", "nanoid": "5.1.11", "neverthrow": "^8.2.0", "polkadot-api": ">=2", "rxjs": "^7.8.2", "scale-ts": "1.6.1" } }, "sha512-og8wsnquiZIU+qJC1zzhGldujT1Jy9kB2psdpf94V2UkTa7BbkqFI9lBdI9n3gO2T7elTnFqf9rs/rrU2s7ZXQ=="], + "@novasamatech/host-papp": ["@novasamatech/host-papp@0.8.8", "", { "dependencies": { "@noble/ciphers": "2.2.0", "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", "@novasamatech/host-api": "0.8.8", "@novasamatech/scale": "0.8.8", "@novasamatech/statement-store": "0.8.8", "@novasamatech/storage-adapter": "0.8.8", "@polkadot-api/utils": "^0.4.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "nanoevents": "9.1.0", "nanoid": "5.1.11", "neverthrow": "^8.2.0", "polkadot-api": ">=2", "rxjs": "^7.8.2", "scale-ts": "1.6.1" } }, "sha512-WNHHP3rkUkAB/6Ddm4zc1wywrwVjRC/2eWWLDol7uz8WKbWZnB8tpmYGFmnWLZQttelpM7kIOp8Kg1Vd//wc/w=="], - "@novasamatech/scale": ["@novasamatech/scale@0.8.7", "", { "dependencies": { "@polkadot-api/utils": "^0.4.0", "scale-ts": "1.6.1" } }, "sha512-t9p5YyXkGzXvUaT2mltgcnTHdt32XB3kodNEhWAty9uFZiVfwLB6PYNRsW/CHUviof7Gqqjgw/LrPZQmys8jdg=="], + "@novasamatech/scale": ["@novasamatech/scale@0.8.8", "", { "dependencies": { "@polkadot-api/utils": "^0.4.0", "scale-ts": "1.6.1" } }, "sha512-cWg4RkrUoysc+q7zQnr2vC+JmcuRLZFft3ZITkq1U6d7wDAYGJjzTYGKLDxIe2HZ+mRhU7otFCz1aaFtBEu98w=="], "@novasamatech/sdk-statement": ["@novasamatech/sdk-statement@0.6.0", "", { "dependencies": { "@polkadot-api/substrate-bindings": "0.20.1", "@polkadot-api/utils": "^0.4.0", "polkadot-api": "^2.0.2" } }, "sha512-NTqM+yS45iHgy87lVSWIpFozrTfCUWb7r4ZpsDtN1eFnfixXpDKpPXDWQsvPs+nh18r/jmoMgtlTjUltrS6mYQ=="], - "@novasamatech/statement-store": ["@novasamatech/statement-store@0.8.7", "", { "dependencies": { "@noble/ciphers": "2.2.0", "@noble/hashes": "2.2.0", "@novasamatech/scale": "0.8.7", "@novasamatech/sdk-statement": "^0.6.0", "@novasamatech/substrate-slot-sr25519-wasm": "0.8.7", "@polkadot-api/substrate-bindings": "^0.20.3", "@polkadot-api/substrate-client": "^0.7.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "@polkadot-labs/schnorrkel-wasm": "0.0.9", "@scure/sr25519": "2.2.0", "nanoid": "5.1.11", "neverthrow": "^8.2.0", "polkadot-api": ">=2", "scale-ts": "1.6.1" } }, "sha512-vRlcTHpdI0UqUHq5lvWG4WhSTbbvf/fW7nfTJmF2k97pdI4bR0VPGlGdvHNyIKFHXx4OPQWGzA5Cdbf85d7sWA=="], + "@novasamatech/statement-store": ["@novasamatech/statement-store@0.8.8", "", { "dependencies": { "@noble/ciphers": "2.2.0", "@noble/hashes": "2.2.0", "@novasamatech/scale": "0.8.8", "@novasamatech/sdk-statement": "^0.6.0", "@novasamatech/substrate-slot-sr25519-wasm": "0.8.8", "@polkadot-api/substrate-bindings": "^0.20.3", "@polkadot-api/substrate-client": "^0.7.0", "@polkadot-labs/hdkd-helpers": "^0.0.30", "@polkadot-labs/schnorrkel-wasm": "0.0.9", "@scure/sr25519": "2.2.0", "nanoid": "5.1.11", "neverthrow": "^8.2.0", "polkadot-api": ">=2", "scale-ts": "1.6.1" } }, "sha512-2D71j3VzTIknlUlCfrnQG3l1lzxkZrASNh+w1dp0HClMrKhlcdSiZDGrPPRkVjcOi2zt3KSLH+i5+5EOuZ66Yw=="], - "@novasamatech/storage-adapter": ["@novasamatech/storage-adapter@0.8.7", "", { "dependencies": { "nanoevents": "^9.1.0", "neverthrow": "^8.2.0" } }, "sha512-cA8VL6ghEETja1Q4rt11/njW3mCvkT9HdgQo5Hv7WBrRTcyb0rV34sdQvsbr+PpLBem/xLYBRTXzOK518ce8ww=="], + "@novasamatech/storage-adapter": ["@novasamatech/storage-adapter@0.8.8", "", { "dependencies": { "nanoevents": "^9.1.0", "neverthrow": "^8.2.0" } }, "sha512-q83gWxMUyooDZGDlriZ+NXFCk8MZHHCFJBiCXMMnAs6CzUAIlnFxyi8/PpUgXZUinEQCgpmKkEbx8hYMwKRdrw=="], - "@novasamatech/substrate-slot-sr25519-wasm": ["@novasamatech/substrate-slot-sr25519-wasm@0.8.7", "", {}, "sha512-FLjoXY05IBKGIF9KHO3488NFkSGBg5yqL8jl47VaC4YE8CiW5E5rxLRH55l0FXMnYPc8gdP/fvI7JH3wt6ac4g=="], + "@novasamatech/substrate-slot-sr25519-wasm": ["@novasamatech/substrate-slot-sr25519-wasm@0.8.8", "", {}, "sha512-clW1YI19AXiR28/DT79IyoXL9MukJlkT2bOSfmuzwwVPZ3QYmkIZ7RUiz/74r7edy56W1rM8NK9F/s5bWAmwYQ=="], "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], diff --git a/packages/auth/package.json b/packages/auth/package.json index 839a9606..c5c60765 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -29,11 +29,11 @@ "@dotli/resolver": "workspace:*", "@dotli/shared": "workspace:*", "@noble/hashes": "^2.2.0", - "@novasamatech/host-api": "0.8.7", - "@novasamatech/host-papp": "0.8.7", + "@novasamatech/host-api": "0.8.8", + "@novasamatech/host-papp": "0.8.8", "@novasamatech/sdk-statement": "^0.6.0", - "@novasamatech/statement-store": "0.8.7", - "@novasamatech/storage-adapter": "0.8.7", + "@novasamatech/statement-store": "0.8.8", + "@novasamatech/storage-adapter": "0.8.8", "@scure/sr25519": "^2.2.0", "neverthrow": "^8.2.0", "polkadot-api": "^2.1.6", diff --git a/packages/auth/src/account.ts b/packages/auth/src/account.ts index 2ff2da98..479b0bef 100644 --- a/packages/auth/src/account.ts +++ b/packages/auth/src/account.ts @@ -52,6 +52,15 @@ const ss58Codec = AccountId(); export const productPublicKeyToAddress = (publicKey: Uint8Array): string => ss58Codec.dec(publicKey); +/** + * Inverse of `productPublicKeyToAddress`: decode an SS58 address string back to + * its 32-byte public key. Used to turn a legacy account's `signer` address + * (the wire format products send) into the `AccountId` bytes host-papp's + * `signRawLegacy` expects. Throws on a malformed address. + */ +export const productAddressToPublicKey = (address: string): Uint8Array => + ss58Codec.enc(address); + // NOTE: Uncomment when derived product accounts get their own network // allowance (quota) on People Chain. Currently only the root session account // has allowance, so createProof signs with the root ssSecret directly. diff --git a/packages/auth/src/signing.ts b/packages/auth/src/signing.ts index 9e516c52..31b5818f 100644 --- a/packages/auth/src/signing.ts +++ b/packages/auth/src/signing.ts @@ -19,6 +19,7 @@ import { } from "@novasamatech/host-api"; import { log } from "@dotli/shared/log"; import type { UserSession } from "@novasamatech/host-papp"; +import { productPublicKeyToAddress } from "./account"; export interface SigningResult { signature: `0x${string}`; @@ -61,6 +62,20 @@ export interface ContainerCreateTransactionPayload { txExtVersion: number; } +/** + * Legacy-account variant of `ContainerCreateTransactionPayload`. The signer is + * a raw 32-byte public key (host-api `AccountId = Bytes(32)`) rather than the + * `[dotNsIdentifier, derivationIndex]` product tuple, matching host-papp's + * `LegacyTransaction = GenericTxPayloadV1(AccountId)`. + */ +export interface ContainerCreateTransactionLegacyPayload { + signer: Uint8Array; + genesisHash: Uint8Array; + callData: Uint8Array; + extensions: { id: string; extra: Uint8Array; additionalSigned: Uint8Array }[]; + txExtVersion: number; +} + /** Timeout for the wallet to respond (ms). Covers WS drops and unresponsive wallets. */ const SIGN_TIMEOUT_MS = 300_000; // 300 seconds @@ -433,3 +448,213 @@ export function showCreateTransactionModal( }); }); } + +/** + * Sign-raw modal for a true legacy (imported) account. + * + * Unlike `showSignRawModal`, the wallet is asked to sign with a concrete + * account public key (`signRawLegacy`) rather than deriving a product key from + * a `[dotNsIdentifier, index]` tuple. host-papp's `signRawLegacy` returns the + * raw signature only (no signed transaction). + */ +export function showSignRawLegacyModal( + session: UserSession, + data: ContainerSignRawRequest["payload"], + appLabel: string, + account: Uint8Array, +): Promise { + return new Promise((resolve, reject) => { + const signerLabel = productPublicKeyToAddress(account); + const message = data.tag === "Payload" ? data.value : toHex(data.value); + + const fields: { label: string; value: string; mono?: boolean }[] = [ + { label: "App", value: appLabel }, + { label: "Signer", value: signerLabel, mono: true }, + { label: "Message", value: message, mono: true }, + ]; + + const { backdrop, signBtn, cancelBtn } = createModalDOM( + "Sign Message", + fields, + ); + + cancelBtn.addEventListener("click", () => { + removeModal(backdrop); + reject(new SigningErr.Rejected()); + }); + + signBtn.addEventListener("click", () => { + signBtn.disabled = true; + signBtn.textContent = "Signing..."; + + void withTimeout( + session.signRawLegacy({ account, data }), + SIGN_TIMEOUT_MS, + ).then( + (result) => { + result.match( + (signature) => { + removeModal(backdrop); + resolve({ signature: toHex(signature) }); + }, + (e) => { + log.error("[dot.li signing] signRawLegacy FAILED:", e.message, e); + removeModal(backdrop); + reject(new SigningErr.Unknown({ reason: e.message })); + }, + ); + }, + (e: unknown) => { + log.error("[dot.li signing] signRawLegacy timed out:", e); + removeModal(backdrop); + const msg = e instanceof Error ? e.message : "Request timed out"; + reject(new SigningErr.Unknown({ reason: msg })); + }, + ); + }); + }); +} + +/** + * Create-transaction modal for a true legacy (imported) account. Mirrors + * `showCreateTransactionModal` but routes through host-papp's + * `createTransactionLegacy`, which carries the signer as a raw public key. + */ +export function showCreateTransactionLegacyModal( + session: UserSession, + payload: ContainerCreateTransactionLegacyPayload, + appLabel: string, +): Promise { + return new Promise((resolve, reject) => { + const signerLabel = productPublicKeyToAddress(payload.signer); + const genesisHashHex = toHex(payload.genesisHash); + const callDataHex = toHex(payload.callData); + const callDataPreview = + callDataHex.length > 80 ? `${callDataHex.slice(0, 80)}...` : callDataHex; + + const fields: { label: string; value: string; mono?: boolean }[] = [ + { label: "App", value: appLabel }, + { label: "Signer", value: signerLabel, mono: true }, + { label: "Genesis Hash", value: genesisHashHex, mono: true }, + { label: "Call Data", value: callDataPreview, mono: true }, + { label: "Tx Ext Version", value: String(payload.txExtVersion) }, + ]; + + const { backdrop, signBtn, cancelBtn } = createModalDOM( + "Sign Transaction", + fields, + ); + + cancelBtn.addEventListener("click", () => { + removeModal(backdrop); + reject(new CreateTransactionErr.Rejected()); + }); + + signBtn.addEventListener("click", () => { + signBtn.disabled = true; + signBtn.textContent = "Signing..."; + + void withTimeout( + session.createTransactionLegacy({ + payload: enumValue("v1", payload), + }), + SIGN_TIMEOUT_MS, + ).then( + (result) => { + result.match( + (signedTransaction) => { + removeModal(backdrop); + resolve(signedTransaction); + }, + (e) => { + log.error( + "[dot.li signing] createTransactionLegacy FAILED:", + e.message, + e, + ); + removeModal(backdrop); + reject(new CreateTransactionErr.Unknown({ reason: e.message })); + }, + ); + }, + (e: unknown) => { + log.error("[dot.li signing] createTransactionLegacy timed out:", e); + removeModal(backdrop); + const msg = e instanceof Error ? e.message : "Request timed out"; + reject(new CreateTransactionErr.Unknown({ reason: msg })); + }, + ); + }); + }); +} + +/** + * Sign-payload modal for a true legacy (imported) account. host-papp has no + * `signPayloadLegacy`, so the JSON-encoded payload is signed through + * `signRawLegacy` (Payload variant), mirroring polkadot-desktop. Returns the + * raw signature only (no signed transaction). + */ +export function showSignPayloadLegacyModal( + session: UserSession, + payload: ContainerSignPayloadRequest["payload"], + appLabel: string, + account: Uint8Array, +): Promise { + return new Promise((resolve, reject) => { + const signerLabel = productPublicKeyToAddress(account); + + const fields: { label: string; value: string; mono?: boolean }[] = [ + { label: "App", value: appLabel }, + { label: "Signer", value: signerLabel, mono: true }, + { label: "Genesis Hash", value: payload.genesisHash, mono: true }, + { label: "Call Data", value: payload.method, mono: true }, + ]; + + const { backdrop, signBtn, cancelBtn } = createModalDOM( + "Sign Transaction", + fields, + ); + + cancelBtn.addEventListener("click", () => { + removeModal(backdrop); + reject(new SigningErr.Rejected()); + }); + + signBtn.addEventListener("click", () => { + signBtn.disabled = true; + signBtn.textContent = "Signing..."; + + void withTimeout( + session.signRawLegacy({ + account, + data: { tag: "Payload", value: JSON.stringify(payload) }, + }), + SIGN_TIMEOUT_MS, + ).then( + (result) => { + result.match( + (signature) => { + removeModal(backdrop); + resolve({ signature: toHex(signature) }); + }, + (e) => { + log.error( + "[dot.li signing] signPayloadLegacy FAILED:", + e.message, + e, + ); + removeModal(backdrop); + reject(new SigningErr.Unknown({ reason: e.message })); + }, + ); + }, + (e: unknown) => { + log.error("[dot.li signing] signPayloadLegacy timed out:", e); + removeModal(backdrop); + const msg = e instanceof Error ? e.message : "Request timed out"; + reject(new SigningErr.Unknown({ reason: msg })); + }, + ); + }); + }); +} diff --git a/packages/config/src/timeouts.ts b/packages/config/src/timeouts.ts index 66cad02f..e6fa2114 100644 --- a/packages/config/src/timeouts.ts +++ b/packages/config/src/timeouts.ts @@ -18,4 +18,6 @@ export const TIMEOUTS = { SHARED_WORKER_READY: 210_000, /** Upper bound on `getFinalizedBlock()` while bootstrapping smoldot. */ ASSET_HUB_FINALIZED_SYNC: 180_000, + /** Upper bound on the background People-chain warm for legacy-account auth. */ + PEOPLE_FINALIZED_SYNC: 180_000, } as const; diff --git a/packages/resolver/src/resolve.ts b/packages/resolver/src/resolve.ts index b1128a71..d878e654 100644 --- a/packages/resolver/src/resolve.ts +++ b/packages/resolver/src/resolve.ts @@ -20,9 +20,12 @@ import { } from "./errors"; import { dur } from "@dotli/shared/perf"; import { log } from "@dotli/shared/log"; +import { getSmProvider } from "polkadot-api/sm-provider"; import { getSmoldot, getRelayChain, + getPeopleChain, + makeNonRemovingChain, onConnectionIssue, onSmoldotFatal, } from "./smoldot"; @@ -266,6 +269,88 @@ export async function waitForAssetHubFinalized( await ensureClient(onStatus, onPhase); } +// People chain warm-keep for legacy-account auth. +// +// Auth reads the username -> account map on the People chain. On a cold start +// that read races the parachain warp sync, which is the source of the +// intermittent "People read sometimes fails" reports. This mirrors the Asset +// Hub bootstrap above (open a follow, drive it to a finalized block with a +// bounded wait, invalidate on stop, keep the client alive) so the chain is +// already synced when auth connects. The one difference from Asset Hub: this is +// meant to run in the background and must NOT gate the ready signal, because +// resolution does not need People. +let peopleClientInstance: SubstrateClient | null = null; +let peopleApiInstance: Api | null = null; +let peoplePromise: Promise | null = null; + +function destroyPeopleClient(): void { + peopleApiInstance?.destroy(); + peopleClientInstance?.destroy(); + peopleApiInstance = null; + peopleClientInstance = null; + peoplePromise = null; +} + +export async function waitForPeopleFinalized( + onStatus?: StatusCallback, +): Promise { + if (peopleApiInstance) { + return; + } + peoplePromise ??= (async () => { + const initStart = performance.now(); + onStatus?.("Warming People chain..."); + // `makeNonRemovingChain` so a `getSmProvider` disconnect on this warm + // follow does not remove the shared smoldot chain out from under the + // dApp follows that connect through the broker. + const provider = getSmProvider(() => + getPeopleChain().then((chain) => makeNonRemovingChain(chain)), + ); + const client = createClient(provider); + const api = createRawApi(client); + try { + await Promise.race([ + api.whenReady(), + new Promise((_, reject) => { + setTimeout(() => { + reject( + new NetworkSyncTimeoutError( + "People Paseo", + TIMEOUTS.PEOPLE_FINALIZED_SYNC, + ), + ); + }, TIMEOUTS.PEOPLE_FINALIZED_SYNC); + }), + ]); + } catch (err) { + try { + api.destroy(); + client.destroy(); + // eslint-disable-next-line no-restricted-syntax -- best-effort teardown of a never-fully-initialised client; the real cause is rethrown on the next line. + } catch { + /* already dead, real cause rethrown below */ + } + peoplePromise = null; + throw err; + } + + // If the follow dies, invalidate so the next warm rebuilds against a fresh + // smoldot chain instead of reusing a dead one. + api.onStop(() => { + log.warn( + "[dot.li resolve] People chainHead follow stopped, invalidating warm client", + ); + destroyPeopleClient(); + }); + + peopleClientInstance = client; + peopleApiInstance = api; + log.warn(`[dot.li resolve] People chain warmed (${dur(initStart)})`); + return api; + })(); + await peoplePromise; +} + export async function resolveDotName( label: string, onStatus?: StatusCallback, diff --git a/packages/resolver/src/rpc-chain.ts b/packages/resolver/src/rpc-chain.ts index c175e843..34a3d034 100644 --- a/packages/resolver/src/rpc-chain.ts +++ b/packages/resolver/src/rpc-chain.ts @@ -1,46 +1,58 @@ // Copyright 2026 Parity Technologies (UK) Ltd. // SPDX-License-Identifier: AGPL-3.0-only -// dot.li WSS JSON-RPC chain provider (gateway mode). -// -// Produces a `JsonRpcProvider` backed by a public Polkadot RPC node instead -// of smoldot. Used by the protocol host iframe when running in `rpc` submode -// so sandboxed apps can issue chain calls via `chainConnect` without -// requiring a light client. -// -// Trust model: the RPC endpoints are trusted. This is the same trade-off -// gateway-mode name resolution already makes in `./rpc-resolve.ts`. -// -// Intentionally does not import smoldot so Vite can tree-shake the worker -// out of any bundle that only pulls this module. -// -// Currently only Asset Hub Paseo has curated RPC endpoints in -// `@dotli/config`. Other supported chains (relay, Bulletin) fall back to -// `null`, meaning sandboxed apps will see an immediate "chain unsupported" -// error instead of silently hanging. Add more endpoints to config to widen -// coverage. +/** + * WSS JSON-RPC chain providers for gateway mode. + * + * Produces a `JsonRpcProvider` backed by a public Polkadot RPC node instead + * of smoldot. The protocol host iframe uses these in `rpc` submode so + * sandboxed apps can issue chain calls via `chainConnect` without a light + * client. The endpoints are trusted, the same posture `./rpc-resolve.ts` + * already takes for gateway-mode name resolution. + * + * smoldot is never imported here, so Vite tree-shakes the light client out of + * any bundle that only pulls this module. + * + * Coverage is the active network's relay, Asset Hub, and People chains, each + * dialled through its configured `rpcs`. Login and identity resolution live + * on the People chain, so it must be reachable for auth to work in gateway + * mode. Bulletin is deliberately absent. Its content (IPFS or bitswap) is + * served through IPFS gateways, not a chain RPC connection. + */ import { getWsProvider } from "polkadot-api/ws"; import type { JsonRpcProvider } from "polkadot-api"; import { getActiveServicesConfig } from "@dotli/config/network"; +import type { ChainService } from "@dotli/config/network"; -export function isRpcChainSupported(genesisHash: string): boolean { - return ( - genesisHash.toLowerCase() === - getActiveServicesConfig().assethub.genesis.toLowerCase() +/** Resolve a genesis hash to its active-network chain, or `null` when gateway mode cannot reach it. */ +function gatewayChain(genesisHash: string): ChainService | null { + const cfg = getActiveServicesConfig(); + const key = genesisHash.toLowerCase(); + const chain = [cfg.relay, cfg.assethub, cfg.people].find( + (c) => c.genesis.toLowerCase() === key, ); + if (chain === undefined || chain.rpcs.length === 0) { + return null; + } + return chain; +} + +/** Whether gateway mode can serve chain calls for `genesisHash`. */ +export function isRpcChainSupported(genesisHash: string): boolean { + return gatewayChain(genesisHash) !== null; } +/** A WSS JSON-RPC provider for `genesisHash`, or `null` when gateway mode does not support that chain. */ export function createRpcChainProvider( genesisHash: string, ): JsonRpcProvider | null { - const assethub = getActiveServicesConfig().assethub; - if (genesisHash.toLowerCase() === assethub.genesis.toLowerCase()) { - // Single active endpoint, no silent round-robin. - // Public RPC endpoints are occasionally tunnel-gated, so the default 40s - // heartbeat is too tight. Match the timeout used in `./rpc-resolve.ts`. - return getWsProvider([...assethub.rpcs], { - heartbeatTimeout: 120_000, - }); + const chain = gatewayChain(genesisHash); + if (chain === null) { + return null; } - return null; + // Public RPC endpoints are occasionally tunnel-gated, so the default 40s + // heartbeat is too tight. Match the timeout used in `./rpc-resolve.ts`. + return getWsProvider([...chain.rpcs], { + heartbeatTimeout: 120_000, + }); } diff --git a/packages/truapi-debug/package.json b/packages/truapi-debug/package.json index acc1d867..ee708d72 100644 --- a/packages/truapi-debug/package.json +++ b/packages/truapi-debug/package.json @@ -20,9 +20,9 @@ }, "dependencies": { "@dotli/shared": "workspace:*", - "@novasamatech/host-api": "0.8.7", - "@novasamatech/host-container": "0.8.7", - "@novasamatech/host-papp": "0.8.7", + "@novasamatech/host-api": "0.8.8", + "@novasamatech/host-container": "0.8.8", + "@novasamatech/host-papp": "0.8.8", "nanoevents": "^9.1.0" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index a7b47c68..6508a663 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,11 +32,11 @@ "@dotli/storage": "workspace:*", "@dotli/truapi-debug": "workspace:*", "@noble/hashes": "^2.2.0", - "@novasamatech/host-api": "0.8.7", - "@novasamatech/host-container": "0.8.7", - "@novasamatech/host-papp": "0.8.7", + "@novasamatech/host-api": "0.8.8", + "@novasamatech/host-container": "0.8.8", + "@novasamatech/host-papp": "0.8.8", "@novasamatech/sdk-statement": "^0.6.0", - "@novasamatech/statement-store": "0.8.7", + "@novasamatech/statement-store": "0.8.8", "@polkadot-api/json-rpc-provider": "^0.2.0", "neverthrow": "^8.2.0", "polkadot-api": "^2.1.6", diff --git a/packages/ui/src/container.ts b/packages/ui/src/container.ts index 35062af1..e6aaf4f4 100644 --- a/packages/ui/src/container.ts +++ b/packages/ui/src/container.ts @@ -68,13 +68,17 @@ import { type AuthState, } from "@dotli/auth/auth"; import { + showCreateTransactionLegacyModal, showCreateTransactionModal, + showSignPayloadLegacyModal, showSignPayloadModal, + showSignRawLegacyModal, showSignRawModal, - type ContainerCreateTransactionPayload, + type ContainerCreateTransactionLegacyPayload, } from "@dotli/auth/signing"; import { deriveProductPublicKey, + productAddressToPublicKey, productPublicKeyToAddress, } from "@dotli/auth/account"; import { @@ -465,11 +469,14 @@ function wireContainerHandlers( }), ); - // Legacy-account signing wires. Re-derive the same `(session, identifier, 0)` - // public key, SS58-encode it, and require it equals the product-supplied - // `signer: string` before opening the regular signing modal with a synthetic - // `[identifier, 0]` tuple. Mirrors the desktop host's wire-up at - // browser/src/widgets/ProductContainerBinding/integrations/signing.tsx. + // Legacy-account signing wires. A `*WithLegacyAccount` request always targets + // a genuine imported account, so decode the supplied signer to its raw + // AccountId and relay to the wallet via host-papp's legacy SDK calls. The + // non-legacy handlers above own the product-account (derived) path. Mirrors + // polkadot-desktop#632. + // + // host-papp has no `signPayloadLegacy`, so signPayload is signed through + // `signRawLegacy` (Payload variant) — see `showSignPayloadLegacyModal`. container.handleSignPayloadWithLegacyAccount((payload, { ok, err }) => queueWalletFlow(() => { log.warn(`[${label}] handleSignPayloadWithLegacyAccount invoked:`, { @@ -486,21 +493,12 @@ function wireContainerHandlers( return errAsync(new SigningErr.Rejected(undefined)); } - const identifier = labelToProductIdentifier(label); - const derivedPk = deriveProductPublicKey( - session.rootAccountId, - identifier, - 0, - ); - const derivedAddress = productPublicKeyToAddress(derivedPk); - if (derivedAddress !== payload.signer) { - log.warn( - `[${label}] handleSignPayloadWithLegacyAccount — signer mismatch (expected ${derivedAddress}, got ${payload.signer})`, - ); + let account: Uint8Array; + try { + account = productAddressToPublicKey(payload.signer); + } catch { return errAsync( - new SigningErr.Unknown({ - reason: "Account can't be derived from product account id", - }), + new SigningErr.Unknown({ reason: "Invalid legacy signer address" }), ); } @@ -513,28 +511,21 @@ function wireContainerHandlers( return err(new SigningErr.PermissionDenied(undefined)); } return fromPromise( - showSignPayloadModal(session, payload.payload, label, [ - identifier, - 0, - ]), + showSignPayloadLegacyModal( + session, + payload.payload, + label, + account, + ), (e) => e as never, ) - .andThen((result) => { - log.warn( - `[${label}] handleSignPayloadWithLegacyAccount — resolved OK`, - ); - return ok({ + .andThen((result) => + ok({ signature: result.signature, signedTransaction: result.signedTransaction, - }); - }) - .orElse((e) => { - log.warn( - `[${label}] handleSignPayloadWithLegacyAccount — rejected:`, - e, - ); - return err(e); - }); + }), + ) + .orElse((e) => err(e)); }, ); }), @@ -555,47 +546,31 @@ function wireContainerHandlers( return errAsync(new SigningErr.Rejected(undefined)); } - const identifier = labelToProductIdentifier(label); - const derivedPk = deriveProductPublicKey( - session.rootAccountId, - identifier, - 0, - ); - const derivedAddress = productPublicKeyToAddress(derivedPk); - if (derivedAddress !== payload.signer) { - log.warn( - `[${label}] handleSignRawWithLegacyAccount — signer mismatch (expected ${derivedAddress}, got ${payload.signer})`, - ); + let account: Uint8Array; + try { + account = productAddressToPublicKey(payload.signer); + } catch { return errAsync( - new SigningErr.Unknown({ - reason: "Account can't be derived from product account id", - }), + new SigningErr.Unknown({ reason: "Invalid legacy signer address" }), ); } return fromPromise( - showSignRawModal(session, payload.payload, label, [identifier, 0]), + showSignRawLegacyModal(session, payload.payload, label, account), (e) => e as never, ) - .andThen((result) => { - log.warn(`[${label}] handleSignRawWithLegacyAccount — resolved OK`); - return ok({ + .andThen((result) => + ok({ signature: result.signature, signedTransaction: result.signedTransaction, - }); - }) - .orElse((e) => { - log.warn(`[${label}] handleSignRawWithLegacyAccount — rejected:`, e); - return err(e); - }); + }), + ) + .orElse((e) => err(e)); }), ); - // Legacy-account create-transaction. The host-papp SSO message only carries - // the product-account flavor, so we re-route the request through the same - // wallet flow using a synthetic `[identifier, 0]` tuple. Mirrors the trust - // model of `handleSignPayloadWithLegacyAccount` above. `payload.signer` is - // the raw 32-byte public key (codec is `AccountId = Bytes(32)`). + // Legacy-account create-transaction: relay the raw 32-byte signer + // (`AccountId = Bytes(32)`) to the wallet via createTransactionLegacy. container.handleCreateTransactionWithLegacyAccount((payload, { ok, err }) => queueWalletFlow(() => { log.warn(`[${label}] handleCreateTransactionWithLegacyAccount invoked:`, { @@ -614,23 +589,6 @@ function wireContainerHandlers( return errAsync(new CreateTransactionErr.Rejected()); } - const identifier = labelToProductIdentifier(label); - const derivedPk = deriveProductPublicKey( - session.rootAccountId, - identifier, - 0, - ); - if (toHex(derivedPk) !== toHex(payload.signer)) { - log.warn( - `[${label}] handleCreateTransactionWithLegacyAccount — signer mismatch (expected ${productPublicKeyToAddress(derivedPk)}, got pk=${toHex(payload.signer)})`, - ); - return errAsync( - new CreateTransactionErr.Unknown({ - reason: "Account can't be derived from product account id", - }), - ); - } - return promptCachedSubmitPermission(label, "ChainSubmit").andThen( (granted) => { if (!granted) { @@ -639,30 +597,19 @@ function wireContainerHandlers( ); return err(new CreateTransactionErr.PermissionDenied()); } - const productPayload: ContainerCreateTransactionPayload = { - signer: [identifier, 0], + const legacyPayload: ContainerCreateTransactionLegacyPayload = { + signer: payload.signer, genesisHash: payload.genesisHash, callData: payload.callData, extensions: payload.extensions, txExtVersion: payload.txExtVersion, }; return fromPromise( - showCreateTransactionModal(session, productPayload, label), + showCreateTransactionLegacyModal(session, legacyPayload, label), (e) => e as never, ) - .andThen((signedTx) => { - log.warn( - `[${label}] handleCreateTransactionWithLegacyAccount — resolved OK`, - ); - return ok(signedTx); - }) - .orElse((e) => { - log.warn( - `[${label}] handleCreateTransactionWithLegacyAccount — rejected:`, - e, - ); - return err(e); - }); + .andThen((signedTx) => ok(signedTx)) + .orElse((e) => err(e)); }, ); }),