diff --git a/Cargo.lock b/Cargo.lock index 515ec10f..5074af2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -190,12 +201,32 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "funty" version = "2.0.0" @@ -308,6 +339,109 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "impl-trait-for-tuples" version = "0.2.3" @@ -365,6 +499,12 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "memchr" version = "2.8.0" @@ -405,12 +545,27 @@ dependencies = [ "syn", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -514,6 +669,18 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -531,12 +698,48 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -600,12 +803,34 @@ dependencies = [ "syn", ] +[[package]] +name = "truapi-platform" +version = "0.1.0" +dependencies = [ + "async-trait", + "derive_more", + "futures", + "parity-scale-codec", + "truapi", + "unicode-normalization", + "url", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -618,6 +843,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -648,6 +891,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "wyz" version = "0.5.1" @@ -657,6 +906,83 @@ dependencies = [ "tap", ] +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 2f8d2023..3ab213ba 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -86,8 +86,6 @@ describe("generated client transport", () => { expectedFrame.set(expectedPayload, str.enc("p:1").length + 1); expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); - expect(transport.truapiVersion).toBe(1); - expect(transport.codecVersion).toBe(1); }); it("uses the transport codec version for generated handshake calls", () => { diff --git a/js/packages/truapi/src/scale.ts b/js/packages/truapi/src/scale.ts index f5670ffa..c9a09a1c 100644 --- a/js/packages/truapi/src/scale.ts +++ b/js/packages/truapi/src/scale.ts @@ -9,12 +9,12 @@ import { Bytes, Enum, Struct, - _void, createCodec, createDecoder, enhanceCodec, - str as scaleStr, + str, u8, + _void, type Codec, } from "scale-ts"; import { @@ -123,49 +123,23 @@ export function TaggedUnion( return Enum(inner) as unknown as Codec>; } -/** - * Wire codec for Rust `CallError`, projected to the public domain error `D`. - * - * Generated TypeScript APIs expose only the domain error union in - * `ResultAsync`. The Rust host still wraps that value in - * `CallError::Domain` on the wire so framework errors can share the response - * channel. Encoding always emits `Domain`; decoding returns the inner domain - * value and throws for framework-level failures that have no public `D` shape. - */ -export function CallError(domain: Codec): Codec { - type WireCallError = - | { tag: "Domain"; value: D } - | { tag: "Denied"; value?: undefined } - | { tag: "Unsupported"; value?: undefined } - | { tag: "MalformedFrame"; value: { reason: string } } - | { tag: "HostFailure"; value: { reason: string } }; +/** Public TS value for Rust's derived `CallError` enum. */ +export type CallErrorValue = + | { tag: "Domain"; value: D } + | { tag: "Denied"; value?: undefined } + | { tag: "Unsupported"; value?: undefined } + | { tag: "MalformedFrame"; value: { reason: string } } + | { tag: "HostFailure"; value: { reason: string } }; - const wire = Enum({ +/** SCALE codec for Rust's derived `CallError` enum. */ +export function CallError(domain: Codec): Codec> { + return TaggedUnion({ Domain: domain, Denied: _void, Unsupported: _void, - MalformedFrame: Struct({ reason: scaleStr }), - HostFailure: Struct({ reason: scaleStr }), - }) as unknown as Codec; - - return enhanceCodec( - wire, - (value: D): WireCallError => ({ tag: "Domain", value }), - (value: WireCallError): D => { - switch (value.tag) { - case "Domain": - return value.value; - case "Denied": - throw new Error("Host denied the request"); - case "Unsupported": - throw new Error("Host does not support this request"); - case "MalformedFrame": - throw new Error(`Malformed request frame: ${value.value.reason}`); - case "HostFailure": - throw new Error(`Host failure: ${value.value.reason}`); - } - }, - ); + MalformedFrame: Struct({ reason: str }), + HostFailure: Struct({ reason: str }), + }) as Codec>; } type TaggedUnionCodecs = { diff --git a/playground/package.json b/playground/package.json index 848f58b8..78fef5f3 100644 --- a/playground/package.json +++ b/playground/package.json @@ -23,7 +23,9 @@ "dependencies": { "@monaco-editor/react": "^4", "@parity/truapi": "link:../js/packages/truapi", - "@polkadot-api/substrate-bindings": "^0.12.0", + "@polkadot-api/metadata-builders": "0.14.2", + "@polkadot-api/substrate-bindings": "0.20.2", + "@polkadot-api/utils": "0.4.0", "monaco-editor": "^0.52", "neverthrow": "^8.2.0", "next": "15.5.18", diff --git a/playground/src/lib/example-helpers.ts b/playground/src/lib/example-helpers.ts index 3cfb5d3c..0ee99018 100644 --- a/playground/src/lib/example-helpers.ts +++ b/playground/src/lib/example-helpers.ts @@ -4,9 +4,26 @@ import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; import { Blake2128Concat, Bytes, + decAnyMetadata, Storage, + unifyMetadata, } from "@polkadot-api/substrate-bindings"; -import type { Client, HexString, StorageResultItem } from "@parity/truapi"; +import { + getDynamicBuilder, + getLookupFn, +} from "@polkadot-api/metadata-builders"; +import { fromHex, toHex } from "@polkadot-api/utils"; +import type { + Client, + HexString, + ProductAccountId, + ProductAccountTxPayload, + RemoteChainHeadFollowItem, + RuntimeSpec, + RuntimeType, + StorageResultItem, + TxPayloadExtension, +} from "@parity/truapi"; export type ChainHeadCtx = { genesisHash: `0x${string}`; @@ -23,6 +40,12 @@ export type AccountIdForDotNsUsername = ( username?: string, ) => Promise>; +export type BuildCreateTransactionPayload = (opts: { + signer: ProductAccountId; + genesisHash: HexString; + callData: HexString; +}) => Promise>; + const usernameOwnerOfStorage = Storage("Resources")("UsernameOwnerOf", [ Bytes(), Blake2128Concat, @@ -176,6 +199,425 @@ export function createAccountIdForDotNsUsername( }; } +export function createBuildCreateTransactionPayload( + truapi: Client, +): BuildCreateTransactionPayload { + return async function buildCreateTransactionPayload(opts) { + const accountResult = await truapi.account.getAccount({ + productAccountId: opts.signer, + }); + if (accountResult.isErr()) { + return err(toError(accountResult.error)); + } + + const built = await buildTransactionContext( + truapi, + opts.genesisHash, + accountResult.value.account.publicKey, + ); + if (built.isErr()) return err(built.error); + + const { metadata, runtime, nonce, genesisHash } = built.value; + const unified = unifyMetadata(decAnyMetadata(metadata)); + const lookupFn = getLookupFn(unified); + const builder = getDynamicBuilder(lookupFn); + const chainState = { + genesisHash: fromHex(genesisHash), + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion ?? 0, + nonce, + }; + + return ok({ + signer: opts.signer, + genesisHash, + callData: opts.callData, + extensions: encodeSignedExtensions( + unified, + lookupFn, + builder, + chainState, + ), + txExtVersion: txExtVersionFromMetadata(unified), + }); + }; +} + +type UnifiedMetadata = ReturnType; +type LookupFn = ReturnType; +type LookupEntry = ReturnType; +type DynamicBuilder = ReturnType; + +type ChainState = { + genesisHash: Uint8Array; + specVersion: number; + transactionVersion: number; + nonce: number; +}; + +type TransactionContext = { + genesisHash: HexString; + metadata: Uint8Array; + nonce: number; + runtime: RuntimeSpec; +}; + +function buildTransactionContext( + truapi: Client, + genesisHash: HexString, + accountPublicKey: HexString, +): Promise> { + return new Promise((resolve) => { + let subscription: ReturnType< + ReturnType["subscribe"] + > | null = null; + const completedOperations = new Map>(); + const operationWaiters = new Map< + string, + (result: Result) => void + >(); + let initialized = false; + let settled = false; + + const settle = (result: Result) => { + if (settled) return; + settled = true; + try { + subscription?.unsubscribe(); + } catch { + /* benign */ + } + resolve(result); + }; + + const finishOperation = ( + operationId: string, + result: Result, + ) => { + const waiter = operationWaiters.get(operationId); + if (waiter) { + operationWaiters.delete(operationId); + waiter(result); + return; + } + completedOperations.set(operationId, result); + }; + + const waitForOperation = ( + operationId: string, + ): Promise> => { + const completed = completedOperations.get(operationId); + if (completed) { + completedOperations.delete(operationId); + return Promise.resolve(completed); + } + return new Promise((operationResolve) => { + operationWaiters.set(operationId, operationResolve); + }); + }; + + const callHead = async ( + hash: HexString, + fn: string, + callParameters: HexString, + ): Promise> => { + if (!subscription) { + return err(new Error("chain head subscription was not initialized")); + } + const result = await truapi.chain.callHead({ + genesisHash, + followSubscriptionId: subscription.subscriptionId, + hash, + function: fn, + callParameters, + }); + if (result.isErr()) return err(toError(result.error)); + if (result.value.operation.tag !== "Started") { + return err(new Error(`chainHead call limit reached for ${fn}`)); + } + return waitForOperation(result.value.operation.value.operationId); + }; + + const handleInitialized = async ( + item: Extract, + ) => { + if (initialized) return; + initialized = true; + const hash = item.value.finalizedBlockHashes[0]; + if (!hash) { + settle( + err(new Error("chainHead initialized without a finalized hash")), + ); + return; + } + const runtime = runtimeSpecFrom(item.value.finalizedBlockRuntime); + if (runtime.isErr()) { + settle(err(runtime.error)); + return; + } + + const [metadata, nonce] = await Promise.all([ + callHead(hash, "Metadata_metadata", "0x"), + callHead(hash, "AccountNonceApi_account_nonce", accountPublicKey), + ]); + if (metadata.isErr()) { + settle(err(metadata.error)); + return; + } + if (nonce.isErr()) { + settle(err(nonce.error)); + return; + } + + const rawMetadata = unwrapOpaqueMetadata(metadata.value); + if (rawMetadata.isErr()) { + settle(err(rawMetadata.error)); + return; + } + + let decodedNonce: number; + try { + decodedNonce = nonceFromRuntimeApiOutput(nonce.value); + } catch (error) { + settle(err(toError(error))); + return; + } + + const followSubscriptionId = subscription?.subscriptionId; + if (followSubscriptionId) { + void truapi.chain.unpinHead({ + genesisHash, + followSubscriptionId, + hashes: [hash], + }); + } + + settle( + ok({ + genesisHash, + metadata: rawMetadata.value, + nonce: decodedNonce, + runtime: runtime.value, + }), + ); + }; + + subscription = truapi.chain + .followHeadSubscribe({ + request: { genesisHash, withRuntime: true }, + }) + .subscribe({ + next: (item) => { + switch (item.tag) { + case "Initialized": + void handleInitialized(item); + return; + case "OperationCallDone": + finishOperation(item.value.operationId, ok(item.value.output)); + return; + case "OperationError": + finishOperation( + item.value.operationId, + err( + new Error(`chainHead operation failed: ${item.value.error}`), + ), + ); + return; + case "OperationInaccessible": + finishOperation( + item.value.operationId, + err(new Error("chainHead operation inaccessible")), + ); + return; + case "Stop": + settle( + err( + new Error( + "chain head subscription stopped before transaction context was built", + ), + ), + ); + return; + } + }, + error: (error) => settle(err(toError(error))), + complete: () => + settle( + err( + new Error( + "chain head subscription completed before transaction context was built", + ), + ), + ), + }); + }); +} + +function runtimeSpecFrom(value?: RuntimeType): Result { + if (!value) return err(new Error("chainHead did not include runtime data")); + if (value.tag === "Invalid") { + return err(new Error(`chainHead runtime invalid: ${value.value.error}`)); + } + if (value.value.transactionVersion === undefined) { + return err(new Error("runtime did not include transactionVersion")); + } + return ok(value.value); +} + +function unwrapOpaqueMetadata(output: HexString): Result { + try { + const raw = Bytes().dec(fromHex(output)); + if ( + raw.length < 5 || + raw[0] !== 0x6d || + raw[1] !== 0x65 || + raw[2] !== 0x74 || + raw[3] !== 0x61 + ) { + return err( + new Error("runtime Metadata_metadata returned invalid metadata"), + ); + } + return ok(raw); + } catch (error) { + return err(toError(error)); + } +} + +function nonceFromRuntimeApiOutput(output: HexString): number { + const bytes = fromHex(output); + if (bytes.length < 4) { + throw new Error("AccountNonceApi_account_nonce returned too few bytes"); + } + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(0, true); +} + +function txExtVersionFromMetadata(metadata: UnifiedMetadata): number { + const latestVersion = metadata.extrinsic.version.reduce( + (max, version) => Math.max(max, version), + 0, + ); + return latestVersion === 4 ? 0 : latestVersion; +} + +function encodeSignedExtensions( + metadata: UnifiedMetadata, + lookupFn: LookupFn, + builder: DynamicBuilder, + chainState: ChainState, +): TxPayloadExtension[] { + const exts = metadata.extrinsic.signedExtensions[0] as Array<{ + identifier: string; + type: number; + additionalSigned: number; + }>; + + return exts.map((ext) => { + const values = signedExtensionValues(ext, lookupFn, chainState); + const extra = encodeExtensionField( + builder, + lookupFn, + ext.type, + values.extra, + ); + const additionalSigned = encodeExtensionField( + builder, + lookupFn, + ext.additionalSigned, + values.additionalSigned, + ); + + return { + id: ext.identifier, + extra: toHex(extra) as HexString, + additionalSigned: toHex(additionalSigned) as HexString, + }; + }); +} + +function signedExtensionValues( + ext: { identifier: string; type: number; additionalSigned: number }, + lookupFn: LookupFn, + chainState: ChainState, +): { extra: unknown; additionalSigned: unknown } { + switch (ext.identifier) { + case "CheckNonce": + return { extra: chainState.nonce, additionalSigned: undefined }; + case "CheckSpecVersion": + return { + extra: undefined, + additionalSigned: chainState.specVersion, + }; + case "CheckTxVersion": + return { + extra: undefined, + additionalSigned: chainState.transactionVersion, + }; + case "CheckGenesis": + return { + extra: undefined, + additionalSigned: toHex(chainState.genesisHash), + }; + case "CheckMortality": + return { + extra: { type: "Immortal" }, + additionalSigned: toHex(chainState.genesisHash), + }; + case "VerifyMultiSignature": + return { extra: { type: "Disabled" }, additionalSigned: undefined }; + case "ChargeAssetTxPayment": + return { + extra: { tip: 0, asset_id: undefined }, + additionalSigned: undefined, + }; + case "RestrictOrigins": + return { extra: false, additionalSigned: undefined }; + default: + return { + extra: defaultValueForType(lookupFn(ext.type)), + additionalSigned: defaultValueForType(lookupFn(ext.additionalSigned)), + }; + } +} + +function encodeExtensionField( + builder: DynamicBuilder, + lookupFn: LookupFn, + typeId: number, + value: unknown, +): Uint8Array { + const entry = lookupFn(typeId); + if (!entry || entry.type === "void") return new Uint8Array(0); + const codec = builder.buildDefinition(typeId) as { + enc: (value: unknown) => Uint8Array; + }; + return codec.enc(value); +} + +function defaultValueForType(entry: LookupEntry): unknown { + if (!entry) return undefined; + if (entry.type === "void" || entry.type === "option") return undefined; + if (entry.type === "primitive") { + if (entry.value === "bool") return false; + if (entry.value.startsWith("u") || entry.value.startsWith("i")) return 0; + return undefined; + } + if (entry.type === "compact") return 0; + if (entry.type === "array") return new Uint8Array(entry.len); + if (entry.type === "enum") { + const first = Object.entries(entry.value)[0]; + if (!first) return undefined; + const [name, variant] = first; + if (variant.type === "void") return { type: name }; + return { type: name, value: undefined }; + } + return undefined; +} + function findStorageValue( items: StorageResultItem[], key: HexString, diff --git a/playground/src/lib/example-runner.ts b/playground/src/lib/example-runner.ts index 97cf72df..79f1aee7 100644 --- a/playground/src/lib/example-runner.ts +++ b/playground/src/lib/example-runner.ts @@ -2,8 +2,10 @@ import { transform } from "sucrase"; import type { Subscription, TrUApiClient } from "@parity/truapi"; import { createAccountIdForDotNsUsername, + createBuildCreateTransactionPayload, createWithChainHeadFollow, type AccountIdForDotNsUsername, + type BuildCreateTransactionPayload, type WithChainHeadFollow, } from "./example-helpers"; @@ -39,14 +41,14 @@ function exampleAssert( // Drop any `@parity/truapi` import that does not name value specifiers (e.g. // bare type-only imports left over after sucrase). Named value imports are // rewritten by `TRUAPI_NAMED_IMPORT_RE` below. -const IMPORT_RE = /^\s*import\s+(?!\{)[^;]*?from\s+["']@parity\/truapi["'];?\s*$/gm; +const IMPORT_RE = + /^\s*import\s+(?!\{)[^;]*?from\s+["']@parity\/truapi["'];?\s*$/gm; // `import { PASEO_NEXT_V2_ASSET_HUB, ... } from "@parity/truapi"` // → `const { PASEO_NEXT_V2_ASSET_HUB, ... } = __truapi;` const TRUAPI_NAMED_IMPORT_RE = /^\s*import\s*(\{[^}]*\})\s*from\s+["']@parity\/truapi["'];?\s*$/gm; // `import { from, take, ... } from "rxjs"` → `const { from, take, ... } = __rxjs;` -const RXJS_IMPORT_RE = - /^\s*import\s*(\{[^}]*\})\s*from\s+["']rxjs["'];?\s*$/gm; +const RXJS_IMPORT_RE = /^\s*import\s*(\{[^}]*\})\s*from\s+["']rxjs["'];?\s*$/gm; const EXPORT_RE = /^(\s*)export\s+(async\s+function|function|const|let|var|class)\b/gm; @@ -58,14 +60,16 @@ type ConsoleShim = { warn: (...args: unknown[]) => void; }; -const AsyncFunction = Object.getPrototypeOf( - async function () {}, -).constructor as new (...args: string[]) => ( +const AsyncFunction = Object.getPrototypeOf(async function () {}) + .constructor as new ( + ...args: string[] +) => ( truapi: unknown, __console: ConsoleShim, __rxjs: unknown, withChainHeadFollow: WithChainHeadFollow, accountIdForDotNsUsername: AccountIdForDotNsUsername, + buildCreateTransactionPayload: BuildCreateTransactionPayload, __truapi: unknown, assert: typeof exampleAssert, ) => Promise; @@ -105,6 +109,7 @@ export async function runExample(opts: { rxjs: unknown, withChainHeadFollow: WithChainHeadFollow, accountIdForDotNsUsername: AccountIdForDotNsUsername, + buildCreateTransactionPayload: BuildCreateTransactionPayload, truapiPkg: unknown, assert: typeof exampleAssert, ) => Promise; @@ -115,6 +120,7 @@ export async function runExample(opts: { "__rxjs", "withChainHeadFollow", "accountIdForDotNsUsername", + "buildCreateTransactionPayload", "__truapi", "assert", body, @@ -147,16 +153,22 @@ export async function runExample(opts: { }; const [rxjs, truapiPkg] = await Promise.all([getRxjs(), getTruapiPkg()]); - const withChainHeadFollow = createWithChainHeadFollow(trackingClient as TrUApiClient); + const withChainHeadFollow = createWithChainHeadFollow( + trackingClient as TrUApiClient, + ); const accountIdForDotNsUsername = createAccountIdForDotNsUsername( trackingClient as TrUApiClient, ); + const buildCreateTransactionPayload = createBuildCreateTransactionPayload( + trackingClient as TrUApiClient, + ); const promise = run( trackingClient, consoleShim, rxjs, withChainHeadFollow, accountIdForDotNsUsername, + buildCreateTransactionPayload, truapiPkg, exampleAssert, ); @@ -181,17 +193,17 @@ function createTrackingClient( }); } -function wrapService( - svc: object, - onSub: (sub: Subscription) => void, -): unknown { +function wrapService(svc: object, onSub: (sub: Subscription) => void): unknown { return new Proxy(svc as Record, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value !== "function") return value; return (...args: unknown[]) => { const out = (value as (...a: unknown[]) => unknown).apply(target, args); - if (out && typeof (out as { subscribe?: unknown }).subscribe === "function") { + if ( + out && + typeof (out as { subscribe?: unknown }).subscribe === "function" + ) { return wrapObservable(out as ObservableLike, onSub); } return out; diff --git a/playground/src/lib/monaco-setup.ts b/playground/src/lib/monaco-setup.ts index eb401a25..facddd99 100644 --- a/playground/src/lib/monaco-setup.ts +++ b/playground/src/lib/monaco-setup.ts @@ -97,6 +97,12 @@ export function setupMonaco(m: Monaco): void { ` }): import("rxjs").Observable;`, ` /** Resolve a DotNS username to the owning raw AccountId32 hex string. Defaults to truapi.account.getUserId(). */`, ` function accountIdForDotNsUsername(username?: string): Promise>;`, + ` /** Build a metadata-backed product-account transaction payload for \`truapi.signing.createTransaction\`. */`, + ` function buildCreateTransactionPayload(opts: {`, + ` signer: import("@parity/truapi").ProductAccountId;`, + ` genesisHash: \`0x\${string}\`;`, + ` callData: \`0x\${string}\`;`, + ` }): Promise>;`, ` /**`, ` * Assert a condition, throwing when it does not hold. Examples signal`, ` * failure explicitly with \`assert(...)\`; the diagnosis marks an example`, diff --git a/playground/yarn.lock b/playground/yarn.lock index a485b3d9..01d5a21a 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -371,11 +371,6 @@ resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.18.tgz#beac6228e60e3ee08ce7a20b7f61b3dc516d4b10" integrity sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg== -"@noble/hashes@^1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - "@noble/hashes@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" @@ -408,10 +403,8 @@ integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== "@parity/truapi@link:../js/packages/truapi": - version "0.3.1" - dependencies: - neverthrow "^8.2.0" - scale-ts "^1.6.1" + version "0.0.0" + uid "" "@playwright/test@^1.49.1": version "1.59.1" @@ -420,20 +413,28 @@ dependencies: playwright "1.59.1" -"@polkadot-api/substrate-bindings@^0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@polkadot-api/substrate-bindings/-/substrate-bindings-0.12.0.tgz#2b9cd9ba1b7e29c4a1d0be0575504c02cb435c78" - integrity sha512-cIjDeJRHW6g3z+/55UzpoG4LG1N0HbT4x3NvZsQkYg4eoio9Sw7Pw2aZZX86pWemxc7vQbNw7WSz2Gz+ckdX6Q== +"@polkadot-api/metadata-builders@0.14.2": + version "0.14.2" + resolved "https://registry.yarnpkg.com/@polkadot-api/metadata-builders/-/metadata-builders-0.14.2.tgz#b7081728eb6451ae7cc5d56061b301058b1d4af2" + integrity sha512-nhsFfti0M5tE0LR8++0wHqbP54I/QSFXP/uF5I82MYVSKY0NqIDkIFvr27oLo/ltF+o3vcN44ZJQqvi1k6l9mA== + dependencies: + "@polkadot-api/substrate-bindings" "0.20.2" + "@polkadot-api/utils" "0.4.0" + +"@polkadot-api/substrate-bindings@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@polkadot-api/substrate-bindings/-/substrate-bindings-0.20.2.tgz#d0a74935e1b78583375202fb560b22b2d8001ecf" + integrity sha512-js5UTREoI+FlrPRXMhtKimVWmOqwfNFBnhyshsdloSZHNx/Hulg2RQZNvrVTscyZTf8LyxlGJaH5dsitOUoFKw== dependencies: - "@noble/hashes" "^1.8.0" - "@polkadot-api/utils" "0.1.2" - "@scure/base" "^1.2.5" + "@noble/hashes" "^2.2.0" + "@polkadot-api/utils" "0.4.0" + "@scure/base" "^2.2.0" scale-ts "^1.6.1" -"@polkadot-api/utils@0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@polkadot-api/utils/-/utils-0.1.2.tgz#45471371183efaa2fc52f40d84326d84e49c7297" - integrity sha512-yhs5k2a8N1SBJcz7EthZoazzLQUkZxbf+0271Xzu42C5AEM9K9uFLbsB+ojzHEM72O5X8lPtSwGKNmS7WQyDyg== +"@polkadot-api/utils@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@polkadot-api/utils/-/utils-0.4.0.tgz#6ee6476aa40dbdb92e4ded39d2feb9002b5b509a" + integrity sha512-9b/hwRM0UloLWV7SfpNaSD/4k8UQAHoaACAk7Xe+1MlfAm2JtnmPiB1GfGrfTyBlsrJVUIBCZpEmbmxVMaIqBA== "@rollup/rollup-linux-x64-gnu@^4.24.0": version "4.60.4" @@ -450,10 +451,10 @@ resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz" integrity sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag== -"@scure/base@^1.2.5": - version "1.2.6" - resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.6.tgz#ca917184b8231394dd8847509c67a0be522e59f6" - integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg== +"@scure/base@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.2.0.tgz#1311378ed247df6d58f8eb8941921965e97e5747" + integrity sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg== "@swc/helpers@0.5.15": version "0.5.15" diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 2e8aa459..5cc8c5a9 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -294,6 +294,9 @@ pub fn extract_api(krate: &Crate) -> Result { } for candidate in candidates { + if should_skip_type_candidate(&name, &candidate) { + continue; + } let item = krate .index .get(&candidate.item_id) @@ -393,18 +396,24 @@ fn should_skip_type_name(name: &str) -> bool { | "CancellationToken" | "FrameworkOnlyError" | "Infallible" + | "LatestOf" | "RequestId" | "RuntimeFailure" | "RuntimeFailureKind" ) } +fn should_skip_type_candidate(name: &str, candidate: &ItemCandidate) -> bool { + should_skip_type_name(name) || candidate.path.iter().any(|segment| segment == "latest") +} + fn build_name_context(type_candidates: &BTreeMap>) -> NameContext { let mut ctx = NameContext::default(); for (simple_name, candidates) in type_candidates { - if should_skip_type_name(simple_name) { - continue; - } + let candidates = candidates + .iter() + .filter(|candidate| !should_skip_type_candidate(simple_name, candidate)) + .collect::>(); let has_conflict = candidates.len() > 1; for candidate in candidates { let output_name = if has_conflict { diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index eeec62a1..d833b542 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -1901,6 +1901,12 @@ fn codec_expr_mode( _ => bail!("Unsupported primitive type `{name}` in TypeScript codec generation"), }, TypeRef::Named { name, args } => { + if name == "CallError" && args.len() == 1 { + return Ok(format!( + "S.CallError({})", + codec_expr_mode(&args[0], qualified, ctx, mode)? + )); + } let resolved = resolve_named(name, mode); let target = if qualified { qualify_named(&resolved, mode) @@ -1975,6 +1981,12 @@ fn ts_type_with_named(ty: &TypeRef, qualified: bool, mode: NameMode) -> Result bail!("Unsupported primitive type `{name}` in TypeScript type generation"), }, TypeRef::Named { name, args } => { + if name == "CallError" && args.len() == 1 { + return Ok(format!( + "S.CallErrorValue<{}>", + ts_type_with_named(&args[0], qualified, mode)? + )); + } let resolved = resolve_named(name, mode); let target = if qualified { qualify_named(&resolved, mode) diff --git a/rust/crates/truapi-codegen/src/ts/examples.rs b/rust/crates/truapi-codegen/src/ts/examples.rs index b0cc842f..11b6d151 100644 --- a/rust/crates/truapi-codegen/src/ts/examples.rs +++ b/rust/crates/truapi-codegen/src/ts/examples.rs @@ -45,6 +45,12 @@ declare global { }): Observable; /** Resolve a DotNS username to the owning raw AccountId32 hex string. Defaults to truapi.account.getUserId(). */ function accountIdForDotNsUsername(username?: string): Promise>; + /** Build a metadata-backed product-account transaction payload for `truapi.signing.createTransaction`. */ + function buildCreateTransactionPayload(opts: { + signer: import("@parity/truapi").ProductAccountId; + genesisHash: `0x${string}`; + callData: `0x${string}`; + }): Promise>; /** * Assert a condition, throwing when it does not hold. Examples signal * failure explicitly with `assert(...)`; the playground's diagnosis marks diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml new file mode 100644 index 00000000..ee4f101c --- /dev/null +++ b/rust/crates/truapi-platform/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "truapi-platform" +version = "0.1.0" +edition.workspace = true +description = "Platform capability traits for TrUAPI host implementations" +license = "MIT" + +[dependencies] +truapi = { path = "../truapi" } +async-trait = "0.1" +derive_more = { version = "2", features = ["display"] } +futures = "0.3" +parity-scale-codec = { version = "3", features = ["derive"] } +unicode-normalization = "0.1" +url = "2" + +[lints.rust] +unsafe_code = "forbid" diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md new file mode 100644 index 00000000..25067c05 --- /dev/null +++ b/rust/crates/truapi-platform/README.md @@ -0,0 +1,39 @@ +# truapi-platform + +Platform capability traits for TrUAPI host implementations. + +Each host (web/WASM, desktop, iOS/UniFFI, Android/UniFFI) implements these +traits to provide the native capabilities the shared Rust runtime cannot reach +directly. The dispatcher in `truapi-server` calls this surface while the Rust +runtime owns product account management, SSO signing, statement-store protocol +flows, permission state, and auth state transitions. + +## Type Imports + +Host-facing wire types are imported from `truapi::latest` by this crate and are +exposed through the trait signatures below. + +## Host Callback Traits + +- `ProductStorage`: product-scoped key-value storage. +- `CoreStorage`: typed core-owned storage slots such as auth session, pairing + identity, and permission authorization state. +- `Navigation`: open URLs in the system browser. +- `Notifications`: deliver and cancel push notifications. +- `Permissions`: prompt for device and remote authorizations. +- `Features`: report host feature support. +- `ChainProvider` / `JsonRpcConnection`: open JSON-RPC connections to chains. +- `AuthPresenter`: render core-owned auth state transitions. +- `UserConfirmation`: confirm signing, transaction, resource, alias, and + preimage actions before the core asks the paired wallet. +- `ThemeHost`: stream the host theme into the runtime. +- `PreimageHost`: submit and look up preimages through the host-selected backend. + +`Platform` is a blanket-implemented supertrait that combines the capability +traits above. + +## Core-Owned Admin API + +`CoreAdmin` is not part of the host-provided `Platform` callback surface. It is +the core-owned control API exposed to host UI for logout, pairing cancellation, +session-store refresh, and permission administration. diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs new file mode 100644 index 00000000..9b8ccac5 --- /dev/null +++ b/rust/crates/truapi-platform/src/lib.rs @@ -0,0 +1,618 @@ +//! Capability traits a TrUAPI host must implement. +//! +//! Each trait covers a single OS-primitive surface the Rust core cannot reach +//! from its own process (key-value persistence, URL launching, push +//! notifications, permission UI, chain RPC, host-selected preimage backends). +//! Account management, signing, and statement-store protocol flows live in the +//! Rust core itself and are not part of this trait set. +//! +//! Async capability traits use `async_trait` so the combined [`Platform`] +//! surface can be used as a trait object by the runtime. + +use futures::stream::BoxStream; +use parity_scale_codec::{Decode, Encode}; +use unicode_normalization::UnicodeNormalization; + +pub use async_trait::async_trait; + +use truapi::latest::{ + GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, + HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, + HostNavigateToError, HostPushNotificationRequest, HostPushNotificationResponse, + HostRequestResourceAllocationRequest, HostSignPayloadRequest, + HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, + PreimageSubmitError, ProductAccountTxPayload, RemotePermissionRequest, + RemotePermissionResponse, ThemeVariant, +}; +use url::Url; + +/// Role-neutral runtime configuration supplied by the embedding host. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostRuntimeConfig { + /// Host metadata. + pub host_info: HostInfo, + /// Platform metadata. + pub platform_info: PlatformInfo, +} + +/// Pairing-host runtime configuration supplied by the embedding host. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingHostConfig { + /// Host identity shown to the signing host during pairing. + pub host: HostRuntimeConfig, + /// People-chain genesis hash used for statement-store SSO. + pub people_chain_genesis_hash: [u8; 32], + /// Deeplink URI scheme used in pairing QR payloads, without `://`. + pub pairing_deeplink_scheme: String, +} + +/// Signing-host runtime configuration supplied by the embedding host. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SigningHostConfig { + /// Host identity. Not read by the local-signing paths yet; retained for + /// parity with [`PairingHostConfig`] and for the future signer-side SSO + /// responder, which advertises host identity in handshake responses. + pub host: HostRuntimeConfig, + /// People-chain genesis hash used for statement-store product calls. + pub people_chain_genesis_hash: [u8; 32], +} + +/// Product identity attached to one product-facing TrUAPI connection. +/// +/// A host may create multiple product runtimes from the same long-lived host +/// runtime, each with its own product context. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductContext { + /// Product identifier used for account derivation and product-scoped + /// storage/permission namespaces. + pub product_id: String, +} + +/// Host metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostInfo { + /// Host name. + pub name: String, + /// Optional absolute HTTPS host icon URL. + pub icon: Option, + /// Optional host version. + pub version: Option, +} + +/// Platform metadata. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PlatformInfo { + /// Optional platform/browser name. + pub kind: Option, + /// Optional platform/browser version. + pub version: Option, +} + +impl HostRuntimeConfig { + /// Build a role-neutral host runtime config, validating fields whose + /// representation cannot be made invalid by Rust types alone. + pub fn new( + host_info: HostInfo, + platform_info: PlatformInfo, + ) -> Result { + require_non_empty("host_info.name", &host_info.name)?; + if let Some(icon) = &host_info.icon { + let parsed = + Url::parse(icon).map_err(|err| RuntimeConfigValidationError::InvalidHostIcon { + reason: err.to_string(), + })?; + if parsed.scheme() != "https" { + return Err(RuntimeConfigValidationError::InsecureHostIcon { + scheme: parsed.scheme().to_string(), + }); + } + } + Ok(Self { + host_info, + platform_info, + }) + } +} + +impl PairingHostConfig { + /// Build a pairing-host runtime config, validating fields whose + /// representation cannot be made invalid by Rust types alone. + pub fn new( + host_info: HostInfo, + platform_info: PlatformInfo, + people_chain_genesis_hash: [u8; 32], + pairing_deeplink_scheme: String, + ) -> Result { + require_non_empty("pairing_deeplink_scheme", &pairing_deeplink_scheme)?; + if pairing_deeplink_scheme.contains("://") { + return Err(RuntimeConfigValidationError::InvalidDeeplinkScheme { + scheme: pairing_deeplink_scheme, + }); + } + let config = Self { + host: HostRuntimeConfig::new(host_info, platform_info)?, + people_chain_genesis_hash, + pairing_deeplink_scheme, + }; + Ok(config) + } +} + +impl SigningHostConfig { + /// Build a signing-host runtime config, validating fields whose + /// representation cannot be made invalid by Rust types alone. + pub fn new( + host_info: HostInfo, + platform_info: PlatformInfo, + people_chain_genesis_hash: [u8; 32], + ) -> Result { + Ok(Self { + host: HostRuntimeConfig::new(host_info, platform_info)?, + people_chain_genesis_hash, + }) + } +} + +impl ProductContext { + /// Build a product context, validating fields whose representation cannot + /// be made invalid by Rust types alone. + pub fn new(product_id: String) -> Result { + Ok(Self { + product_id: normalize_product_identifier(&product_id)?, + }) + } +} + +/// Whether `identifier` is a product scope the core is allowed to derive for. +pub fn is_product_identifier(identifier: &str) -> bool { + normalize_product_identifier(identifier).is_ok() +} + +/// Normalize product identifiers before derivation and policy checks. +pub fn normalize_product_identifier( + product_id: &str, +) -> Result { + let trimmed = product_id.trim(); + require_non_empty("product_id", trimmed)?; + let normalized = trimmed.nfc().collect::().to_lowercase(); + if normalized.ends_with(".dot") + || normalized == "localhost" + || normalized.starts_with("localhost:") + { + Ok(normalized) + } else { + Err(RuntimeConfigValidationError::InvalidProductId { + product_id: product_id.to_string(), + }) + } +} + +fn require_non_empty(field: &'static str, value: &str) -> Result<(), RuntimeConfigValidationError> { + if value.trim().is_empty() { + return Err(RuntimeConfigValidationError::EmptyField { field }); + } + Ok(()) +} + +/// Runtime config validation error. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +pub enum RuntimeConfigValidationError { + /// Required string field was empty or whitespace-only. + #[display("{field} must not be empty")] + EmptyField { + /// Field name. + field: &'static str, + }, + /// Host icon URL could not be parsed as an absolute HTTPS URL. + #[display("host_info.icon must be an absolute HTTPS URL: {reason}")] + InvalidHostIcon { + /// Parse failure reason. + reason: String, + }, + /// Host icon URL used a non-HTTPS scheme. + #[display("host_info.icon must use https scheme, got {scheme:?}")] + InsecureHostIcon { + /// Actual URL scheme. + scheme: String, + }, + /// Pairing deeplink scheme included a URL separator. + #[display("pairing_deeplink_scheme must not include ://, got {scheme:?}")] + InvalidDeeplinkScheme { + /// Actual deeplink scheme value. + scheme: String, + }, + /// Product id was not a `.dot` or localhost product identifier. + #[display("product_id must be a .dot or localhost product identifier, got {product_id:?}")] + InvalidProductId { + /// Actual product id value. + product_id: String, + }, +} + +impl core::error::Error for RuntimeConfigValidationError {} + +/// Product-scoped key-value storage. +/// +/// The core namespaces product keys before calling this trait. Host +/// implementations should treat `key` as an opaque OS-style storage key. +#[async_trait] +pub trait ProductStorage: Send + Sync { + /// Read a value by key. + async fn read(&self, key: String) -> Result>, HostLocalStorageReadError>; + + /// Write a value to a key. + async fn write(&self, key: String, value: Vec) -> Result<(), HostLocalStorageReadError>; + + /// Clear a value at a key. + async fn clear(&self, key: String) -> Result<(), HostLocalStorageReadError>; +} + +/// Open URLs in the system browser. Input is already trimmed, categorized, +/// and (where needed) normalized by the core; the host implementation only +/// needs to hand the URL to the OS URL handler. +#[async_trait] +pub trait Navigation: Send + Sync { + /// Open the given URL in the system browser. + async fn navigate_to(&self, url: String) -> Result<(), HostNavigateToError>; +} + +/// Deliver push notifications. +#[async_trait] +pub trait Notifications: Send + Sync { + /// Schedule or immediately display the given notification and return the + /// host-assigned id. + async fn push_notification( + &self, + notification: HostPushNotificationRequest, + ) -> Result; + + /// Cancel a notification by id. Idempotent: cancelling an already-fired or + /// unknown id still returns `Ok(())`. + async fn cancel_notification(&self, id: NotificationId) -> Result<(), GenericError> { + let _ = id; + Ok(()) + } +} + +/// Permission prompts. Device permissions (camera, mic, NFC, ...) are separate +/// from remote permissions (domain access, chain submit, ...), so the platform +/// surface mirrors that split. +#[async_trait] +pub trait Permissions: Send + Sync { + /// Prompt the user for a device-level permission. + async fn device_permission( + &self, + request: HostDevicePermissionRequest, + ) -> Result; + + /// Prompt the user for a remote (product-scoped) permission bundle. + async fn remote_permission( + &self, + request: RemotePermissionRequest, + ) -> Result; +} + +/// Permission request whose authorization status can be inspected or updated +/// by host administration UI. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum PermissionAuthorizationRequest { + /// Device-level permission such as camera, microphone, or location. + Device(HostDevicePermissionRequest), + /// Remote/product-scoped permission such as chain submit or HTTP access. + Remote(RemotePermissionRequest), +} + +/// Authorization status for a permission request. +/// +/// `NotDetermined` means the core has no persisted answer and will prompt the +/// host the next time the product requests this permission. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum PermissionAuthorizationStatus { + /// No persisted authorization exists. + NotDetermined, + /// Access is denied. + Denied, + /// Access is authorized. + Authorized, +} + +/// Core-owned administration API exposed to host UI. +/// +/// Hosts call this surface to drive global runtime actions or inspect/update +/// core-owned state without going through a product-scoped TrUAPI request. +#[async_trait] +pub trait CoreAdmin: Send + Sync { + /// Best-effort logout/disconnect. Clears the active session and emits the + /// resulting auth state transition. + async fn disconnect_session(&self) -> Result<(), GenericError>; + + /// Read a stored permission authorization status without prompting. + async fn get_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result; + + /// Read stored permission authorization statuses without prompting. + /// + /// Results are returned in the same order as `requests`. + async fn get_permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, GenericError>; + + /// Update a stored permission authorization status. `NotDetermined` clears + /// the stored value so the next product request prompts again. + async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), GenericError>; +} + +/// Pairing-host-only administration API exposed to host UI. +#[async_trait] +pub trait PairingHostAdmin: Send + Sync { + /// Cancel any in-flight pairing request. + fn cancel_pairing(&self); + + /// Notify the core that the persisted auth-session blob may have changed. + /// + /// The host owns persistence and change detection. The pairing core owns + /// decoding that blob into live `SessionState` / `AuthState`. + fn notify_session_store_changed(&self); +} + +/// Feature-support probing. The host answers whether it can service a given +/// capability (currently scoped to per-chain support). +#[async_trait] +pub trait Features: Send + Sync { + /// Report whether the requested feature is supported. + async fn feature_supported( + &self, + request: HostFeatureSupportedRequest, + ) -> Result; +} + +/// JSON-RPC provider factory for chain access. +/// +/// The platform provides a way to get a JSON-RPC connection for a given chain. +/// The server runtime manages the chainHead v1 state machine on top of this. +#[async_trait] +pub trait ChainProvider: Send + Sync { + /// Open a JSON-RPC connection for the chain identified by `genesis_hash`. + /// Drop the returned connection to disconnect. + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, GenericError>; +} + +/// A live JSON-RPC connection to a chain. +pub trait JsonRpcConnection: Send + Sync { + /// Send a JSON-RPC request string. + fn send(&self, request: String); + + /// Stream of JSON-RPC response strings. + fn responses(&self) -> BoxStream<'static, String>; + + /// Close the connection lease. + /// + /// Hosts may keep a shared underlying transport alive, but this handle + /// must stop receiving responses and release any per-caller resources. + fn close(&self); +} + +/// Core-owned host-private storage slots. Products never address these slots; +/// the host chooses the backing store for each slot. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CoreStorageKey { + /// Opaque SSO/auth session blob. + AuthSession, + /// Pairing device identity used during SSO flows. + PairingDeviceIdentity, + /// Persisted authorization for one product-scoped permission request. + PermissionAuthorization { + /// Product whose permission decision is being stored. + product_id: String, + /// Permission request whose authorization is being stored. + request: PermissionAuthorizationRequest, + }, +} + +/// Host-private persistence for core-owned state. +#[async_trait] +pub trait CoreStorage: Send + Sync { + /// Read a core-owned value by typed slot. + async fn read_core_storage(&self, key: CoreStorageKey) + -> Result>, GenericError>; + + /// Write a core-owned value by typed slot. + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError>; + + /// Clear a core-owned value by typed slot. + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError>; +} + +/// Decoded session fields a host shell needs to render account UI without +/// parsing the opaque session blob the core persists through [`CoreStorage`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionUiInfo { + /// 32-byte sr25519 root public key of the active session. + pub public_key: [u8; 32], + /// Wallet identity account id used for People-chain username lookup. + pub identity_account_id: Option<[u8; 32]>, + /// Short username from the People-chain identity record. + pub lite_username: Option, + /// Fully qualified username from the People-chain identity record. + pub full_username: Option, +} + +/// Auth/session lifecycle state the core projects for host UI. The core owns +/// every transition and emits states in order; hosts render the current state +/// and never derive auth UI from any other signal. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum AuthState { + /// No active session and no login in progress. + #[default] + Disconnected, + /// A login is in progress: present the pairing deeplink/QR. Leave this + /// state only on a subsequent emission (connected, failed, or + /// disconnected after cancellation). + Pairing { + /// Wallet pairing deeplink to render as a QR code or open directly. + deeplink: String, + }, + /// A session is active. + Connected(SessionUiInfo), + /// The last login attempt failed; show the reason and offer a retry. + LoginFailed { + /// Human-readable failure reason. + reason: String, + }, +} + +/// Host auth UI driven by core-owned [`AuthState`] transitions. +pub trait AuthPresenter: Send + Sync { + /// Observe an auth state change. Emitted only when the state actually + /// changes, in transition order. Default is a no-op for hosts that + /// render no auth UI. + fn auth_state_changed(&self, state: AuthState) { + let _ = state; + } +} + +/// Review shown before a sign-payload request is sent to the paired wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SignPayloadReview { + /// Product-account signing request. + Product(HostSignPayloadRequest), + /// Legacy-account signing request. + LegacyAccount(HostSignPayloadWithLegacyAccountRequest), +} + +/// Review shown before a sign-raw request is sent to the paired wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SignRawReview { + /// Product-account raw signing request. + Product(HostSignRawRequest), + /// Legacy-account raw signing request. + LegacyAccount(HostSignRawWithLegacyAccountRequest), +} + +/// Review shown before a transaction-creation request is sent to the paired wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CreateTransactionReview { + /// Product-account transaction request. + Product(ProductAccountTxPayload), + /// Legacy-account transaction request. + LegacyAccount(LegacyAccountTxPayload), +} + +/// Review shown before a product asks to alias another product account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct AccountAliasReview { + /// Product currently handling the request. + pub requesting_product_id: String, + /// Product whose account is being requested. + pub target_product_id: String, +} + +/// Review shown before a preimage is submitted. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct PreimageSubmitReview { + /// Size of the preimage in bytes. + pub size: u64, +} + +/// Review shown before a user-confirmed core action continues. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum UserConfirmationReview { + /// Sign a SCALE payload with a product or legacy account. + SignPayload(SignPayloadReview), + /// Sign raw bytes with a product or legacy account. + SignRaw(SignRawReview), + /// Create a transaction with a product or legacy account. + CreateTransaction(CreateTransactionReview), + /// Allow a product to request another product account alias. + AccountAlias(AccountAliasReview), + /// Allocate resources for the requesting product. + ResourceAllocation(HostRequestResourceAllocationRequest), + /// Submit a preimage to the host-selected backend. + PreimageSubmit(PreimageSubmitReview), +} + +/// Local user confirmation UI for session-channel operations. +#[async_trait] +pub trait UserConfirmation: Send + Sync { + /// Confirm a reviewed action before the core asks the SSO peer. + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result; +} + +/// Host theme source. +pub trait ThemeHost: Send + Sync { + /// Emits current theme immediately, then future changes. + fn subscribe_theme(&self) -> BoxStream<'static, Result>; +} + +/// Host preimage backend. The core owns wire mapping and subscription +/// lifecycle; the host owns the selected backend. +#[async_trait] +pub trait PreimageHost: Send + Sync { + /// Submit the preimage and return its key. + async fn submit_preimage(&self, value: Vec) -> Result, PreimageSubmitError> { + let _ = value; + Err(PreimageSubmitError::Unknown { + reason: "submitPreimage callback not provided by host".to_string(), + }) + } + + /// Emits current value/miss immediately, then future updates. + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, GenericError>>; +} + +/// Combined platform interface. A host must provide all capability traits. +pub trait Platform: + Navigation + + Notifications + + Permissions + + Features + + ProductStorage + + CoreStorage + + ChainProvider + + AuthPresenter + + UserConfirmation + + ThemeHost + + PreimageHost +{ +} + +impl Platform for T where + T: Navigation + + Notifications + + Permissions + + Features + + ProductStorage + + CoreStorage + + ChainProvider + + AuthPresenter + + UserConfirmation + + ThemeHost + + PreimageHost +{ +} diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs new file mode 100644 index 00000000..5a1733c4 --- /dev/null +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -0,0 +1,135 @@ +//! Compile-time check that the `Platform` super-trait composes its capability +//! traits with `Send + Sync + 'static` bounds and remains object-safe via +//! `async_trait`. + +use truapi_platform::{ + HostInfo, HostRuntimeConfig, PairingHostConfig, Platform, PlatformInfo, ProductContext, + RuntimeConfigValidationError, +}; + +fn _assert_platform_bounds() {} + +fn _assert_platform_object_safe(_: &(dyn Platform + 'static)) {} + +#[test] +fn runtime_config_validation_cases() { + struct TestCase { + name: &'static str, + host_name: &'static str, + host_icon: Option<&'static str>, + expected: Result<(), RuntimeConfigValidationError>, + } + + let cases = vec![ + TestCase { + name: "accepts HTTPS host icon", + host_name: "Polkadot Web", + host_icon: Some("https://dot.li/dotli.png"), + expected: Ok(()), + }, + TestCase { + name: "rejects empty host name", + host_name: " ", + host_icon: Some("https://dot.li/dotli.png"), + expected: Err(RuntimeConfigValidationError::EmptyField { + field: "host_info.name", + }), + }, + TestCase { + name: "rejects relative host icon", + host_name: "Polkadot Web", + host_icon: Some("/dotli.png"), + expected: Err(RuntimeConfigValidationError::InvalidHostIcon { + reason: "relative URL without a base".to_string(), + }), + }, + TestCase { + name: "rejects non-HTTPS host icon", + host_name: "Polkadot Web", + host_icon: Some("http://localhost:3000/dotli.png"), + expected: Err(RuntimeConfigValidationError::InsecureHostIcon { + scheme: "http".to_string(), + }), + }, + ]; + + for case in cases { + let result = HostRuntimeConfig::new( + HostInfo { + name: case.host_name.to_string(), + icon: case.host_icon.map(str::to_string), + version: None, + }, + PlatformInfo::default(), + ) + .map(|_| ()); + assert_eq!(result, case.expected, "{}", case.name); + } +} + +#[test] +fn pairing_config_validation_cases() { + struct TestCase { + name: &'static str, + host_name: &'static str, + host_icon: Option<&'static str>, + pairing_deeplink_scheme: &'static str, + expected: Result<(), RuntimeConfigValidationError>, + } + + let cases = vec![TestCase { + name: "rejects malformed deeplink scheme", + host_name: "Polkadot Web", + host_icon: Some("https://dot.li/dotli.png"), + pairing_deeplink_scheme: "polkadotapp://", + expected: Err(RuntimeConfigValidationError::InvalidDeeplinkScheme { + scheme: "polkadotapp://".to_string(), + }), + }]; + + for case in cases { + let result = PairingHostConfig::new( + HostInfo { + name: case.host_name.to_string(), + icon: case.host_icon.map(str::to_string), + version: None, + }, + PlatformInfo::default(), + [0xa2; 32], + case.pairing_deeplink_scheme.to_string(), + ) + .map(|_| ()); + assert_eq!(result, case.expected, "{}", case.name); + } +} + +#[test] +fn product_context_validation_cases() { + let dotli = ProductContext::new("Dotli.DOT".to_string()).expect("dot product id is valid"); + assert_eq!(dotli.product_id, "dotli.dot"); + + let localhost = + ProductContext::new(" localhost:3000 ".to_string()).expect("localhost product id is valid"); + assert_eq!(localhost.product_id, "localhost:3000"); + + assert_eq!( + ProductContext::new("localhost".to_string()).map(|context| context.product_id), + Ok("localhost".to_string()) + ); + assert_eq!( + ProductContext::new("dotli.dot".to_string()).map(|_| ()), + Ok(()) + ); + assert_eq!( + ProductContext::new("example.com".to_string()).map(|_| ()), + Err(RuntimeConfigValidationError::InvalidProductId { + product_id: "example.com".to_string(), + }) + ); + assert_eq!( + ProductContext::new(" ".to_string()).map(|_| ()), + Err(RuntimeConfigValidationError::EmptyField { + field: "product_id", + }) + ); +} diff --git a/rust/crates/truapi/src/api/mod.rs b/rust/crates/truapi/src/api.rs similarity index 100% rename from rust/crates/truapi/src/api/mod.rs rename to rust/crates/truapi/src/api.rs diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 7c4e065f..83211328 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -86,10 +86,9 @@ pub trait Account: Send + Sync { /// }, /// ringLocation: { /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// ringRootHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", - /// hints: { palletInstance: 42 }, + /// ringRootHash: "0x...", /// }, - /// context: "0x", + /// context: "0x48656c6c6f", /// }); /// assert(result.isOk(), "createAccountProof failed:", result); /// console.log("account proof created:", result.value); diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index 6bc4db1e..699609f1 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -20,18 +20,19 @@ pub trait Signing: Send + Sync { /// Construct a signed transaction for a product account. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; /// - /// const result = await truapi.signing.createTransaction({ + /// const payload = await buildCreateTransactionPayload({ /// signer: { /// dotNsIdentifier: "truapi-playground.dot", /// derivationIndex: 0, /// }, - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// callData: "0x0000", - /// extensions: [], - /// txExtVersion: 0, + /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// callData: "0x000000", /// }); + /// assert(payload.isOk(), "buildCreateTransactionPayload failed:", payload); + /// + /// const result = await truapi.signing.createTransaction(payload.value); /// assert(result.isOk(), "createTransaction failed:", result); /// console.log("transaction created:", result.value); /// ``` @@ -47,18 +48,27 @@ pub trait Signing: Send + Sync { /// Construct a signed transaction for a non-product (legacy) account. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; /// - /// const signerResult = await accountIdForDotNsUsername(); - /// assert(signerResult.isOk(), "accountIdForDotNsUsername failed:", signerResult); - /// console.log("fetched user account:", signerResult.value); + /// const accountsResult = await truapi.account.getLegacyAccounts(); + /// assert(accountsResult.isOk(), "getLegacyAccounts failed:", accountsResult); + /// const legacyAccount = accountsResult.value.accounts[0]; + /// assert(legacyAccount, "no legacy accounts available"); + /// console.log("selected legacy account:", legacyAccount); + /// + /// const payload = await buildCreateTransactionPayload({ + /// signer: { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: 0, + /// }, + /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// callData: "0x000000", + /// }); + /// assert(payload.isOk(), "buildCreateTransactionPayload failed:", payload); /// /// const result = await truapi.signing.createTransactionWithLegacyAccount({ - /// signer: signerResult.value, - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// callData: "0x0000", - /// extensions: [], - /// txExtVersion: 0, + /// ...payload.value, + /// signer: legacyAccount.publicKey, /// }); /// assert(result.isOk(), "createTransactionWithLegacyAccount failed:", result); /// console.log("transaction created:", result.value); @@ -78,8 +88,13 @@ pub trait Signing: Send + Sync { /// Sign raw bytes with a non-product account. /// /// ```ts + /// const accountsResult = await truapi.account.getLegacyAccounts(); + /// assert(accountsResult.isOk(), "getLegacyAccounts failed:", accountsResult); + /// const legacyAccount = accountsResult.value.accounts[0]; + /// assert(legacyAccount, "no legacy accounts available"); + /// /// const result = await truapi.signing.signRawWithLegacyAccount({ - /// signer: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + /// signer: legacyAccount.publicKey, /// payload: { /// tag: "Bytes", /// value: { bytes: "0x48656c6c6f" }, @@ -103,8 +118,13 @@ pub trait Signing: Send + Sync { /// ```ts /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// + /// const accountsResult = await truapi.account.getLegacyAccounts(); + /// assert(accountsResult.isOk(), "getLegacyAccounts failed:", accountsResult); + /// const legacyAccount = accountsResult.value.accounts[0]; + /// assert(legacyAccount, "no legacy accounts available"); + /// /// const result = await truapi.signing.signPayloadWithLegacyAccount({ - /// signer: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + /// signer: legacyAccount.publicKey, /// payload: { /// blockHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", /// blockNumber: "0x00000000", diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 5addc9b7..16cfc8cd 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -6,7 +6,8 @@ use crate::versioned::statement_store::{ RemoteStatementStoreCreateProofAuthorizedResponse, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubmitError, RemoteStatementStoreSubmitRequest, - RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, + RemoteStatementStoreSubscribeError, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, }; use crate::wire; use crate::{CallContext, CallError, Subscription}; @@ -56,8 +57,11 @@ pub trait StatementStore: Send + Sync { &self, _cx: &CallContext, _request: RemoteStatementStoreSubscribeRequest, - ) -> Subscription { - Subscription::empty() + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::unavailable()) } /// Create a proof for a statement. diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 7637a67b..b4baf520 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -1,30 +1,73 @@ //! TrUAPI trait and type definitions for the host product SDK. //! -//! Concrete wire types live in per-version modules (currently [`v01`]). -//! Versioned envelopes are in [`versioned`]. +//! Concrete wire types live in per-version modules. Versioned envelopes are in +//! [`versioned`]. #![forbid(unsafe_code)] #![allow(async_fn_in_trait)] -use std::convert::Infallible; -use std::pin::Pin; +use core::convert::Infallible; +use core::pin::Pin; +use core::task::{Context, Poll}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::task::{Context, Poll}; use futures::Stream; +use parity_scale_codec::{Decode, Encode}; pub mod api; pub mod v01; pub mod versioned; +pub mod latest { + use crate::versioned::{self, Versioned}; + + pub use crate::v01::{ + AccountId, AllocatableResource, GenericError, HostSignPayloadData, NotificationId, + ProductAccountId, RawPayload, RemotePermission, ThemeVariant, + }; + + pub type LatestOf = ::Latest; + + pub type HostAccountGetAliasResponse = + LatestOf; + pub type HostDevicePermissionRequest = + LatestOf; + pub type HostDevicePermissionResponse = + LatestOf; + pub type HostFeatureSupportedRequest = LatestOf; + pub type HostFeatureSupportedResponse = + LatestOf; + pub type HostLocalStorageReadError = + LatestOf; + pub type HostNavigateToError = LatestOf; + pub type HostPushNotificationRequest = + LatestOf; + pub type HostPushNotificationResponse = + LatestOf; + pub type HostRequestResourceAllocationRequest = + LatestOf; + pub type HostSignPayloadRequest = LatestOf; + pub type HostSignPayloadWithLegacyAccountRequest = + LatestOf; + pub type HostSignRawRequest = LatestOf; + pub type HostSignRawWithLegacyAccountRequest = + LatestOf; + pub type LegacyAccountTxPayload = + LatestOf; + pub type PreimageSubmitError = LatestOf; + pub type ProductAccountTxPayload = LatestOf; + pub type RemotePermissionRequest = LatestOf; + pub type RemotePermissionResponse = LatestOf; +} + pub use truapi_macros::wire; /// Per-message id carried from the transport frame. pub type RequestId = String; /// Framework-level outcomes shared by API methods. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CallError { /// Method-specific failure. Domain(D), diff --git a/rust/crates/truapi/src/v01/mod.rs b/rust/crates/truapi/src/v01.rs similarity index 100% rename from rust/crates/truapi/src/v01/mod.rs rename to rust/crates/truapi/src/v01.rs diff --git a/rust/crates/truapi/src/versioned/mod.rs b/rust/crates/truapi/src/versioned.rs similarity index 100% rename from rust/crates/truapi/src/versioned/mod.rs rename to rust/crates/truapi/src/versioned.rs diff --git a/rust/crates/truapi/src/versioned/statement_store.rs b/rust/crates/truapi/src/versioned/statement_store.rs index 979885ba..ed31d47e 100644 --- a/rust/crates/truapi/src/versioned/statement_store.rs +++ b/rust/crates/truapi/src/versioned/statement_store.rs @@ -5,6 +5,7 @@ use crate::v01; truapi_macros::versioned_type! { pub enum RemoteStatementStoreSubscribeRequest { V1 => v01::RemoteStatementStoreSubscribeRequest } pub enum RemoteStatementStoreSubscribeItem { V1 => v01::RemoteStatementStoreSubscribeItem } + pub enum RemoteStatementStoreSubscribeError { V1 => v01::GenericError } pub enum RemoteStatementStoreCreateProofRequest { V1 => v01::RemoteStatementStoreCreateProofRequest } pub enum RemoteStatementStoreCreateProofResponse { V1 => v01::RemoteStatementStoreCreateProofResponse } pub enum RemoteStatementStoreCreateProofError { V1 => v01::RemoteStatementStoreCreateProofError }