diff --git a/Cargo.lock b/Cargo.lock index 0e38dbf43..c78f6d006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4998,6 +4998,7 @@ dependencies = [ "anyhow", "arboard", "async-trait", + "base64", "bip39", "clap", "crossterm", diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index 8c4b0fb93..43fa9c560 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -84,6 +84,11 @@ export interface ProductRuntimeConfig { /** Bulletin-chain genesis hash. */ genesisHash: string | Uint8Array; }; + /** Asset Hub configuration used to resolve session usernames from dotNS. */ + assetHub: { + /** Asset Hub genesis hash. */ + genesisHash: string | Uint8Array; + }; /** Wallet pairing configuration. */ pairing: { /** URI scheme used for wallet pairing deeplinks. */ diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 3c75785d5..026c46eb7 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -94,6 +94,10 @@ function runtimeConfig( genesisHash: "0xbbcccc1cbe333151b8ed63b17e9e0dec61ee53b57296f1fbe2d161ae3e6fb4dc", }, + assetHub: { + genesisHash: + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + }, pairing: { deeplinkScheme: "polkadotapp", }, diff --git a/playground/src/lib/example-helpers.ts b/playground/src/lib/example-helpers.ts index 47e436ccd..f624522fc 100644 --- a/playground/src/lib/example-helpers.ts +++ b/playground/src/lib/example-helpers.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; import { err, ok, type Result } from "neverthrow"; -import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; +import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; import { AccountId, Blake2128Concat, @@ -51,7 +51,10 @@ export type BuildCreateTransactionPayload = (opts: { callData: HexString; }) => Promise>; -const usernameOwnerOfStorage = Storage("Resources")("UsernameOwnerOf", [ +// Lite usernames are keyed by their dotted label ("alice.01") on the Asset +// Hub gateway pallet. Full-person ownership lives contract-side as H160 only. +// Name→account resolution therefore covers lite usernames. +const liteLabelOwnerStorage = Storage("DotnsGateway")("LiteLabelOwner", [ Bytes(), Blake2128Concat, ]); @@ -113,7 +116,7 @@ export function createAccountIdForDotNsUsername( return err(new Error("DotNS username is empty")); } - const key = usernameOwnerOfStorage.enc( + const key = liteLabelOwnerStorage.enc( new TextEncoder().encode(dotNsUsername), ) as HexString; @@ -137,7 +140,7 @@ export function createAccountIdForDotNsUsername( switch (item.tag) { case "Initialized": { const result = await truapi.chain.getHeadStorage({ - genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, followSubscriptionId: sub.subscriptionId, hash: item.value.finalizedBlockHashes[0], items: [{ key, queryType: "Value" }], @@ -188,7 +191,7 @@ export function createAccountIdForDotNsUsername( const sub = truapi.chain .followHeadSubscribe({ request: { - genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, withRuntime: false, }, }) diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 72ef36136..c6ebdbe3b 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -19,6 +19,7 @@ truapi-server = { path = "../truapi-server" } anyhow = "1" arboard = { version = "3.6.1", default-features = false } async-trait = "0.1" +base64 = "0.22" bip39 = { version = "2", features = ["rand"] } clap = { version = "4", features = ["derive", "env"] } crossterm = { version = "0.29", features = ["event-stream"] } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 337bc2449..2f6af47a1 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -391,21 +391,22 @@ cleans up a unique temporary Unix socket. ## Scope / gaps -- **Chain methods** route to real `wss://` nodes from the selected `--network` - when `E2E_LIVE_CHAIN=1`; off by default. A rustls crypto provider is - installed at startup for the TLS connections. +- **Chain methods** route to real `wss://` nodes from the selected `--network`. + People, Bulletin, and Asset Hub are host-required and always enabled. + `E2E_LIVE_CHAIN=1` additionally enables any preset route marked optional. A + rustls crypto provider is installed at startup for the TLS connections. - **Ring-VRF product-account aliases and proofs** are implemented by the signing host via the `verifiable` crate (`get_account_alias` and `create_account_proof`). -- **`get_user_id`** resolves the signing account's username from People-chain - `Resources.Consumers`. Auto-managed signing accounts register fresh lite - usernames via the identity backend (`src/attestation.rs`); first registration - is backend-async and can take minutes (ring onboarding). `truapi-host - identity-check --mnemonic ` probes which derivation carries a username. +- **`get_user_id`** resolves the signing account's username from the dotNS + contracts on Asset Hub. Auto-managed signing accounts register fresh lite + usernames via the identity backend (`src/attestation.rs`). First registration + is backend-async and can take minutes, waiting on ring onboarding. + `truapi-host identity-check --mnemonic ` probes which derivation carries a + username. - `set_statement_store_account` and Bulletin long-term-storage resource allocation are implemented over SSO on native headless hosts. - Everything else the browser host exercises passes: signing (raw, payload, create-transaction, and their legacy variants), statement store, entropy, - aliases, preimage, storage, permissions, notifications, theme, system, chain - (with `E2E_LIVE_CHAIN=1`), and user id, subject to live chain availability - and allowance-slot capacity. + aliases, preimage, storage, permissions, notifications, theme, system, chain, + and user id, subject to live chain availability and allowance-slot capacity. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 0ef6c77d4..17619f041 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -114,7 +114,7 @@ as the paired path. `truapi-host-cli` owns: - argument and slash-command parsing; -- the single supported network preset; +- the supported network presets (`paseo-next-v2`, `previewnet`); - local signer selection and onboarding; - local persistence and account-store locking; - approvals and `--auto-accept`; @@ -198,7 +198,8 @@ Commands: | --- | --- | | `pairing-host` | Run the seedless product-facing host. | | `signing-host` | Run the wallet-local signing host. | -| `identity-check` | Probe People-chain identity records for a mnemonic. | +| `identity-check` | Probe dotNS identity records on Asset Hub for a mnemonic. | +| `register-name` | Register a full-person username via `DotnsGateway.register_name`. | | `alloc-check` | Inspect or submit Statement Store allowance registration. | ### 4.1 Global logging option @@ -230,7 +231,7 @@ truapi-host pairing-host [options] | `--product-id ` | `headless-playground.dot` | Initial product scope. | | `--frame-listen ` | none | Opt into a TCP product WebSocket listener. When omitted, use a private per-process Unix socket. Port `0` selects an available TCP port. | | `--base-path ` | section 12.1 | Root for network, identity, core, script, and product state. | -| `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset. | +| `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset (`paseo-next-v2`, `previewnet`). | | `--auto-accept` | off | Approve platform confirmations automatically. | Without `--script`, both stdin and stdout must be terminals. The command enters @@ -268,7 +269,7 @@ truapi-host signing-host [options] [exec ''] | `--session ` | remembered session | Restore or create a managed session. | | `--lite-username-prefix ` | session-derived | Prefix for newly generated Lite username bases. | | `--base-path ` | section 12.1 | Root for account, session, core, script, and product state. | -| `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset. | +| `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset (`paseo-next-v2`, `previewnet`). | | `--frame-listen ` | none | Opt into a TCP product WebSocket listener. When omitted, use a private per-process Unix socket. Port `0` is allowed. | | `--auto-accept` | off | Approve platform confirmations automatically. | @@ -797,14 +798,27 @@ A new auto account: 4. chooses `auto-` as its local name; 5. tries up to eight available Lite username bases; 6. saves a pending account record; -7. builds and submits identity-backend registration proofs; -8. polls `Resources.Consumers` for the final `name.discriminator`; +7. builds and submits identity-backend registration proofs, including the dotNS + gateway reservation signature timestamped with Asset Hub chain time; +8. polls the dotNS contracts on Asset Hub for the final `name.discriminator`; 9. waits for inclusion in a LitePeople ring; and 10. marks and saves the account as attested. Identity and ring polling each allow 10 attempts with four seconds between attempts. Identity-backend HTTP clients use a 30-second timeout. +The backend's username routes are bearer-gated. Unless +`HOST_CLI_IDENTITY_BACKEND_TOKEN` supplies one, the CLI mints an access token +once per process. It takes a challenge from `auth/challenges`. It answers +`auth/token` with an sr25519 proof over +`SHA256(challenge || clientId || SHA256(body))`, signed by a throwaway keypair. + +The token's subject only identifies the calling app instance. The username claim +carries its own candidate account. A fresh subject per run therefore stays clear +of the backend's per-subject device gate and rate limit. Availability answers are +read from both wire shapes: the `{_tag, value: {base: {status}}}` record and the +flat `{base: "AVAILABLE"}` map. + The default Lite username prefix is `headless`. For a non-default session, the prefix is its lowercase letters with digits and separators removed; a name with no letters becomes `session`. `--lite-username-prefix` overrides this and @@ -1097,8 +1111,9 @@ v0.1 supports only `paseo-next-v2`. There are no public endpoint override flags. -People and Bulletin routes are always enabled because host internals require -them. Asset Hub routing is enabled only when `E2E_LIVE_CHAIN=1`. +People, Bulletin, and Asset Hub routes are always enabled because host +internals require them. `E2E_LIVE_CHAIN=1` additionally enables any preset +route marked optional. The all-zero SSO sentinel and every genesis hash not present in the active route map fall back to the People RPC. @@ -1148,7 +1163,7 @@ surface. | Service | Implemented behavior | | --- | --- | | Account | Connection status, product accounts, aliases, proofs, empty legacy-account list, user id, and login. | -| Chain | chainHead-v1 follow/header/body/storage/call/unpin/continue/stop, chain spec queries, transaction broadcast/stop. Asset Hub needs `E2E_LIVE_CHAIN=1`. | +| Chain | chainHead-v1 follow/header/body/storage/call/unpin/continue/stop, chain spec queries, transaction broadcast/stop. People, Bulletin, and Asset Hub all route to the preset's live nodes. | | Entropy | Product-scoped deterministic entropy from the active account/session. | | Local Storage | Persistent product-scoped read, write, and clear. | | Notifications | In-process immediate/scheduled delivery and cancellation with transcript events. | @@ -1490,12 +1505,15 @@ ended. This preserves the child status but bypasses later Rust destructors. | `RUST_LOG` | Full startup tracing filter. | | `TRUAPI_HOST_BASE_PATH` | Default `--base-path`. | | `HOST_CLI_SIGNER_MNEMONIC` | Signing, identity, and allowance mnemonic input. | +| `HOST_CLI_IDENTITY_BACKEND_BASE` | Identity backend base URL override, including `/api/v1`, for instance a local backend. Chain endpoints stay on the preset. | +| `HOST_CLI_IDENTITY_BACKEND_TOKEN` | Bearer token for the identity backend's username routes. Unset, the CLI mints one itself through the backend's `auth/challenges` → `auth/token` sr25519 handshake with a throwaway keypair. | +| `HOST_CLI_DOTNS_POP_CONTROLLER` | `DotnsPopController` H160 override, skipping on-chain discovery. Required on networks whose deployed dispatcher exposes no target getter. On paseo-next-v2 that address is `0x1c858C31497a7715C0D56A11208feB6b74FaB2aB`. | | `XDG_STATE_HOME` | Preferred default state parent. | | `HOME` | Fallback default state parent. | | `VISUAL` | Preferred script editor. | | `EDITOR` | Fallback script editor. | | `TRUAPI_HOST_RUNNER` | Override `js/runner.ts`. | -| `E2E_LIVE_CHAIN` | Value `1` enables optional Asset Hub routing. | +| `E2E_LIVE_CHAIN` | Value `1` enables optional live-chain routing. Every preset route is currently host-required, so this is a no-op. | | `NO_COLOR` | Disable CLI semantic colors and battery reporter color. | | `COLORFGBG` | Infer TUI background color. | | `COLORTERM` | Select true-color TUI rendering. | diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs index dfd500c1f..d71953864 100644 --- a/rust/crates/truapi-host-cli/src/accounts.rs +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -428,7 +428,7 @@ async fn ensure_record_ready( record.attested = true; } else { record.lite_username = - attestation::registered_lite_username(network.people_ws, &identity.entropy) + attestation::registered_lite_username(network.asset_hub_ws, &identity.entropy) .await .with_context(|| format!("resolve Lite username for account {}", record.name))?; } @@ -447,9 +447,10 @@ async fn attest_record(network: NetworkConfig, record: &AccountRecord) -> Result let entropy = mnemonic_entropy(&record.mnemonic)?; let lite_username = attestation::attest(&attestation::AttestConfig { backend_base: network.identity_backend_base.to_string(), - people_ws: network.people_ws.to_string(), + asset_hub_ws: network.asset_hub_ws.to_string(), entropy, username_base: record.lite_username.clone(), + reserved_username: None, }) .await .with_context(|| format!("attest account {}", record.name))?; diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index ad906e270..c89ddf6fb 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -1,35 +1,149 @@ //! Lite-username attestation against the People-chain identity backend. //! -//! Ports signing-bot `attestation.ts`: fetch the backend verifier, build the -//! client proofs (`truapi_server::host_logic::attestation`), POST them to -//! `/usernames`, then poll People-chain `Resources.Consumers` until the record -//! lands. Registers the signing host's RFC-0022 `uid.dot` identity account so -//! the paired host can resolve its username via `get_user_id`. +//! Fetches the backend verifier. Builds the client proofs +//! (`truapi_server::host_logic::attestation`), including the dotNS gateway +//! reservation signature timestamped with Asset Hub chain time. POSTs them to +//! `/usernames`. Polls the dotNS contracts on Asset Hub until the lite username +//! lands. +//! +//! Registers the signing host's RFC-0022 `uid.dot` identity account. The paired +//! host can then resolve its username via `get_user_id`. use std::time::Duration; use anyhow::{Context, Result, bail}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use bip39::Mnemonic; use serde_json::{Value, json}; -use subxt_rpcs::client::{RpcClient, rpc_params}; +use sha2::{Digest as _, Sha256}; +use tokio::sync::OnceCell; use tracing::{debug, warn}; use truapi_server::host_logic::attestation::build_lite_registration; -use truapi_server::host_logic::identity::{ - decode_people_identity, resources_consumers_storage_key, -}; use truapi_server::host_logic::product_account::{ - derive_identity_keypair, derive_root_keypair_from_entropy, product_public_key_to_address, + SR25519_SIGNING_CONTEXT, derive_identity_keypair, derive_root_keypair_from_entropy, + product_public_key_to_address, }; +use crate::dotns_read::AssetHubReader; + +/// Env var carrying an optional bearer token for the identity backend. +/// Set it to reuse a token minted elsewhere. Unset, the CLI runs the sr25519 +/// auth handshake itself ([`backend_token`]). +pub const IDENTITY_BACKEND_TOKEN_ENV: &str = "HOST_CLI_IDENTITY_BACKEND_TOKEN"; + +/// Access token for the username routes, minted once per process. +static BACKEND_TOKEN: OnceCell = OnceCell::const_new(); + +/// Bearer token for the identity backend's username routes. +/// +/// The explicit env token wins. Otherwise the CLI completes the backend's +/// `challenges` → `token` sr25519 handshake with a throwaway keypair. The JWT +/// subject only identifies the calling app instance. The username claim carries +/// its own candidate account. A fresh subject each run keeps repeat +/// registrations clear of the per-subject device gate and rate limit. +async fn backend_token(client: &reqwest::Client, backend_base: &str) -> Result<&'static str> { + if let Ok(token) = std::env::var(IDENTITY_BACKEND_TOKEN_ENV) { + let token = token.trim(); + if !token.is_empty() { + return Ok(Box::leak(token.to_string().into_boxed_str())); + } + } + BACKEND_TOKEN + .get_or_try_init(|| mint_backend_token(client, backend_base)) + .await + .map(String::as_str) +} + +/// Runs the backend's auth handshake and returns its access JWT. +async fn mint_backend_token(client: &reqwest::Client, backend_base: &str) -> Result { + let url = format!("{backend_base}/auth/challenges"); + let body: Value = client + .post(&url) + .json(&json!({})) + .send() + .await + .with_context(|| format!("POST {url}"))? + .error_for_status()? + .json() + .await + .context("decoding challenge response")?; + let challenge = body + .get("challenge") + .and_then(Value::as_str) + .context("challenge response missing 'challenge' field")? + .to_string(); + let challenge_bytes = BASE64 + .decode(&challenge) + .context("challenge is not valid base64")?; + + let entropy = Mnemonic::generate(12) + .context("generate throwaway auth mnemonic")? + .to_entropy(); + let keypair = derive_root_keypair_from_entropy(&entropy) + .map_err(|err| anyhow::anyhow!("auth keypair derivation failed: {err}"))?; + let client_id = keypair.public.to_bytes(); + + // The proof signs SHA256(challenge || clientId || SHA256(body)). It must + // cover the exact bytes the request carries, so the body is serialized once. + let payload = b"{}"; + let mut hasher = Sha256::new(); + hasher.update(&challenge_bytes); + hasher.update(client_id); + hasher.update(Sha256::digest(payload)); + let message: [u8; 32] = hasher.finalize().into(); + let proof = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + + let url = format!("{backend_base}/auth/token"); + let response = client + .post(&url) + .header("Auth-ClientId", BASE64.encode(client_id)) + .header("Auth-ClientProof", BASE64.encode(proof)) + .header("Auth-Challenge", &challenge) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(payload.as_slice()) + .send() + .await + .with_context(|| format!("POST {url}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("identity backend auth handshake failed ({status}): {body}"); + } + let body: Value = serde_json::from_str(&body).context("decoding token response")?; + let token = body + .get("token") + .and_then(Value::as_str) + .context("token response missing 'token' field")?; + debug!("minted identity backend access token"); + Ok(token.to_string()) +} + +/// Attaches the identity backend bearer token to `request`. +async fn with_backend_auth( + client: &reqwest::Client, + backend_base: &str, + request: reqwest::RequestBuilder, +) -> Result { + Ok(request.bearer_auth(backend_token(client, backend_base).await?)) +} + /// Inputs for one attestation run. pub struct AttestConfig { /// Identity backend base URL including `/api/v1`. pub backend_base: String, - /// People-chain WebSocket URL for the `Resources.Consumers` poll. - pub people_ws: String, + /// Asset Hub WebSocket URL for the reservation timestamp and the dotNS + /// username poll. + pub asset_hub_ws: String, /// BIP-39 entropy of the signing host's root account. pub entropy: Vec, /// Requested lite username base (6+ lowercase letters, no digits). pub username_base: String, + /// Optional base name to queue on dotNS for a later full-person claim. + pub reserved_username: Option, } /// Check whether a lite username base is available through the identity @@ -40,35 +154,62 @@ pub async fn lite_username_available(backend_base: &str, username_base: &str) -> .build()?; let url = format!("{backend_base}/usernames/available"); let body = json!({ "usernames": [username_base] }); - let response = client - .post(&url) - .json(&body) + let response = with_backend_auth(&client, backend_base, client.post(&url).json(&body)) + .await? .send() .await - .with_context(|| format!("POST {url}"))? - .error_for_status() - .with_context(|| format!("username availability check failed for {username_base}"))?; + .with_context(|| format!("POST {url}"))?; + let status = response.status(); + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + bail!("username availability check failed for {username_base} ({status}): {text}"); + } let body: Value = response .json() .await .context("decoding availability response")?; - Ok(body - .get(username_base) - .and_then(Value::as_str) - .is_some_and(|status| status == "AVAILABLE")) + Ok(availability_status(&body, username_base) == Some("AVAILABLE")) +} + +/// Reads one base's availability status out of either wire shape. Those are the +/// v1 record `{_tag, value: {base: {status, …}}}` and the flat +/// `{base: "AVAILABLE"}` the preset backends serve. +fn availability_status<'a>(body: &'a Value, username_base: &str) -> Option<&'a str> { + let entry = body + .get("value") + .and_then(|value| value.get(username_base)) + .or_else(|| body.get(username_base))?; + match entry { + Value::String(status) => Some(status), + entry => entry.get("status").and_then(Value::as_str), + } } -/// Register (or confirm) the signing host's lite username and wait until the -/// People-chain `Resources.Consumers` record exists. Returns the Lite username -/// assigned on chain (including its discriminator). +/// Registers (or confirms) the signing host's lite username. Waits until the +/// dotNS contracts on Asset Hub record it. Returns the lite username assigned on +/// chain, including its discriminator. pub async fn attest(config: &AttestConfig) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build()?; + let mut reader = AssetHubReader::connect(&config.asset_hub_ws).await?; + // Timestamping the reservation signature with Asset Hub chain time. The + // gateway rejects values ahead of the chain. + let signed_at = reader + .timestamp_secs() + .await + .context("read Asset Hub Timestamp.Now")?; + let verifier = fetch_verifier(&client, &config.backend_base).await?; - let registration = build_lite_registration(&config.entropy, verifier, &config.username_base) - .map_err(|reason| anyhow::anyhow!("failed to build registration params: {reason}"))?; + let registration = build_lite_registration( + &config.entropy, + verifier, + &config.username_base, + config.reserved_username.as_deref(), + signed_at, + ) + .map_err(|reason| anyhow::anyhow!("failed to build registration params: {reason}"))?; debug!( candidate = %registration.candidate_account_id, "attesting lite username '{}'", @@ -79,51 +220,44 @@ pub async fn attest(config: &AttestConfig) -> Result { &client, &config.backend_base, &config.username_base, + config.reserved_username.as_deref(), + signed_at, ®istration, ) .await?; - let storage_key = format!( - "0x{}", - hex::encode(resources_consumers_storage_key( - ®istration.candidate_public_key - )) - ); - let identity = wait_for_consumer_record(&config.people_ws, &storage_key).await?; + let identity = wait_for_dotns_username(&mut reader, ®istration.candidate_public_key).await?; debug!("lite username registered and confirmed on-chain"); identity .lite_username - .context("registered People-chain identity has no Lite username") + .context("registered dotNS identity has no lite username") } -/// Resolve the on-chain Lite username for an already-attested signer. +/// Resolves the on-chain lite username for an already-attested signer. /// -/// Older CLI account records stored the requested username base rather than -/// the final `name.discriminator` assigned by the People chain. Reading the -/// consumer record repairs those records without re-attesting the account. -pub async fn registered_lite_username(people_ws: &str, entropy: &[u8]) -> Result { +/// Older CLI account records stored the requested username base rather than the +/// final `name.discriminator` assigned on chain. Reading the dotNS record repairs +/// those records without re-attesting the account. +pub async fn registered_lite_username(asset_hub_ws: &str, entropy: &[u8]) -> Result { let identity = derive_identity_keypair(entropy) .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; - let storage_key = format!( - "0x{}", - hex::encode(resources_consumers_storage_key(&identity.public.to_bytes())) - ); - let value = query_storage(people_ws, &storage_key) + let mut reader = AssetHubReader::connect(asset_hub_ws).await?; + reader + .dotns_identity(&identity.public.to_bytes()) .await? - .context("attested signer has no Resources.Consumers record")?; - decode_identity_hex(&value)? .lite_username - .context("registered People-chain identity has no Lite username") + .context("attested signer has no dotNS lite username") } -/// Probe the People chain for the bare root and canonical RFC-0022 `uid.dot` -/// identity account, printing any `Resources.Consumers` record. Used to -/// confirm a pre-onboarded account. -pub async fn check_identity(people_ws: &str, entropy: &[u8]) -> Result<()> { +/// Probes the dotNS contracts for the bare root and canonical RFC-0022 `uid.dot` +/// identity account. Prints any recorded usernames. Used to confirm a +/// pre-onboarded account. +pub async fn check_identity(asset_hub_ws: &str, entropy: &[u8]) -> Result<()> { let root = derive_root_keypair_from_entropy(entropy) .map_err(|err| anyhow::anyhow!("invalid entropy: {err}"))?; let identity = derive_identity_keypair(entropy) .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; + let mut reader = AssetHubReader::connect(asset_hub_ws).await?; for (label, public) in [ ("", root.public.to_bytes()), @@ -132,23 +266,15 @@ pub async fn check_identity(people_ws: &str, entropy: &[u8]) -> Result<()> { identity.public.to_bytes(), ), ] { - let key = format!( - "0x{}", - hex::encode(resources_consumers_storage_key(&public)) - ); let address = product_public_key_to_address(public); - match query_storage(people_ws, &key).await { - Ok(Some(value)) => { - let decoded = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) - .ok() - .and_then(|bytes| decode_people_identity(&bytes).ok()); - let username = decoded - .and_then(|id| id.full_username.or(id.lite_username)) - .unwrap_or_else(|| "".to_string()); - println!("IDENTITY_FOUND path={label} account={address} username={username}"); - } - Ok(None) => println!("IDENTITY_NONE path={label} account={address}"), - Err(err) => println!("IDENTITY_ERROR path={label} account={address} error={err}"), + match reader.dotns_identity(&public).await { + Ok(identity) => match identity.full_username.or(identity.lite_username) { + Some(username) => { + println!("IDENTITY_FOUND path={label} account={address} username={username}") + } + None => println!("IDENTITY_NONE path={label} account={address}"), + }, + Err(err) => println!("IDENTITY_ERROR path={label} account={address} error={err:#}"), } } Ok(()) @@ -179,9 +305,18 @@ async fn submit_registration( client: &reqwest::Client, backend_base: &str, username_base: &str, + reserved_username: Option<&str>, + signed_at: u64, reg: &truapi_server::host_logic::attestation::LiteRegistration, ) -> Result<()> { let url = format!("{backend_base}/usernames"); + let mut dotns = json!({ + "signature": hex0x(®.dotns_signature), + "signedAt": signed_at, + }); + if let Some(reserved) = reserved_username { + dotns["reservedUsername"] = json!(reserved); + } let body = json!({ "username": username_base, "candidateAccountId": reg.candidate_account_id, @@ -190,10 +325,10 @@ async fn submit_registration( "proofOfOwnership": hex0x(®.proof_of_ownership), "identifierKey": hex0x(®.identifier_key), "consumerRegistrationSignature": hex0x(®.consumer_registration_signature), + "dotns": dotns, }); - let response = client - .post(&url) - .json(&body) + let response = with_backend_auth(client, backend_base, client.post(&url).json(&body)) + .await? .send() .await .with_context(|| format!("POST {url}"))?; @@ -217,60 +352,62 @@ fn hex0x(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) } -async fn wait_for_consumer_record( - people_ws: &str, - storage_key: &str, -) -> Result { +async fn wait_for_dotns_username( + reader: &mut AssetHubReader, + candidate: &[u8; 32], +) -> Result { // First-time lite registration is backend-async and can lag the HTTP - // response. The record is permanent once written, so later runs resolve on - // the first poll. + // response. The record is permanent once written. Later runs therefore + // resolve on the first poll. const MAX_ATTEMPTS: usize = 10; for attempt in 1..=MAX_ATTEMPTS { - match query_storage(people_ws, storage_key).await { - Ok(Some(value)) => { + match reader.dotns_identity(candidate).await { + Ok(identity) if identity.lite_username.is_some() => { crate::terminal_ui::update_activity( "signer", "Setting up signer", - Some("People-chain identity ready".to_string()), + Some("dotNS identity ready".to_string()), crate::terminal_ui::ActivityState::Running, ); - return decode_identity_hex(&value); + return Ok(identity); } - Ok(None) => { + Ok(_) => { crate::terminal_ui::update_activity( "signer", "Setting up signer", Some(format!( - "Waiting for People-chain identity · attempt {attempt}/{MAX_ATTEMPTS}" + "Waiting for dotNS username · attempt {attempt}/{MAX_ATTEMPTS}" )), crate::terminal_ui::ActivityState::Running, ); - debug!("Resources.Consumers poll {attempt}/{MAX_ATTEMPTS}: empty"); + debug!("dotNS username poll {attempt}/{MAX_ATTEMPTS}: empty"); } - Err(err) => warn!(%err, "Resources.Consumers poll attempt {attempt} failed"), + Err(err) => warn!(%err, "dotNS username poll attempt {attempt} failed"), } if attempt < MAX_ATTEMPTS { tokio::time::sleep(Duration::from_secs(4)).await; } } - bail!("Resources.Consumers record did not appear after attestation") + bail!("dotNS username did not appear on Asset Hub after attestation") } -fn decode_identity_hex(value: &str) -> Result { - let bytes = hex::decode(value.strip_prefix("0x").unwrap_or(value)) - .context("Resources.Consumers value is not valid hex")?; - decode_people_identity(&bytes).map_err(anyhow::Error::msg) -} +#[cfg(test)] +mod tests { + use super::*; -/// One `state_getStorage` request over a fresh RPC connection; returns the value -/// hex when present. -async fn query_storage(people_ws: &str, storage_key: &str) -> Result> { - let rpc = RpcClient::from_insecure_url(people_ws) - .await - .with_context(|| format!("connect {people_ws}"))?; - let value = rpc - .request::("state_getStorage", rpc_params![storage_key]) - .await - .context("rpc state_getStorage")?; - Ok(value.as_str().map(str::to_string)) + /// Both backends the CLI talks to are understood. Those are the identity + /// backend's v1 record and the flat map the preset backends serve. + #[test] + fn availability_reads_both_wire_shapes() { + let v1 = json!({ + "_tag": "v1", + "value": { "pntest": { "status": "AVAILABLE", "availableDigits": [1, 2] } }, + }); + assert_eq!(availability_status(&v1, "pntest"), Some("AVAILABLE")); + + let flat = json!({ "pntest": "EXHAUSTED" }); + assert_eq!(availability_status(&flat, "pntest"), Some("EXHAUSTED")); + + assert_eq!(availability_status(&v1, "other"), None); + } } diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs index 5fbf94ebf..77fcc4dc9 100644 --- a/rust/crates/truapi-host-cli/src/chain.rs +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -217,9 +217,11 @@ mod tests { network.bulletin_ws ); assert_eq!(provider.url_for(&network.people_genesis), network.people_ws); + // Asset Hub is a required host route. Session usernames resolve from + // the dotNS contracts there. assert_eq!( - provider.url_for(&network.live_chain_endpoints[0].genesis), - network.people_ws + provider.url_for(&network.asset_hub_genesis), + network.asset_hub_ws ); } diff --git a/rust/crates/truapi-host-cli/src/dotns_read.rs b/rust/crates/truapi-host-cli/src/dotns_read.rs new file mode 100644 index 000000000..bb2c6c8ee --- /dev/null +++ b/rust/crates/truapi-host-cli/src/dotns_read.rs @@ -0,0 +1,155 @@ +//! Asset Hub dotNS reads over plain RPC (`state_getStorage` / `state_call`). +//! +//! The transport half of username resolution. This module supplies the two RPC +//! primitives. `truapi_server::host_logic::dotns_gateway` walks the contract +//! chain over them. The CLI and the in-core `chainHead_v1` lookup therefore +//! resolve identically. + +use anyhow::{Context, Result, bail}; +use serde_json::Value; +use subxt_rpcs::client::{RpcClient, rpc_params}; +use truapi_platform::async_trait; +use truapi_server::host_logic::dotns_gateway::{ + DotnsIdentity, DotnsTransport, VIEW_CALL_ORIGIN, account_alias_key, classify_labels, + decode_revive_call_output, discover_pop_controller, encode_revive_call, resolve_labels, + timestamp_now_key, +}; + +/// Env var overriding the `DotnsPopController` H160 (hex), skipping on-chain +/// discovery. +/// +/// Needed on networks whose deployed dispatcher exposes no target getter. On +/// paseo-next-v2 that address is `0x1c858C31497a7715C0D56A11208feB6b74FaB2aB`. +pub const DOTNS_POP_CONTROLLER_ENV: &str = "HOST_CLI_DOTNS_POP_CONTROLLER"; + +/// One Asset Hub RPC connection for a batch of dotNS reads. +pub struct AssetHubReader { + rpc: RpcClient, +} + +impl AssetHubReader { + /// Connects to the Asset Hub WebSocket endpoint. + pub async fn connect(asset_hub_ws: &str) -> Result { + let rpc = RpcClient::from_insecure_url(asset_hub_ws) + .await + .with_context(|| format!("connect {asset_hub_ws}"))?; + Ok(Self { rpc }) + } + + /// Asset Hub chain time in Unix seconds. `Timestamp.Now` itself is + /// milliseconds. + pub async fn timestamp_secs(&self) -> Result { + let value = self + .raw_storage(×tamp_now_key()) + .await? + .context("Timestamp.Now is unset")?; + let millis: [u8; 8] = value + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("Timestamp.Now is not a u64"))?; + Ok(u64::from_le_bytes(millis) / 1000) + } + + /// Alias `account` registered with, per `DotnsGateway.AccountAlias`. + pub async fn account_alias(&self, account: &[u8; 32]) -> Result> { + let value = self.raw_storage(&account_alias_key(account)).await?; + Ok(value.and_then(|bytes| bytes.try_into().ok())) + } + + /// Usernames of `account` as recorded by the dotNS contracts. + pub async fn dotns_identity(&mut self, account: &[u8; 32]) -> Result { + let controller = self.pop_controller().await?; + let labels = resolve_labels(self, &controller, account) + .await + .map_err(anyhow::Error::msg)?; + Ok(classify_labels(labels)) + } + + /// `DotnsPopController` address. The env override wins, otherwise on-chain + /// discovery. + /// + /// The currently deployed paseo dispatcher exposes no target getter at all. + /// Its fallback reverts every unknown selector. On-chain discovery cannot + /// reach the controller there, hence the override. + async fn pop_controller(&mut self) -> Result<[u8; 20]> { + if let Ok(value) = std::env::var(DOTNS_POP_CONTROLLER_ENV) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + let bytes = hex::decode(trimmed.strip_prefix("0x").unwrap_or(trimmed)) + .with_context(|| format!("{DOTNS_POP_CONTROLLER_ENV} is not valid hex"))?; + return bytes.try_into().map_err(|bytes: Vec| { + anyhow::anyhow!( + "{DOTNS_POP_CONTROLLER_ENV} must be 20 bytes, got {}", + bytes.len() + ) + }); + } + } + discover_pop_controller(self) + .await + .map_err(anyhow::Error::msg)? + .with_context(|| { + format!( + "dotNS gateway has no reachable controller; set \ + {DOTNS_POP_CONTROLLER_ENV} to the DotnsPopController H160" + ) + }) + } + + /// Dry-runs a contract view via the `ReviveApi_call` runtime API and returns + /// its data. + /// + /// Views originate from the synthetic always-mapped account. They work + /// regardless of the queried account's revive mapping. + async fn raw_view(&self, dest: &[u8; 20], input: Vec) -> Result> { + let args = encode_revive_call(&VIEW_CALL_ORIGIN, dest, &input); + let output: Value = self + .rpc + .request( + "state_call", + rpc_params!["ReviveApi_call", format!("0x{}", hex::encode(args))], + ) + .await + .context("rpc state_call ReviveApi_call")?; + let Some(output) = output.as_str() else { + bail!("state_call returned a non-string response"); + }; + let bytes = hex::decode(output.strip_prefix("0x").unwrap_or(output)) + .context("state_call output is not valid hex")?; + Ok(decode_revive_call_output(&bytes)?) + } + + /// One `state_getStorage` read at the best block. + async fn raw_storage(&self, key: &[u8]) -> Result>> { + let value: Value = self + .rpc + .request( + "state_getStorage", + rpc_params![format!("0x{}", hex::encode(key))], + ) + .await + .context("rpc state_getStorage")?; + value + .as_str() + .map(|hex_value| { + hex::decode(hex_value.strip_prefix("0x").unwrap_or(hex_value)) + .context("storage value is not valid hex") + }) + .transpose() + } +} + +#[async_trait] +impl DotnsTransport for AssetHubReader { + async fn storage(&mut self, key: Vec) -> Result>, String> { + self.raw_storage(&key) + .await + .map_err(|err| format!("{err:#}")) + } + + async fn view(&mut self, dest: &[u8; 20], input: Vec) -> Result, String> { + self.raw_view(dest, input) + .await + .map_err(|err| format!("{err:#}")) + } +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index e9eb53ad1..56181dc12 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -12,9 +12,11 @@ mod accounts; mod attestation; mod chain; +mod dotns_read; mod frame_server; mod network; mod platform; +mod register_name; mod script_runner; mod sessions; mod signing_shell; @@ -147,7 +149,8 @@ enum Command { /// specified, and can accept pairing deeplinks. With `--script`, exits with /// the script's status; otherwise stays interactive. SigningHost(SigningHostArgs), - /// Probe the People chain for a mnemonic's registered identity/username. + /// Probe the dotNS contracts on Asset Hub for a mnemonic's registered + /// identity/username. IdentityCheck { /// BIP-39 mnemonic to probe. #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] @@ -156,6 +159,28 @@ enum Command { #[arg(long, value_enum, default_value = "paseo-next-v2")] network: Network, }, + /// Register a full-person username on dotNS via + /// `DotnsGateway.register_name` on Asset Hub. Requires a recognized full + /// person. That person's People ring must have propagated to Asset Hub. + RegisterName { + /// BIP-39 mnemonic of the recognized full person. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: String, + /// Network preset to use. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Base label to register (no digits). + #[arg(long)] + label: String, + /// Dotted lite username to link (`name.NN`). Defaults to the + /// account's own lite username. + #[arg(long, conflicts_with = "chat_key")] + link_lite: Option, + /// 65-byte ECDH chat key (hex). Use it for a standalone registration + /// with no lite-username link. + #[arg(long)] + chat_key: Option, + }, /// Check (and optionally submit) a statement-store allowance registration /// against the real People chain: ring membership, the chosen slot, and /// (with `--submit`) the `set_statement_store_account` extrinsic. @@ -295,7 +320,35 @@ async fn main() -> Result<()> { let entropy = bip39::Mnemonic::parse(mnemonic.trim()) .context("invalid BIP-39 mnemonic")? .to_entropy(); - attestation::check_identity(network.config().people_ws, &entropy).await + attestation::check_identity(network.config().asset_hub_ws, &entropy).await + } + Command::RegisterName { + mnemonic, + network, + label, + link_lite, + chat_key, + } => { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + let chat_key = chat_key + .map(|value| -> Result<[u8; 65]> { + let bytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .context("chat key is not valid hex")?; + bytes.try_into().map_err(|bytes: Vec| { + anyhow::anyhow!("chat key must be 65 bytes, got {}", bytes.len()) + }) + }) + .transpose()?; + register_name::register_name(®ister_name::RegisterNameConfig { + network: network.config(), + entropy, + label, + link_lite, + chat_key, + }) + .await } Command::AllocCheck { mnemonic, @@ -499,13 +552,14 @@ async fn run_pairing_host( approval_policy(args.auto_accept), ui_handle, ); - // SSO and identity both run over the real People chain, so usernames always - // resolve from `Resources.Consumers` (host-spec G). + // SSO runs over the real People chain. Usernames resolve from the dotNS + // contracts on Asset Hub. let config = PairingHostConfig::new( host_info("Headless Pairing Host"), platform_info(), network.people_genesis, network.bulletin_genesis, + network.asset_hub_genesis, DEEPLINK_SCHEME.to_string(), ) .context("invalid pairing host config")?; @@ -801,12 +855,12 @@ async fn start_signing_host( lite_username_prefix: None, }) .await?; - match attestation::registered_lite_username(network.people_ws, &explicit_signer.entropy) + match attestation::registered_lite_username(network.asset_hub_ws, &explicit_signer.entropy) .await { Ok(user_id) => explicit_signer.lite_username = Some(user_id), Err(error) => { - tracing::warn!(%error, "explicit signer has no resolvable People-chain username") + tracing::warn!(%error, "explicit signer has no resolvable dotNS username") } } signer = Some(explicit_signer); @@ -896,6 +950,7 @@ fn build_signing_runtime( platform_info(), network.people_genesis, network.bulletin_genesis, + network.asset_hub_genesis, ) .context("invalid signing host config")?; Ok(Arc::new(SigningHostRuntime::new( diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs index c1f909202..571034bc2 100644 --- a/rust/crates/truapi-host-cli/src/network.rs +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -11,51 +11,118 @@ pub enum Network { #[value(name = "paseo-next-v2")] #[default] PaseoNextV2, + /// Previewnet, the testnet carrying the newer dotNS gateway. Its reservation + /// signature scheme has `MaxValiditySeconds`. + #[value(name = "previewnet")] + Previewnet, } +/// Env var overriding the identity backend base URL for every command. The URL +/// includes `/api/v1`, for instance a local backend at +/// `http://localhost:8080/api/v1`. Chain endpoints stay on the preset. +pub const IDENTITY_BACKEND_BASE_ENV: &str = "HOST_CLI_IDENTITY_BACKEND_BASE"; + impl Network { + /// Preset resolved with any environment overrides applied. pub fn config(self) -> NetworkConfig { + apply_backend_override(self.preset(), std::env::var(IDENTITY_BACKEND_BASE_ENV).ok()) + } + + /// The unmodified preset values. + fn preset(self) -> NetworkConfig { match self { Self::PaseoNextV2 => NetworkConfig { id: "paseo-next-v2", identity_backend_base: "https://identity-backend-next.parity-testnet.parity.io/api/v1", - people_ws: "wss://paseo-people-next-system-rpc.polkadot.io", - bulletin_ws: "wss://paseo-bulletin-next-rpc.polkadot.io", - people_genesis: hex_literal_genesis( - "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", - ), - bulletin_genesis: hex_literal_genesis( - "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", - ), + people_ws: PASEO_PEOPLE.ws, + bulletin_ws: PASEO_BULLETIN.ws, + asset_hub_ws: PASEO_ASSET_HUB.ws, + people_genesis: PASEO_PEOPLE.genesis, + bulletin_genesis: PASEO_BULLETIN.genesis, + asset_hub_genesis: PASEO_ASSET_HUB.genesis, live_chain_endpoints: PASEO_NEXT_V2_CHAIN_ENDPOINTS, }, + Self::Previewnet => NetworkConfig { + id: "previewnet", + identity_backend_base: "https://polkadot-app-stg.parity.io/api/v1", + people_ws: PREVIEWNET_PEOPLE.ws, + // Previewnet has no bulletin chain. Preimage submission keeps + // using the paseo testnet bulletin. + bulletin_ws: PASEO_BULLETIN.ws, + asset_hub_ws: PREVIEWNET_ASSET_HUB.ws, + people_genesis: PREVIEWNET_PEOPLE.genesis, + bulletin_genesis: PASEO_BULLETIN.genesis, + asset_hub_genesis: PREVIEWNET_ASSET_HUB.genesis, + live_chain_endpoints: PREVIEWNET_CHAIN_ENDPOINTS, + }, + } + } +} + +/// Replaces the preset's identity backend base with `base` when it carries a +/// non-empty URL. Trailing slashes are stripped so path joins stay clean. The +/// override is leaked once per process, which is fine for a CLI. +fn apply_backend_override(mut config: NetworkConfig, base: Option) -> NetworkConfig { + if let Some(base) = base { + let trimmed = base.trim().trim_end_matches('/'); + if !trimmed.is_empty() { + config.identity_backend_base = Box::leak(trimmed.to_string().into_boxed_str()); } } + config } -const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ - ChainEndpoint { - genesis: hex_literal_genesis( - "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", - ), - ws: "wss://paseo-asset-hub-next-rpc.polkadot.io", - required_for_host: false, - }, - ChainEndpoint { - genesis: hex_literal_genesis( - "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", - ), - ws: "wss://paseo-people-next-system-rpc.polkadot.io", - required_for_host: true, - }, - ChainEndpoint { - genesis: hex_literal_genesis( - "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", - ), - ws: "wss://paseo-bulletin-next-rpc.polkadot.io", - required_for_host: true, - }, -]; +// Each chain is declared once and reused by both the preset fields and the +// endpoint table. A genesis hash can therefore never drift from the URL it +// routes to. Every route below is host-required: session identity (dotNS +// usernames) resolves through Asset Hub, SSO through People, preimages through +// Bulletin. + +const PASEO_ASSET_HUB: ChainEndpoint = ChainEndpoint { + genesis: hex_literal_genesis( + "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", + ), + ws: "wss://paseo-asset-hub-next-rpc.polkadot.io", + required_for_host: true, +}; + +const PASEO_PEOPLE: ChainEndpoint = ChainEndpoint { + genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + ws: "wss://paseo-people-next-system-rpc.polkadot.io", + required_for_host: true, +}; + +const PASEO_BULLETIN: ChainEndpoint = ChainEndpoint { + genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + required_for_host: true, +}; + +const PREVIEWNET_ASSET_HUB: ChainEndpoint = ChainEndpoint { + genesis: hex_literal_genesis( + "4d11c803cc6921429e3876638977ad006ea1bba8cd3976a0bca2f164e7026210", + ), + ws: "wss://previewnet.substrate.dev/asset-hub", + required_for_host: true, +}; + +const PREVIEWNET_PEOPLE: ChainEndpoint = ChainEndpoint { + genesis: hex_literal_genesis( + "3138c6d4ce58c760047a413c2a930e919b4673a841ab4890de59aac3bd037f3d", + ), + ws: "wss://previewnet.substrate.dev/people", + required_for_host: true, +}; + +const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = + &[PASEO_ASSET_HUB, PASEO_PEOPLE, PASEO_BULLETIN]; + +const PREVIEWNET_CHAIN_ENDPOINTS: &[ChainEndpoint] = + &[PREVIEWNET_ASSET_HUB, PREVIEWNET_PEOPLE, PASEO_BULLETIN]; /// Resolved RPC/backend/genesis values for one network preset. #[derive(Debug, Clone, Copy)] @@ -65,8 +132,10 @@ pub struct NetworkConfig { pub people_ws: &'static str, #[allow(dead_code)] pub bulletin_ws: &'static str, + pub asset_hub_ws: &'static str, pub people_genesis: [u8; 32], pub bulletin_genesis: [u8; 32], + pub asset_hub_genesis: [u8; 32], pub live_chain_endpoints: &'static [ChainEndpoint], } @@ -110,17 +179,21 @@ mod tests { #[test] fn every_preset_is_a_test_network() { for network in Network::value_variants() { - let config = network.config(); + // Reading the raw preset. An exported backend override cannot leak in. + let config = network.preset(); let mut routes = vec![ config.identity_backend_base, config.people_ws, config.bulletin_ws, + config.asset_hub_ws, ]; routes.extend(config.live_chain_endpoints.iter().map(|chain| chain.ws)); for route in routes { assert!( - route.contains("paseo") || route.contains("testnet"), + ["paseo", "testnet", "previewnet", "-stg."] + .iter() + .any(|marker| route.contains(marker)), "preset `{}` routes to a host that is not a recognised test \ network: {route}", config.id, @@ -128,4 +201,22 @@ mod tests { } } } + + #[test] + fn backend_override_replaces_only_the_backend_base() { + let preset = Network::PaseoNextV2.preset(); + let overridden = + apply_backend_override(preset, Some("http://localhost:8080/api/v1/".to_string())); + + assert_eq!( + overridden.identity_backend_base, "http://localhost:8080/api/v1", + "trailing slash is stripped" + ); + assert_eq!(overridden.asset_hub_ws, preset.asset_hub_ws); + assert_eq!( + apply_backend_override(preset, Some(" ".to_string())).identity_backend_base, + preset.identity_backend_base, + "a blank override keeps the preset" + ); + } } diff --git a/rust/crates/truapi-host-cli/src/register_name.rs b/rust/crates/truapi-host-cli/src/register_name.rs new file mode 100644 index 000000000..48713ec44 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/register_name.rs @@ -0,0 +1,185 @@ +//! Full-person username registration through `DotnsGateway.register_name`. +//! +//! Builds and submits the v5 general transaction on Asset Hub. The signer's +//! RFC-0022 `uid.dot` account is the call's `who`. The full-person bandersnatch +//! key proves People-ring membership bound to the dotNS gateway context. A fresh +//! sr25519 signature over the inherited-implication digest travels in the +//! `AsDotnsGateway` extension. +//! +//! Requires a recognized full person. The account's full-person key must be +//! `Included` in a People ring already propagated to Asset Hub. + +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use tracing::debug; +use truapi_server::host_logic::dotns_gateway::{ + DOTNS_GATEWAY_CONTEXT, Link, build_register_proof_message, encode_register_full_name_extra, + encode_register_name_call, +}; +use truapi_server::host_logic::product_account::{ + SR25519_SIGNING_CONTEXT, derive_full_person_ring_vrf_entropy, derive_identity_keypair, +}; +use truapi_server::statement_allowance::extension::AS_DOTNS_GATEWAY; +use truapi_server::statement_allowance::{ + self as alloc, extension, extrinsic, proof, ring, rpc::RpcClient, +}; + +use crate::dotns_read::AssetHubReader; +use crate::network::NetworkConfig; + +/// Inputs for one full-person registration run. +pub struct RegisterNameConfig { + /// Network preset providing the People and Asset Hub endpoints. + pub network: NetworkConfig, + /// BIP-39 entropy of the signing host's root account. + pub entropy: Vec, + /// Base label to register (no digits, 6+ characters). + pub label: String, + /// Dotted lite username to link (`name.NN`). Without this and without + /// `chat_key`, the account's own lite username is looked up and linked. + pub link_lite: Option, + /// 65-byte ECDH chat key for a standalone (unlinked) registration. + pub chat_key: Option<[u8; 65]>, +} + +/// Registers (or claims) `label` as a full-person username. Waits until the +/// gateway records the account's alias on Asset Hub. +pub async fn register_name(config: &RegisterNameConfig) -> Result<()> { + let who = derive_identity_keypair(&config.entropy) + .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; + let who_public = who.public.to_bytes(); + + let mut reader = AssetHubReader::connect(config.network.asset_hub_ws).await?; + let link = resolve_link(config, &mut reader, &who_public).await?; + + // Reading the full-person member key's ring and its members from the People + // chain, pinned to one finalized block. + let people_rpc = RpcClient::connect(config.network.people_ws).await?; + let people_metadata = alloc::fetch_metadata(&people_rpc).await?; + let at = people_rpc.finalized_head().await?; + let full_entropy = derive_full_person_ring_vrf_entropy(&config.entropy); + let member = proof::member_key(full_entropy); + let ring_index = ring::read_member_ring_index( + &people_rpc, + &people_metadata, + ring::PEOPLE_IDENTIFIER, + &member, + &at, + ) + .await + .context("resolve full-person ring membership (is the account a recognized person?)")?; + let members = ring::read_collection_ring_members_at( + &people_rpc, + ring::PEOPLE_IDENTIFIER, + ring_index, + &at, + ) + .await?; + debug!( + ring_index, + members = members.len(), + "full-person ring resolved" + ); + + // Reading Asset Hub metadata, which asserts the RegisterFullName shape. + // Then the chain state and the exponent the subscriber verifies proofs + // against. + let ah_rpc = RpcClient::connect(config.network.asset_hub_ws).await?; + let ah_metadata = alloc::fetch_metadata(&ah_rpc).await?; + let chain_state = extension::ChainState { + // A restricted-origin general transaction. RestrictOrigins carries true. + restrict_origins: true, + ..alloc::fetch_chain_state(&ah_rpc).await? + }; + let info_variant = ah_metadata.dotns_register_full_name_variant()?; + let exponent = + ring::read_subscriber_ring_exponent(&ah_rpc, &ah_metadata, ring::PEOPLE_IDENTIFIER).await?; + let domain = proof::domain_for_ring_exponent(exponent)?; + + let call_indices = ah_metadata.call_indices("DotnsGateway", "register_name")?; + let call = encode_register_name_call(call_indices, &who_public, config.label.as_bytes(), &link); + + // Signing the inherited-implication digest with sr25519. The signature is + // carried in the AsDotnsGateway extension. + let digest = + extension::build_proof_message(&ah_metadata, &call, &chain_state, AS_DOTNS_GATEWAY)?; + let signature = who + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &digest, &who.public) + .to_bytes(); + + // Building the ring-VRF membership proof. It is bound to the gateway context + // and the registration intent. + let proof_message = build_register_proof_message(&who_public, config.label.as_bytes(), &link); + let ring_proof = proof::ring_vrf_proof( + domain, + full_entropy, + &members, + &DOTNS_GATEWAY_CONTEXT, + &proof_message, + )?; + + let extra = encode_register_full_name_extra(info_variant, &ring_proof, ring_index, &signature); + let extrinsic = extrinsic::build_unsigned_extrinsic( + &ah_metadata, + &chain_state, + &call, + AS_DOTNS_GATEWAY, + &extra, + )?; + + let block = ah_rpc.submit_and_watch(&extrinsic).await?; + println!("REGISTER_SUBMITTED label={} block={block}", config.label); + + wait_for_alias(&reader, &who_public).await?; + let identity = reader.dotns_identity(&who_public).await?; + println!( + "REGISTER_CONFIRMED label={} full_username={}", + config.label, + identity.full_username.as_deref().unwrap_or("") + ); + Ok(()) +} + +/// Resolves the `Link` argument. An explicit chat key wins, then an explicit +/// lite label, then the account's own on-chain lite username. +async fn resolve_link( + config: &RegisterNameConfig, + reader: &mut AssetHubReader, + who: &[u8; 32], +) -> Result { + if let Some(chat_key) = config.chat_key { + return Ok(Link::None(chat_key)); + } + if let Some(lite) = &config.link_lite { + return Ok(Link::LiteUsername(lite.as_bytes().to_vec())); + } + let identity = reader.dotns_identity(who).await?; + match identity.lite_username { + Some(lite) => { + debug!(%lite, "linking the account's own lite username"); + Ok(Link::LiteUsername(lite.into_bytes())) + } + None => bail!( + "account has no lite username to link; pass --link-lite or \ + --chat-key for a standalone registration" + ), + } +} + +/// Polls `DotnsGateway.AccountAlias[who]` until the registration lands. +async fn wait_for_alias(reader: &AssetHubReader, who: &[u8; 32]) -> Result<()> { + const MAX_ATTEMPTS: usize = 10; + for attempt in 1..=MAX_ATTEMPTS { + if let Some(alias) = reader.account_alias(who).await? { + println!("REGISTER_ALIAS alias=0x{}", hex::encode(alias)); + return Ok(()); + } + debug!("DotnsGateway.AccountAlias poll {attempt}/{MAX_ATTEMPTS}: empty"); + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_secs(4)).await; + } + } + bail!("DotnsGateway.AccountAlias did not appear after registration") +} diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 25e85f948..6fbd23d3b 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -50,6 +50,8 @@ pub struct PairingHostConfig { pub people_chain_genesis_hash: [u8; 32], /// Bulletin-chain genesis hash used for in-core preimage submission. pub bulletin_chain_genesis_hash: [u8; 32], + /// Asset Hub genesis hash used to resolve session usernames from dotNS. + pub asset_hub_chain_genesis_hash: [u8; 32], /// Deeplink URI scheme used in pairing QR payloads, without `://`. /// /// Host-spec L.2-L.3 define the `polkadotapp://pair` route and construction @@ -70,6 +72,8 @@ pub struct SigningHostConfig { pub people_chain_genesis_hash: [u8; 32], /// Bulletin-chain genesis hash used for in-core preimage submission. pub bulletin_chain_genesis_hash: [u8; 32], + /// Asset Hub genesis hash used to resolve session usernames from dotNS. + pub asset_hub_chain_genesis_hash: [u8; 32], } /// Product identity attached to one product-facing TrUAPI connection. @@ -139,6 +143,7 @@ impl PairingHostConfig { platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], + asset_hub_chain_genesis_hash: [u8; 32], pairing_deeplink_scheme: String, ) -> Result { require_non_empty("pairing_deeplink_scheme", &pairing_deeplink_scheme)?; @@ -151,6 +156,7 @@ impl PairingHostConfig { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, bulletin_chain_genesis_hash, + asset_hub_chain_genesis_hash, pairing_deeplink_scheme, }; Ok(config) @@ -165,11 +171,13 @@ impl SigningHostConfig { platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], + asset_hub_chain_genesis_hash: [u8; 32], ) -> Result { Ok(Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, bulletin_chain_genesis_hash, + asset_hub_chain_genesis_hash, }) } } diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index 29ce74543..6b8d2b9dd 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -97,6 +97,7 @@ fn pairing_config_validation_cases() { PlatformInfo::default(), [0xa2; 32], [0xbb; 32], + [0xcc; 32], case.pairing_deeplink_scheme.to_string(), ) .map(|_| ()); diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 28aa4e8bd..50ac1d11f 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -1535,6 +1535,46 @@ pub(crate) async fn wait_for_chain_head_storage_value( } } +/// Waits for one runtime-API call operation's output from a +/// `chainHead_v1_follow` stream. +pub(crate) async fn wait_for_chain_head_call_output( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + operation_id: &str, + label: &'static str, + timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::OperationCallDone { operation_id: item_operation_id, output }) + if item_operation_id == operation_id => + { + return Ok(output); + } + Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Err(format!("{label} runtime call was inaccessible")); + } + Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) + if item_operation_id == operation_id => + { + return Err(error); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped during runtime call")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} runtime call timed out")), + } + } +} + #[cfg(test)] fn decode_hex(value: &str) -> Result, String> { hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| "invalid hex".to_string()) diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index e687fafa1..eae0da4d8 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -7,10 +7,10 @@ pub mod attestation; pub mod bulletin; pub mod dotns; +pub mod dotns_gateway; pub mod entropy; pub mod extrinsic; pub mod features; -pub mod identity; pub mod permissions; pub mod product_account; pub mod session; diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs index 098edec4f..006d2d562 100644 --- a/rust/crates/truapi-server/src/host_logic/attestation.rs +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -16,6 +16,7 @@ use verifiable::Error as VerifiableError; use verifiable::GenerateVerifiable; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; +use crate::host_logic::dotns_gateway::build_reservation_message; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derive_identity_keypair, derive_lite_person_ring_vrf_entropy, product_public_key_to_address, @@ -56,10 +57,14 @@ pub struct LiteRegistration { pub ring_vrf_key: [u8; 32], /// Plain bandersnatch VRF proof over the same proof message. pub proof_of_ownership: [u8; 64], - /// 65-byte uncompressed P-256 identifier key. + /// 65-byte uncompressed P-256 identifier key. It doubles as the dotNS chat + /// key. pub identifier_key: [u8; 65], /// sr25519 signature over the SCALE consumer-registration tuple. pub consumer_registration_signature: [u8; 64], + /// sr25519 signature over the dotNS gateway reservation message. It + /// authorizes `pallet_dotns_gateway::reserve_name` on Asset Hub. + pub dotns_signature: [u8; 64], } /// Error while building lite-person registration parameters. @@ -78,10 +83,17 @@ pub enum LiteRegistrationError { /// Build the lite-person registration parameters for `username_base` /// (6+ lowercase letters, no digit suffix) against the backend `verifier`. +/// +/// `reserved_username` optionally queues a base name for a later full-person +/// claim on dotNS. `dotns_signed_at_secs` must be Asset Hub chain time, meaning +/// `Timestamp.Now` in seconds. The local wall clock will not do: the gateway +/// rejects signatures more than 30 seconds in the chain's future. pub fn build_lite_registration( entropy: &[u8], verifier_account_id: [u8; 32], username_base: &str, + reserved_username: Option<&str>, + dotns_signed_at_secs: u64, ) -> Result { // Registration, local activation, and the SSO responder all use the // RFC-0022 `uid.dot` default product account. @@ -111,7 +123,7 @@ pub fn build_lite_registration( verifier: verifier_account_id, identifier_key, username: username_base.as_bytes().to_vec(), - reserved_username: None, + reserved_username: reserved_username.map(|name| name.as_bytes().to_vec()), } .encode(); let consumer_registration_signature = candidate @@ -123,6 +135,23 @@ pub fn build_lite_registration( ) .to_bytes(); + let reservation_message = build_reservation_message( + &candidate_public_key, + &verifier_account_id, + username_base.as_bytes(), + &identifier_key, + reserved_username.map(str::as_bytes), + dotns_signed_at_secs, + ); + let dotns_signature = candidate + .secret + .sign_simple( + SR25519_SIGNING_CONTEXT, + &reservation_message, + &candidate.public, + ) + .to_bytes(); + Ok(LiteRegistration { candidate_account_id: product_public_key_to_address(candidate_public_key), candidate_public_key, @@ -131,6 +160,7 @@ pub fn build_lite_registration( proof_of_ownership, identifier_key, consumer_registration_signature, + dotns_signature, }) } @@ -172,7 +202,9 @@ mod tests { #[test] fn registration_params_have_expected_shapes_and_verify() { let verifier = [0x11u8; 32]; - let reg = build_lite_registration(&ENTROPY, verifier, "headlesstester").unwrap(); + let reg = + build_lite_registration(&ENTROPY, verifier, "headlesstester", None, 1_749_573_123) + .unwrap(); assert_eq!( reg.candidate_public_key, derive_identity_keypair(&ENTROPY).unwrap().public.to_bytes(), @@ -235,6 +267,70 @@ mod tests { .is_ok(), "consumer registration signature verifies against the runtime tuple" ); + + // dotnsSignature verifies over the gateway reservation message. The + // identifier key doubles as the chat key. + let reservation_message = build_reservation_message( + ®.candidate_public_key, + &verifier, + b"headlesstester", + ®.identifier_key, + None, + 1_749_573_123, + ); + let sig = Signature::from_bytes(®.dotns_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &reservation_message, &sig) + .is_ok(), + "dotns reservation signature verifies against the gateway message" + ); + } + + #[test] + fn reserved_username_threads_into_both_signed_payloads() { + let verifier = [0x33u8; 32]; + let reg = build_lite_registration( + &ENTROPY, + verifier, + "headlesstester", + Some("reservedbase"), + 77, + ) + .unwrap(); + let public = PublicKey::from_bytes(®.candidate_public_key).unwrap(); + + let consumer_message = ( + reg.candidate_public_key, + verifier, + reg.identifier_key, + b"headlesstester".as_slice(), + Some(b"reservedbase".to_vec()), + ) + .encode(); + let sig = Signature::from_bytes(®.consumer_registration_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &consumer_message, &sig) + .is_ok(), + "consumer registration signature commits to the reserved username" + ); + + let reservation_message = build_reservation_message( + ®.candidate_public_key, + &verifier, + b"headlesstester", + ®.identifier_key, + Some(b"reservedbase"), + 77, + ); + let sig = Signature::from_bytes(®.dotns_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &reservation_message, &sig) + .is_ok(), + "dotns signature commits to the reserved username and signed_at" + ); } #[test] @@ -266,8 +362,8 @@ mod tests { #[test] fn registration_is_deterministic_per_entropy_and_username() { let verifier = [0x22u8; 32]; - let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); - let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless", None, 1).unwrap(); + let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless", None, 1).unwrap(); assert_eq!(first.candidate_public_key, again.candidate_public_key); assert_eq!(first.ring_vrf_key, again.ring_vrf_key); assert_eq!(first.candidate_account_id, again.candidate_account_id); diff --git a/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs b/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs new file mode 100644 index 000000000..5cb53af61 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs @@ -0,0 +1,887 @@ +//! dotNS gateway (Asset Hub) payload building and contract reads. +//! +//! Every host shares these helpers. They cover the candidate-signed +//! reservation message for `pallet_dotns_gateway::reserve_name`, the ring-VRF +//! proof message for `register_name`, the minimal Solidity ABI surface for +//! reading usernames back through `ReviveApi_call`, and the raw storage keys of +//! the gateway pallet. +//! +//! Username resolution ([`discover_pop_controller`], [`resolve_labels`]) is +//! written once against the [`DotnsTransport`] trait. The headless CLI's +//! plain-RPC reader and the in-core `chainHead_v1` lookup therefore walk the +//! same contract chain. +//! +//! Byte layouts mirror `pallets/dotns-gateway` in `paritytech/individuality` +//! and the dotNS contracts (`paritytech/dotns`). + +use parity_scale_codec::{Compact, Decode, Encode}; +use sp_crypto_hashing::{blake2_128, blake2_256, keccak_256, twox_128}; +use thiserror::Error; + +/// Ring-VRF context for `register_name` proofs. +/// +/// The ASCII label, space-padded to 32 bytes. Matches `DOTNS_GATEWAY_CONTEXT` +/// in the gateway pallet. +pub const DOTNS_GATEWAY_CONTEXT: [u8; 32] = *b"pop:polkadot.network/dotns "; + +/// Reservation message prefix, matching `RESERVE_MSG_PREFIX` in the gateway +/// pallet. +pub const RESERVE_MSG_PREFIX: &[u8] = b"pop:dotns-gateway:reserve"; + +/// Origin for read-only `ReviveApi_call` dry-runs. +/// +/// `pallet_revive` rejects unmapped signed origins with `AccountUnmapped`. +/// Eth-derived accounts — trailing 12 bytes `0xEE` — are implicitly mapped. +/// They need no on-chain state. Views therefore work from this synthetic +/// account on any network. +pub const VIEW_CALL_ORIGIN: [u8; 32] = { + let mut origin = [0xEEu8; 32]; + let mut i = 0; + while i < 20 { + origin[i] = 0; + i += 1; + } + origin +}; + +/// Builds the payload the candidate signs to authorize a lite-name +/// reservation. +/// +/// Commits to the *base* of the lite label only. The digit suffix is excluded, +/// so the candidate can sign before the gateway assigns digits. Mirrors +/// `Pallet::construct_reservation_message`: a SCALE tuple of +/// `(prefix, candidate, attester, username_base, chat_key, reserved, signed_at)`. +pub fn build_reservation_message( + candidate: &[u8; 32], + attester: &[u8; 32], + username_base: &[u8], + chat_key: &[u8; 65], + reserved_base_label: Option<&[u8]>, + signed_at_secs: u64, +) -> Vec { + ( + RESERVE_MSG_PREFIX, + candidate, + attester, + username_base, + chat_key.as_slice(), + reserved_base_label, + signed_at_secs, + ) + .encode() +} + +/// The `Link` argument of `register_name`: how the new full-person username +/// relates to the account's previous lite identity. +#[derive(Debug, Clone, PartialEq, Eq, Encode)] +pub enum Link { + /// Links the full username to an existing lite username, in dotted form + /// such as `alice.01`. The chat key is inherited from the lite entry. + LiteUsername(Vec), + /// Standalone registration carrying a fresh 65-byte uncompressed ECDH + /// chat key. + None([u8; 65]), +} + +/// Hashes the registration intent into the 32-byte ring-VRF proof message. +/// Matches the pallet's `(who, label, link).using_encoded(blake2_256)`. +pub fn build_register_proof_message(who: &[u8; 32], label: &[u8], link: &Link) -> [u8; 32] { + blake2_256(&(who, label, link).encode()) +} + +/// Encodes the `DotnsGateway.register_name { who, label, link }` call. +/// Layout: dispatch indices, the raw account, the compact-prefixed label, then +/// the link. +pub fn encode_register_name_call( + call_indices: [u8; 2], + who: &[u8; 32], + label: &[u8], + link: &Link, +) -> Vec { + let mut call = call_indices.to_vec(); + (who, label, link).encode_to(&mut call); + call +} + +/// Encodes the `AsDotnsGateway` extension extra. +/// +/// The value is `Some(RegisterFullName { proof, ring_index, signature })`. The +/// signature is wrapped as `MultiSignature::Sr25519`. +pub fn encode_register_full_name_extra( + info_variant: u8, + proof: &[u8], + ring_index: u32, + signature: &[u8; 64], +) -> Vec { + // Option::Some ‖ info variant ‖ Vec(proof) ‖ ring_index ‖ Sr25519 ‖ sig. + let mut extra = vec![0x01, info_variant]; + proof.encode_to(&mut extra); + ring_index.encode_to(&mut extra); + extra.push(0x01); + extra.extend_from_slice(signature); + extra +} + +/// Maps an AccountId32 to the H160 the contracts see. +/// +/// Mirrors `pallet_revive::AccountId32Mapper::to_address`. Eth-derived +/// accounts — trailing 12 bytes all `0xEE` — truncate. Everything else hashes. +pub fn account_to_h160(public_key: &[u8; 32]) -> [u8; 20] { + if public_key[20..].iter().all(|byte| *byte == 0xEE) { + public_key[..20] + .try_into() + .expect("slice of first 20 bytes from a 32-byte array; qed") + } else { + keccak_256(public_key)[12..] + .try_into() + .expect("keccak_256 output is 32 bytes, tail slice is 20; qed") + } +} + +/// Solidity `bytes32("name")` literal: ASCII left-aligned in a 32-byte word. +/// The dotNS protocol registry keys (`storeFactory`, `registrar`, ...) use this +/// form. They are not hashes. +pub fn registry_key(name: &str) -> [u8; 32] { + let bytes = name.as_bytes(); + assert!(bytes.len() <= 32, "registry key exceeds 32 bytes"); + let mut out = [0u8; 32]; + out[..bytes.len()].copy_from_slice(bytes); + out +} + +/// First 4 bytes of `keccak256(signature)` — the Solidity function selector. +pub fn selector(signature: &str) -> [u8; 4] { + keccak_256(signature.as_bytes())[..4] + .try_into() + .expect("keccak_256 output is 32 bytes, head slice is 4; qed") +} + +/// Calldata for a view function without arguments. +pub fn call_no_args(signature: &str) -> Vec { + selector(signature).to_vec() +} + +/// Calldata for a view function taking one `address` argument. +pub fn call_address(signature: &str, address: &[u8; 20]) -> Vec { + let mut data = call_no_args(signature); + data.extend_from_slice(&[0u8; 12]); + data.extend_from_slice(address); + data +} + +/// Calldata for a view function taking one `bytes32` argument. +pub fn call_bytes32(signature: &str, word: &[u8; 32]) -> Vec { + let mut data = call_no_args(signature); + data.extend_from_slice(word); + data +} + +/// Calldata for a view function taking two `uint256` arguments. +pub fn call_u256_pair(signature: &str, first: u64, second: u64) -> Vec { + let mut data = call_no_args(signature); + for value in [first, second] { + let mut word = [0u8; 32]; + word[24..].copy_from_slice(&value.to_be_bytes()); + data.extend_from_slice(&word); + } + data +} + +/// Error decoding a dotNS contract round-trip. +#[derive(Debug, Error)] +pub enum DotnsContractError { + /// `ContractResult` bytes ended before the expected field. + #[error("ContractResult truncated at {context}")] + Truncated { + /// Field being decoded when the input ended. + context: &'static str, + }, + /// The runtime rejected the call before reaching the contract. + #[error("ReviveApi_call dispatch failed: {detail}")] + Dispatch { + /// Raw hex of the `DispatchError` payload. + detail: String, + }, + /// The contract executed but reverted. + #[error("contract reverted: {detail}")] + Reverted { + /// Rendered revert reason. + detail: String, + }, + /// ABI return data did not match the expected shape. + #[error("ABI decode failed at {context}")] + Abi { + /// Value being decoded when the shape mismatched. + context: &'static str, + }, + /// ABI return data was not valid UTF-8 where a string was expected. + #[error("ABI string is not UTF-8")] + AbiUtf8, +} + +/// SCALE arguments for the `ReviveApi_call` runtime API. +/// A zero-value, unlimited-gas dry-run of `input` against `dest` from `origin`. +pub fn encode_revive_call(origin: &[u8; 32], dest: &[u8; 20], input: &[u8]) -> Vec { + let mut args = Vec::with_capacity(32 + 20 + 16 + 2 + 5 + input.len()); + args.extend_from_slice(origin); + args.extend_from_slice(dest); + args.extend_from_slice(&0u128.to_le_bytes()); + // gas_limit: Option and storage_deposit_limit: Option. + // Both absent, so the node estimates. + args.push(0x00); + args.push(0x00); + input.encode_to(&mut args); + args +} + +/// Extracts the contract return data from a `ReviveApi_call` output. +/// The output is a `pallet_revive::ContractResult`. Dispatch errors and reverts +/// are rejected. +pub fn decode_revive_call_output(output: &[u8]) -> Result, DotnsContractError> { + let truncated = |context| DotnsContractError::Truncated { context }; + let input = &mut &output[..]; + // weight_consumed + weight_required. Weight is a compact ref_time plus a + // compact proof_size. + for _ in 0..4 { + Compact::::decode(input).map_err(|_| truncated("weight"))?; + } + // storage_deposit + max_storage_deposit. StorageDeposit is variant ‖ u128. + for _ in 0..2 { + <(u8, u128)>::decode(input).map_err(|_| truncated("storage_deposit"))?; + } + u128::decode(input).map_err(|_| truncated("gas_consumed"))?; + match u8::decode(input).map_err(|_| truncated("result"))? { + 0 => { + let flags = u32::decode(input).map_err(|_| truncated("flags"))?; + let data = Vec::::decode(input).map_err(|_| truncated("data"))?; + // ReturnFlags::REVERT. + if flags & 1 == 1 { + return Err(DotnsContractError::Reverted { + detail: describe_revert(&data), + }); + } + Ok(data) + } + _ => Err(DotnsContractError::Dispatch { + detail: format!("0x{}", hex::encode(*input)), + }), + } +} + +/// Renders a Solidity revert payload. Recognizes `Error(string)` and +/// `Panic(uint256)`. +fn describe_revert(data: &[u8]) -> String { + if data.is_empty() { + return "(empty)".to_string(); + } + if data.starts_with(&[0x08, 0xc3, 0x79, 0xa0]) && data.len() >= 68 { + let len = u64::from_be_bytes( + data[60..68] + .try_into() + .expect("8-byte slice from a length-checked buffer; qed"), + ) as usize; + if let Some(message) = data + .get(68..68 + len) + .and_then(|bytes| core::str::from_utf8(bytes).ok()) + { + return format!("Error({message:?})"); + } + } + if data.starts_with(&[0x4e, 0x48, 0x7b, 0x71]) && data.len() >= 36 { + return format!("Panic(0x{})", hex::encode(&data[32..36])); + } + format!("0x{}", hex::encode(data)) +} + +/// Reads the 32-byte ABI word at index `index`. +fn word(data: &[u8], index: usize) -> Result<&[u8], DotnsContractError> { + data.get(index * 32..(index + 1) * 32) + .ok_or(DotnsContractError::Abi { context: "word" }) +} + +/// Interprets an ABI word as a right-aligned usize offset or length. +fn word_usize(data: &[u8], index: usize) -> Result { + let bytes = word(data, index)?; + if bytes[..24].iter().any(|b| *b != 0) { + return Err(DotnsContractError::Abi { + context: "oversized word", + }); + } + Ok(u64::from_be_bytes( + bytes[24..] + .try_into() + .expect("8-byte tail of a 32-byte word; qed"), + ) as usize) +} + +/// Decodes an ABI `string` at byte offset `at`. +/// The layout is a length word followed by padded UTF-8 bytes. +fn decode_string_at(data: &[u8], at: usize) -> Result { + let region = data.get(at..).ok_or(DotnsContractError::Abi { + context: "string offset", + })?; + let len = word_usize(region, 0)?; + let bytes = region.get(32..32 + len).ok_or(DotnsContractError::Abi { + context: "string bytes", + })?; + String::from_utf8(bytes.to_vec()).map_err(|_| DotnsContractError::AbiUtf8) +} + +/// Decodes a single `address` return value. +pub fn decode_address(data: &[u8]) -> Result<[u8; 20], DotnsContractError> { + let bytes = word(data, 0)?; + if bytes[..12].iter().any(|b| *b != 0) { + return Err(DotnsContractError::Abi { context: "address" }); + } + Ok(bytes[12..] + .try_into() + .expect("20-byte tail of a 32-byte word; qed")) +} + +/// Decodes a `string[]` return value (`LabelStore.getLabels`). +pub fn decode_string_array(data: &[u8]) -> Result, DotnsContractError> { + let array_at = word_usize(data, 0)?; + let array = data.get(array_at..).ok_or(DotnsContractError::Abi { + context: "array offset", + })?; + let len = word_usize(array, 0)?; + let elements = array.get(32..).ok_or(DotnsContractError::Abi { + context: "array elements", + })?; + (0..len) + .map(|i| decode_string_at(elements, word_usize(elements, i)?)) + .collect() +} + +/// Decodes `DotnsPopController.pendingClaim(address)`, an ABI +/// `(string label, uint64 mintedAt)`. An empty label means no pending claim. +pub fn decode_pending_claim(data: &[u8]) -> Result<(String, u64), DotnsContractError> { + let struct_at = word_usize(data, 0)?; + let claim = data.get(struct_at..).ok_or(DotnsContractError::Abi { + context: "struct offset", + })?; + let label = decode_string_at(claim, word_usize(claim, 0)?)?; + let minted_at = word_usize(claim, 1)? as u64; + Ok((label, minted_at)) +} + +/// Decodes the deployed-era `pendingClaims(address)`, an ABI +/// `(string label, uint64 mintedAt)[]`. Returns the labels. +pub fn decode_pending_claims_array(data: &[u8]) -> Result, DotnsContractError> { + let array_at = word_usize(data, 0)?; + let array = data.get(array_at..).ok_or(DotnsContractError::Abi { + context: "claims offset", + })?; + let len = word_usize(array, 0)?; + let elements = array.get(32..).ok_or(DotnsContractError::Abi { + context: "claims elements", + })?; + (0..len) + .map(|i| { + let claim = + elements + .get(word_usize(elements, i)?..) + .ok_or(DotnsContractError::Abi { + context: "claim offset", + })?; + decode_string_at(claim, word_usize(claim, 0)?) + }) + .collect() +} + +/// Usernames of one account as recorded by the dotNS contracts. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DotnsIdentity { + /// Dotted lite username (`alice.01`), if any. + pub lite_username: Option, + /// Full-person username, if any. + pub full_username: Option, +} + +/// Classifies flat contract labels into lite and full usernames. +/// +/// A label with an all-alphabetic stem and exactly two trailing digits is a lite +/// username, the PopRules lite format. Those are re-dotted (`alice01` → +/// `alice.01`). Everything else is a full username. First hit per slot wins. +pub fn classify_labels(labels: I) -> DotnsIdentity +where + I: IntoIterator, + I::Item: AsRef, +{ + let mut identity = DotnsIdentity::default(); + for label in labels { + let label = label.as_ref().trim_end_matches(".dot"); + let (stem, digits) = label.split_at(label.len().saturating_sub(2)); + let is_lite = !stem.is_empty() + && stem.chars().all(|c| c.is_ascii_lowercase()) + && digits.len() == 2 + && digits.chars().all(|c| c.is_ascii_digit()); + if is_lite { + identity + .lite_username + .get_or_insert_with(|| format!("{stem}.{digits}")); + } else if !label.is_empty() { + identity + .full_username + .get_or_insert_with(|| label.to_string()); + } + } + identity +} + +/// Page size for `LabelStore.getLabels`. Gateway users hold at most a lite and +/// a full name. +const LABEL_PAGE_LIMIT: u64 = 16; + +/// How a caller reaches Asset Hub for dotNS reads. +/// +/// The surface is one storage read plus one contract view. The headless CLI +/// implements it over plain RPC. The in-core runtime implements it over a +/// `chainHead_v1` follow. The resolution steps below are written once. +#[truapi_platform::async_trait] +pub trait DotnsTransport { + /// Reads one storage value. `None` when the entry is absent. + async fn storage(&mut self, key: Vec) -> Result>, String>; + + /// Dry-runs a contract view against `dest` and returns its return data. + async fn view(&mut self, dest: &[u8; 20], input: Vec) -> Result, String>; +} + +/// Resolves the `DotnsPopController` address. +/// +/// The pallet's stored controller wins when present. Otherwise the dispatcher's +/// target getter answers: `target()` on deployed-era contracts, `TARGET()` on +/// current dotNS `master`. `None` when the gateway is not deployed on the chain +/// at all. +pub async fn discover_pop_controller( + transport: &mut T, +) -> Result, String> { + if let Some(controller) = transport + .storage(pop_controller_address_key()) + .await? + .and_then(|value| <[u8; 20]>::try_from(value).ok()) + { + return Ok(Some(controller)); + } + let Some(dispatcher) = transport + .storage(dispatcher_address_key()) + .await? + .and_then(|value| <[u8; 20]>::try_from(value).ok()) + else { + return Ok(None); + }; + let output = match transport.view(&dispatcher, call_no_args("target()")).await { + Ok(output) => output, + Err(_) => { + transport + .view(&dispatcher, call_no_args("TARGET()")) + .await? + } + }; + decode_address(&output) + .map(Some) + .map_err(|err| format!("RootGatewayDispatcher target getter: {err}")) +} + +/// Resolves the flat contract labels `account` holds. +/// +/// The warm path reads the account's `LabelStore`. The cold path reads its +/// pending claim on the controller, covering gateway-minted names before the +/// user settles their store. +pub async fn resolve_labels( + transport: &mut T, + controller: &[u8; 20], + account: &[u8; 32], +) -> Result, String> { + let user = account_to_h160(account); + + let registry_output = transport + .view(controller, call_no_args("protocolRegistry()")) + .await?; + let registry = decode_address(®istry_output) + .map_err(|err| format!("DotnsPopController.protocolRegistry(): {err}"))?; + + let factory_output = transport + .view( + ®istry, + call_bytes32("get(bytes32)", ®istry_key("storeFactory")), + ) + .await?; + let factory = decode_address(&factory_output) + .map_err(|err| format!("ProtocolRegistry.get(storeFactory): {err}"))?; + + let store_output = transport + .view(&factory, call_address("getLabelStore(address)", &user)) + .await?; + let store = decode_address(&store_output) + .map_err(|err| format!("StoreFactory.getLabelStore: {err}"))?; + + if store != [0u8; 20] { + let labels_output = transport + .view( + &store, + call_u256_pair("getLabels(uint256,uint256)", 0, LABEL_PAGE_LIMIT), + ) + .await?; + return decode_string_array(&labels_output) + .map_err(|err| format!("LabelStore.getLabels: {err}")); + } + + // The current controller exposes `pendingClaim`, one struct. The + // deployed-era one exposes `pendingClaims`, an array. + match transport + .view(controller, call_address("pendingClaim(address)", &user)) + .await + { + Ok(claim_output) => { + let (label, minted_at) = decode_pending_claim(&claim_output) + .map_err(|err| format!("DotnsPopController.pendingClaim: {err}"))?; + Ok(if minted_at == 0 { vec![] } else { vec![label] }) + } + Err(_) => { + let claims_output = transport + .view(controller, call_address("pendingClaims(address)", &user)) + .await?; + decode_pending_claims_array(&claims_output) + .map_err(|err| format!("DotnsPopController.pendingClaims: {err}")) + } + } +} + +/// `DotnsGateway.PopControllerAddress` storage key. +/// Older runtimes store the controller directly. It is absent on runtimes that +/// wire a dispatcher instead. +pub fn pop_controller_address_key() -> Vec { + plain_key(b"DotnsGateway", b"PopControllerAddress") +} + +/// `DotnsGateway.DispatcherAddress` storage key. +/// The entry holds the `RootGatewayDispatcher`. Its `target()` is the +/// `DotnsPopController`. +pub fn dispatcher_address_key() -> Vec { + plain_key(b"DotnsGateway", b"DispatcherAddress") +} + +/// `DotnsGateway.AccountAlias[account]` storage key. +/// The value is the 32-byte alias the account registered with. +pub fn account_alias_key(account: &[u8; 32]) -> Vec { + let mut key = plain_key(b"DotnsGateway", b"AccountAlias"); + key.extend_from_slice(&blake2_128(account)); + key.extend_from_slice(account); + key +} + +/// `Timestamp.Now` storage key. The value is a u64 of Unix milliseconds. +pub fn timestamp_now_key() -> Vec { + plain_key(b"Timestamp", b"Now") +} + +/// `twox128(pallet) ‖ twox128(entry)`. +fn plain_key(pallet: &[u8], entry: &[u8]) -> Vec { + [twox_128(pallet).as_slice(), twox_128(entry).as_slice()].concat() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Golden vectors from the validated reference implementation in + // pop-dotns-testing: reservation.ts, proof.ts, verify.ts, abi.ts. + const RESERVATION_WITH_RESERVED: &str = "64706f703a646f746e732d676174657761793a72657365727665111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222221c616c696365626305013333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333013072657365727665646e616d65035e486800000000"; + const RESERVATION_NO_RESERVED: &str = "64706f703a646f746e732d676174657761793a72657365727665111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222221c616c69636562630501333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333300035e486800000000"; + + #[test] + fn reservation_message_matches_reference_vectors() { + let with_reserved = build_reservation_message( + &[0x11; 32], + &[0x22; 32], + b"alicebc", + &[0x33; 65], + Some(b"reservedname"), + 1749573123, + ); + assert_eq!(hex::encode(&with_reserved), RESERVATION_WITH_RESERVED); + + let without = build_reservation_message( + &[0x11; 32], + &[0x22; 32], + b"alicebc", + &[0x33; 65], + None, + 1749573123, + ); + assert_eq!(hex::encode(&without), RESERVATION_NO_RESERVED); + } + + #[test] + fn link_and_proof_message_match_reference_vectors() { + let lite = Link::LiteUsername(b"alice.01".to_vec()); + assert_eq!(hex::encode(lite.encode()), "0020616c6963652e3031"); + assert_eq!( + hex::encode(build_register_proof_message(&[0x44; 32], b"alicebc", &lite)), + "506fe3afcb13b2e4fd182d49ff165bc9c834f9dd21d0d545eccb925993b68675" + ); + + let standalone = Link::None([0x55; 65]); + assert_eq!( + hex::encode(standalone.encode()), + format!("01{}", hex::encode([0x55; 65])) + ); + assert_eq!( + hex::encode(build_register_proof_message( + &[0x44; 32], + b"alicebc", + &standalone + )), + "255da65ea6687123d5ac035a438a5c6fa18f7d04f23433611095a0fe58c479ab" + ); + } + + #[test] + fn register_name_call_and_extension_extra_have_the_reference_layout() { + let link = Link::LiteUsername(b"alice.01".to_vec()); + let call = encode_register_name_call([0x6a, 0x01], &[0x44; 32], b"alicebc", &link); + assert_eq!(&call[..2], &[0x6a, 0x01]); + assert_eq!(&call[2..34], &[0x44; 32]); + assert_eq!( + &call[34..], + &[b"alicebc".to_vec().encode(), link.encode()].concat()[..] + ); + + // Layout: 0x01 (Some) ‖ variant ‖ compact-len proof ‖ ring_index LE ‖ + // 0x01 (Sr25519) ‖ signature. Matches registerTx.ts + // encodeAsDotnsGatewayValue. + let extra = encode_register_full_name_extra(0, &[0xEE; 785], 3, &[0xAB; 64]); + assert_eq!( + extra, + [ + vec![0x01, 0x00], + vec![0xEEu8; 785].encode(), + 3u32.to_le_bytes().to_vec(), + vec![0x01], + vec![0xAB; 64], + ] + .concat() + ); + } + + #[test] + fn selectors_match_reference_vectors() { + assert_eq!(hex::encode(selector("getLabelStore(address)")), "4fc5dce9"); + assert_eq!( + hex::encode(selector("getLabels(uint256,uint256)")), + "2d7b5794" + ); + assert_eq!(hex::encode(selector("pendingClaim(address)")), "0ef4b248"); + assert_eq!(hex::encode(selector("protocolRegistry()")), "7656419f"); + assert_eq!(hex::encode(selector("get(bytes32)")), "8eaa6ac0"); + assert_eq!(hex::encode(selector("target()")), "d4b83992"); + } + + #[test] + fn calldata_encoders_place_arguments_in_padded_words() { + let address_call = call_address("getLabelStore(address)", &[0xAA; 20]); + assert_eq!(address_call.len(), 4 + 32); + assert_eq!(&address_call[..4], &selector("getLabelStore(address)")); + assert!(address_call[4..16].iter().all(|b| *b == 0)); + assert_eq!(&address_call[16..], &[0xAA; 20]); + + let pair_call = call_u256_pair("getLabels(uint256,uint256)", 0, 16); + assert_eq!(pair_call.len(), 4 + 64); + assert_eq!(pair_call[35], 0); + assert_eq!(pair_call[67], 16); + + let key_call = call_bytes32("get(bytes32)", ®istry_key("storeFactory")); + assert_eq!(&key_call[4..16], b"storeFactory"); + assert!(key_call[16..36].iter().all(|b| *b == 0)); + } + + #[test] + fn account_to_h160_follows_the_revive_mapper() { + let mut eth_derived = [0x0Fu8; 32]; + eth_derived[20..].fill(0xEE); + assert_eq!(account_to_h160(ð_derived), [0x0F; 20]); + + let hashed = account_to_h160(&[0x11; 32]); + assert_eq!(hashed, keccak_256(&[0x11; 32])[12..]); + } + + /// Encodes a `ContractResult` prefix followed by `result`. + fn contract_result(result: &[u8]) -> Vec { + let mut out = Vec::new(); + for _ in 0..4 { + Compact(7u64).encode_to(&mut out); + } + for _ in 0..2 { + (1u8, 0u128).encode_to(&mut out); + } + 0u128.encode_to(&mut out); + out.extend_from_slice(result); + out + } + + #[test] + fn revive_call_output_surfaces_data_reverts_and_dispatch_errors() { + let mut ok = vec![0u8]; + 0u32.encode_to(&mut ok); + vec![0xABu8, 0xCD].encode_to(&mut ok); + assert_eq!( + decode_revive_call_output(&contract_result(&ok)).unwrap(), + vec![0xAB, 0xCD] + ); + + // Error("nope") revert payload under ReturnFlags::REVERT. + let mut revert_data = vec![0x08, 0xc3, 0x79, 0xa0]; + revert_data.extend_from_slice(&{ + let mut word = [0u8; 32]; + word[31] = 0x20; + word + }); + revert_data.extend_from_slice(&{ + let mut word = [0u8; 32]; + word[31] = 4; + word + }); + revert_data.extend_from_slice(b"nope"); + revert_data.extend_from_slice(&[0u8; 28]); + let mut reverted = vec![0u8]; + 1u32.encode_to(&mut reverted); + revert_data.encode_to(&mut reverted); + let err = decode_revive_call_output(&contract_result(&reverted)).unwrap_err(); + assert!( + matches!(&err, DotnsContractError::Reverted { detail } if detail == "Error(\"nope\")"), + "unexpected error: {err}" + ); + + let dispatch = contract_result(&[0x01, 0x02, 0x03]); + assert!(matches!( + decode_revive_call_output(&dispatch).unwrap_err(), + DotnsContractError::Dispatch { .. } + )); + } + + /// ABI word with `value` right-aligned. + fn abi_word(value: u64) -> [u8; 32] { + let mut word = [0u8; 32]; + word[24..].copy_from_slice(&value.to_be_bytes()); + word + } + + /// ABI string tail: a length word plus padded bytes. + fn abi_string(value: &str) -> Vec { + let mut out = abi_word(value.len() as u64).to_vec(); + out.extend_from_slice(value.as_bytes()); + out.resize(out.len().div_ceil(32) * 32, 0); + out + } + + #[test] + fn abi_decoders_handle_addresses_string_arrays_and_pending_claims() { + let mut address = [0u8; 32]; + address[12..].fill(0xBC); + assert_eq!(decode_address(&address).unwrap(), [0xBC; 20]); + assert!(decode_address(&[0xFF; 32]).is_err()); + + // string[] = ["alice01", "bob"]. + let mut array = abi_word(0x20).to_vec(); + array.extend_from_slice(&abi_word(2)); + array.extend_from_slice(&abi_word(0x40)); + array.extend_from_slice(&abi_word(0x40 + 0x40)); + array.extend_from_slice(&abi_string("alice01")); + array.extend_from_slice(&abi_string("bob")); + assert_eq!( + decode_string_array(&array).unwrap(), + vec!["alice01".to_string(), "bob".to_string()] + ); + assert_eq!( + decode_string_array(&[abi_word(0x20), abi_word(0)].concat()).unwrap(), + Vec::::new() + ); + + // pendingClaim = ("alice", 42). + let mut claim = abi_word(0x20).to_vec(); + claim.extend_from_slice(&abi_word(0x40)); + claim.extend_from_slice(&abi_word(42)); + claim.extend_from_slice(&abi_string("alice")); + assert_eq!( + decode_pending_claim(&claim).unwrap(), + ("alice".to_string(), 42) + ); + + // pendingClaims = [("alice01", 42), ("bob", 7)]. Deployed-era plural. + let struct_a = [ + abi_word(0x40).to_vec(), + abi_word(42).to_vec(), + abi_string("alice01"), + ] + .concat(); + let struct_b = [ + abi_word(0x40).to_vec(), + abi_word(7).to_vec(), + abi_string("bob"), + ] + .concat(); + let mut claims = abi_word(0x20).to_vec(); + claims.extend_from_slice(&abi_word(2)); + claims.extend_from_slice(&abi_word(0x40)); + claims.extend_from_slice(&abi_word(0x40 + struct_a.len() as u64)); + claims.extend_from_slice(&struct_a); + claims.extend_from_slice(&struct_b); + assert_eq!( + decode_pending_claims_array(&claims).unwrap(), + vec!["alice01".to_string(), "bob".to_string()] + ); + assert_eq!( + decode_pending_claims_array(&[abi_word(0x20), abi_word(0)].concat()).unwrap(), + Vec::::new() + ); + } + + #[test] + fn labels_classify_into_lite_and_full_usernames() { + let identity = classify_labels(["alice01", "myproject"]); + assert_eq!(identity.lite_username.as_deref(), Some("alice.01")); + assert_eq!(identity.full_username.as_deref(), Some("myproject")); + + // `.dot` suffixes are tolerated. Digit-bearing stems are full names. + let identity = classify_labels(["bobby42.dot", "web3app"]); + assert_eq!(identity.lite_username.as_deref(), Some("bobby.42")); + assert_eq!(identity.full_username.as_deref(), Some("web3app")); + + assert_eq!( + classify_labels(Vec::::new()), + DotnsIdentity::default() + ); + // One trailing digit or a digit inside the stem is not lite format. + let identity = classify_labels(["alice1", "a2b34"]); + assert_eq!(identity.lite_username, None); + assert_eq!(identity.full_username.as_deref(), Some("alice1")); + } + + #[test] + fn storage_keys_have_the_expected_layout() { + let alias_key = account_alias_key(&[0x11; 32]); + let prefix = [ + twox_128(b"DotnsGateway").as_slice(), + twox_128(b"AccountAlias").as_slice(), + ] + .concat(); + assert_eq!(&alias_key[..32], prefix.as_slice()); + // Blake2_128Concat over the raw account bytes. + assert_eq!(&alias_key[48..], &[0x11; 32]); + + assert_eq!(pop_controller_address_key().len(), 32); + assert_eq!(dispatcher_address_key().len(), 32); + assert_eq!(timestamp_now_key().len(), 32); + } + + #[test] + fn revive_call_args_encode_origin_dest_and_input() { + let args = encode_revive_call(&[0x11; 32], &[0x22; 20], &[0xAB, 0xCD]); + assert_eq!(&args[..32], &[0x11; 32]); + assert_eq!(&args[32..52], &[0x22; 20]); + assert_eq!(&args[52..68], &[0u8; 16], "value is zero"); + assert_eq!(&args[68..70], &[0x00, 0x00], "no gas or deposit limit"); + assert_eq!(&args[70..], vec![0xABu8, 0xCD].encode().as_slice()); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs deleted file mode 100644 index bcf0d834a..000000000 --- a/rust/crates/truapi-server/src/host_logic/identity.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! People-chain identity lookup for paired SSO sessions. -//! -//! The previous host-papp path read `Resources.Consumers[account]` from -//! the People chain and used only the username fields. Keep this module narrow: -//! it builds that storage key and decodes the leading username fields from the -//! SCALE value. The record begins with a fixed identifier public key; credibility -//! and statement-store slots are intentionally ignored. -//! Host-spec G defines the cross-host identity model and lookup behavior: -//! - -use parity_scale_codec::{Decode, Encode}; -use sp_crypto_hashing::{blake2_128, twox_128}; - -/// Username fields read from a People-chain `Resources.Consumers` record. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PeopleIdentity { - /// Lite username; `None` when the record stores an empty string. - pub lite_username: Option, - /// Full username, when the account has registered one. - pub full_username: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -struct ConsumerUsernamePrefix { - identifier_key: [u8; 65], - full_username: Option>, - lite_username: Vec, -} - -/// Build the People-chain `Resources.Consumers` storage key for `account_id`. -pub fn resources_consumers_storage_key(account_id: &[u8; 32]) -> Vec { - let mut key = Vec::with_capacity(32 + 16 + account_id.len()); - key.extend_from_slice(&twox_128(b"Resources")); - key.extend_from_slice(&twox_128(b"Consumers")); - key.extend_from_slice(&blake2_128(account_id)); - key.extend_from_slice(account_id); - key -} - -/// Decode the username fields from a `Resources.Consumers` storage value. -pub fn decode_people_identity(value: &[u8]) -> Result { - if value.len() < 65 { - return Err(format!( - "invalid Resources.Consumers record: expected 65-byte identifier key, got {} bytes", - value.len() - )); - } - - let mut input = value; - let decoded = ConsumerUsernamePrefix::decode(&mut input) - .map_err(|err| format!("invalid Resources.Consumers record: {err}"))?; - let lite_username = non_empty_string(decoded.lite_username)?; - let full_username = decoded - .full_username - .map(non_empty_string) - .transpose()? - .flatten(); - Ok(PeopleIdentity { - lite_username, - full_username, - }) -} - -fn non_empty_string(bytes: Vec) -> Result, String> { - if bytes.is_empty() { - return Ok(None); - } - let value = String::from_utf8(bytes) - .map_err(|err| format!("Resources.Consumers username is not UTF-8: {err}"))?; - Ok(Some(value)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resources_consumers_key_uses_expected_prefix() { - let key = resources_consumers_storage_key(&[0x42; 32]); - - assert_eq!(key.len(), 80); - assert_eq!(&key[..16], &twox_128(b"Resources")); - assert_eq!(&key[16..32], &twox_128(b"Consumers")); - assert_eq!(&key[48..], &[0x42; 32]); - } - - #[test] - fn twox128_matches_substrate_storage_prefix_vector() { - assert_eq!( - hex::encode(twox_128(b"System")), - "26aa394eea5630e07c48ae0c9558cef7" - ); - } - - #[test] - fn decodes_username_prefix_and_ignores_trailing_fields() { - let mut value = vec![0x04; 65]; - value.extend((Some(b"Alice Smith".to_vec()), b"alice.01".to_vec()).encode()); - value.extend_from_slice(&[0xff; 8]); - - let decoded = decode_people_identity(&value).expect("identity should decode"); - - assert_eq!(decoded.full_username.as_deref(), Some("Alice Smith")); - assert_eq!(decoded.lite_username.as_deref(), Some("alice.01")); - } - - #[test] - fn consumer_username_prefix_matches_runtime_field_codec() { - let prefix = ConsumerUsernamePrefix { - identifier_key: [0x04; 65], - full_username: Some(b"Alice Smith".to_vec()), - lite_username: b"alice.01".to_vec(), - }; - let encoded = prefix.encode(); - let runtime_fields = ( - prefix.identifier_key, - prefix.full_username.as_ref(), - prefix.lite_username.as_slice(), - ) - .encode(); - - assert_eq!(encoded, runtime_fields); - assert_eq!( - ConsumerUsernamePrefix::decode(&mut encoded.as_slice()).unwrap(), - prefix - ); - } - - #[test] - fn empty_full_username_is_none() { - let mut value = vec![0x04; 65]; - value.extend((Some(Vec::::new()), b"alice.01".to_vec()).encode()); - - let decoded = decode_people_identity(&value).expect("identity should decode"); - - assert_eq!(decoded.full_username, None); - assert_eq!(decoded.lite_username.as_deref(), Some("alice.01")); - } - - #[test] - fn rejects_missing_identifier_key() { - let value = (None::>, b"alice.01".to_vec()).encode(); - - let error = decode_people_identity(&value).expect_err("identity should reject"); - - assert!(error.contains("65-byte identifier key")); - } -} diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index 94e2da354..d3aa099a7 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -24,9 +24,9 @@ pub const IDENTITY_PRODUCT_ID: &str = "uid.dot"; pub const PERSONHOOD_PRODUCT_ID: &str = "peopl.dot"; const RING_VRF_ROOT_KEY: &[u8] = b"ring-vrf"; -/// Substrate sr25519 signing-context string, shared by every sr25519 signature -/// the core produces (statement store, product raw signing). -pub(crate) const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; +/// Substrate sr25519 signing-context string. Shared by every sr25519 signature +/// the core produces: statement store, product raw signing, dotNS gateway. +pub const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; /// Error deriving product accounts or keys. #[derive(Debug, Error, PartialEq, Eq)] diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index e37e9d5cd..df37fac09 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -1568,6 +1568,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], "polkadotapp".to_string(), ) .expect("test pairing config is valid"); diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 974a490f5..4c9a1b4d8 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -634,6 +634,7 @@ mod tests { }, [0; 32], [0xbb; 32], + [0xcc; 32], "polkadotapp".to_string(), ) .expect("test runtime config is valid") diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index eab2d7bb1..64e8ba44f 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -239,6 +239,8 @@ pub struct NativeRuntimeConfig { pub people_chain_genesis_hash: Vec, /// Bulletin-chain genesis hash. Must be exactly 32 bytes. pub bulletin_chain_genesis_hash: Vec, + /// Asset Hub genesis hash. Must be exactly 32 bytes. + pub asset_hub_chain_genesis_hash: Vec, /// Optional local signing-host secret material (raw BIP-39 entropy). pub local_session_secret: Option>, /// Optional lite username attached to the local signing-host session. @@ -276,6 +278,12 @@ pub enum NativeRuntimeConfigError { /// Supplied byte length. actual: u64, }, + /// Asset Hub genesis hash was not exactly 32 bytes. + #[error("asset_hub_chain_genesis_hash must be exactly 32 bytes, got {actual}")] + InvalidAssetHubChainGenesisHash { + /// Supplied byte length. + actual: u64, + }, /// Host icon URL could not be parsed. #[error("host_icon must be an absolute HTTPS URL: {reason}")] InvalidHostIcon { @@ -324,6 +332,12 @@ impl TryFrom for NativeResolvedRuntimeConfig { actual: config.bulletin_chain_genesis_hash.len() as u64, } })?; + let asset_hub_chain_genesis_hash = + <[u8; 32]>::try_from(config.asset_hub_chain_genesis_hash.as_slice()).map_err(|_| { + NativeRuntimeConfigError::InvalidAssetHubChainGenesisHash { + actual: config.asset_hub_chain_genesis_hash.len() as u64, + } + })?; let product = ProductContext::new(config.product_id).map_err(NativeRuntimeConfigError::from)?; let signing = SigningHostConfig::new( @@ -338,6 +352,7 @@ impl TryFrom for NativeResolvedRuntimeConfig { }, people_chain_genesis_hash, bulletin_chain_genesis_hash, + asset_hub_chain_genesis_hash, )?; Ok(Self { signing, @@ -1264,6 +1279,7 @@ mod tests { platform_version: None, people_chain_genesis_hash: vec![0xa2; 32], bulletin_chain_genesis_hash: vec![0xbb; 32], + asset_hub_chain_genesis_hash: vec![0xcc; 32], local_session_secret: None, local_session_lite_username: None, pairing_deeplink_scheme: NativePairingDeeplinkScheme::PolkadotApp, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 06e08fe2b..5b15a143e 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -349,6 +349,7 @@ impl ProductRuntimeHost { truapi_platform::PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], "polkadotapp".to_string(), ) .expect("compat runtime config is valid") diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index c783c603e..61edc8d86 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -1,4 +1,13 @@ -//! People-chain identity lookup used to resolve usernames for a paired session. +//! dotNS identity lookup used to resolve usernames for a paired session. +//! +//! Usernames live in the dotNS contracts on Asset Hub. The gateway pallet +//! storage anchors the `DotnsPopController`. The protocol registry locates the +//! `StoreFactory`. The account's labels come from its `LabelStore` on the warm +//! path. On the cold path they come from its pending claim on the controller, +//! covering gateway-minted names before the user settles their store. +//! +//! All reads run over one `chainHead_v1` follow via `ReviveApi_call` dry-runs. +//! No chain metadata is needed. use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] @@ -8,52 +17,49 @@ use web_time::Duration; use crate::chain_runtime::{ ChainHeadStorageValue, ChainHeadStorageValueLookup, ChainRuntime, - wait_for_chain_head_best_hash, wait_for_chain_head_storage_value, + wait_for_chain_head_best_hash, wait_for_chain_head_call_output, + wait_for_chain_head_storage_value, }; -use crate::host_logic::identity::{ - PeopleIdentity, decode_people_identity, resources_consumers_storage_key, +use crate::host_logic::dotns_gateway::{ + DotnsIdentity, DotnsTransport, VIEW_CALL_ORIGIN, classify_labels, decode_revive_call_output, + discover_pop_controller, encode_revive_call, resolve_labels, }; use crate::host_logic::session::SessionInfo; +use futures::stream::BoxStream; use futures::{FutureExt, pin_mut}; use tracing::{debug, instrument, warn}; use truapi::latest::{ - OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, - StorageQueryItem, StorageQueryType, + OperationStartedResult, RemoteChainHeadCallRequest, RemoteChainHeadFollowItem, + RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, StorageQueryItem, + StorageQueryType, }; -/// Budget for the whole People-chain lookup (best block + storage read). +/// Budget for the whole Asset Hub lookup: best block, storage, contract views. const LOOKUP_TIMEOUT: Duration = Duration::from_secs(10); -const LOOKUP_RETRY_INTERVAL: Duration = Duration::from_secs(2); const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); -/// Monotonic salt for local identity lookup follow ids, avoiding collisions -/// between concurrent People-chain identity lookups. +/// Monotonic salt for local identity lookup follow ids. It keeps concurrent +/// dotNS identity lookups from colliding. static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); -enum ConsumerRecordLookup { - Found(Vec), - Missing, - Inaccessible, -} - -/// Fill in missing usernames by querying the people chain; returns the -/// session unchanged when it already carries a username or no people chain -/// is configured. +/// Fills in missing usernames by querying the dotNS contracts on Asset Hub. +/// Returns the session unchanged when it already carries a username. Also +/// returns it unchanged when no Asset Hub is configured. #[instrument(skip_all, fields(runtime.method = "session.identity.resolve_with_chain"))] pub(super) async fn resolve_session_identity_with_chain( chain: &ChainRuntime, - people_chain_genesis_hash: [u8; 32], + asset_hub_chain_genesis_hash: [u8; 32], mut session: SessionInfo, ) -> SessionInfo { - if session.has_username() || people_chain_genesis_hash == [0; 32] { + if session.has_username() || asset_hub_chain_genesis_hash == [0; 32] { return session; } let preferred_account = session.identity_account_id.unwrap_or(session.public_key); if lookup_and_apply( chain, - people_chain_genesis_hash, + asset_hub_chain_genesis_hash, preferred_account, &mut session, "identity", @@ -65,7 +71,7 @@ pub(super) async fn resolve_session_identity_with_chain( let public_key = session.public_key; lookup_and_apply( chain, - people_chain_genesis_hash, + asset_hub_chain_genesis_hash, public_key, &mut session, "root identity", @@ -77,37 +83,37 @@ pub(super) async fn resolve_session_identity_with_chain( } /// Maximum lookup attempts per account on transient failure. The first attempt -/// warms the People-chain connection (cached per genesis), so a retry after a -/// cold-start timeout usually resolves immediately. +/// warms the Asset Hub connection, cached per genesis. A retry after a cold-start +/// timeout therefore usually resolves immediately. const IDENTITY_LOOKUP_MAX_ATTEMPTS: usize = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LookupOutcome { /// A username record was found and applied. Applied, - /// The account has no consumer record (definitive; do not retry). + /// The account has no dotNS labels. Definitive, not worth a retry. NoRecord, /// The lookup failed transiently after exhausting retries. Failed, } -/// Look up `account`'s people-chain identity and apply any usernames to -/// `session`, retrying transient failures against the warmed connection. +/// Looks up `account`'s dotNS identity and applies any usernames to `session`. +/// Transient failures are retried against the warmed connection. async fn lookup_and_apply( chain: &ChainRuntime, - people_chain_genesis_hash: [u8; 32], + asset_hub_chain_genesis_hash: [u8; 32], account: [u8; 32], session: &mut SessionInfo, label: &str, ) -> LookupOutcome { for attempt in 1..=IDENTITY_LOOKUP_MAX_ATTEMPTS { - match lookup_people_identity(chain, people_chain_genesis_hash, account).await { + match lookup_dotns_identity(chain, asset_hub_chain_genesis_hash, account).await { Ok(Some(identity)) => { debug!( account = %hex::encode(account), lite_username = identity.lite_username.as_deref().unwrap_or(""), full_username = identity.full_username.as_deref().unwrap_or(""), - "People-chain {label} lookup found username" + "dotNS {label} lookup found username" ); session.apply_usernames(identity.lite_username, identity.full_username); return LookupOutcome::Applied; @@ -115,7 +121,7 @@ async fn lookup_and_apply( Ok(None) => { debug!( account = %hex::encode(account), - "People-chain {label} lookup found no consumer record" + "dotNS {label} lookup found no labels" ); return LookupOutcome::NoRecord; } @@ -124,7 +130,7 @@ async fn lookup_and_apply( account = %hex::encode(account), attempt, %reason, - "People-chain {label} lookup failed" + "dotNS {label} lookup failed" ); } } @@ -132,100 +138,158 @@ async fn lookup_and_apply( LookupOutcome::Failed } +/// Resolves `account_id`'s usernames from the dotNS contracts at a fresh Asset +/// Hub head, under one overall time budget. Returns `None` when the gateway is not +/// deployed. Also returns `None` when the account holds no labels. #[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] -async fn lookup_people_identity( +async fn lookup_dotns_identity( chain: &ChainRuntime, - people_chain_genesis_hash: [u8; 32], + asset_hub_chain_genesis_hash: [u8; 32], account_id: [u8; 32], -) -> Result, String> { +) -> Result, String> { let timeout = futures_timer::Delay::new(LOOKUP_TIMEOUT).fuse(); pin_mut!(timeout); - loop { - let lookup = fetch_consumer_record(chain, people_chain_genesis_hash, account_id).fuse(); - pin_mut!(lookup); - let lookup = futures::select! { - value = lookup => value?, - () = timeout => return Err("People-chain identity lookup timed out".to_string()), + let lookup = async { + let mut lookup = + DotnsLookup::pinned_to_best_block(chain, asset_hub_chain_genesis_hash, account_id) + .await?; + let Some(controller) = discover_pop_controller(&mut lookup).await? else { + return Ok(None); }; - match lookup { - ConsumerRecordLookup::Found(value) => { - return decode_people_identity(&value).map(Some); - } - ConsumerRecordLookup::Missing => return Ok(None), - ConsumerRecordLookup::Inaccessible => {} - } - - let retry = futures_timer::Delay::new(LOOKUP_RETRY_INTERVAL).fuse(); - pin_mut!(retry); - futures::select! { - () = retry => {}, - () = timeout => return Err("People-chain identity lookup timed out".to_string()), + let labels = resolve_labels(&mut lookup, &controller, &account_id).await?; + if labels.is_empty() { + return Ok(None); } + Ok(Some(classify_labels(labels))) + } + .fuse(); + pin_mut!(lookup); + futures::select! { + value = lookup => value, + () = timeout => Err("dotNS identity lookup timed out".to_string()), } } -/// Read the raw `Resources.Consumers` record for `account_id` at a fresh -/// People-chain head. The key is built locally, so the read never needs the -/// People-chain metadata. -async fn fetch_consumer_record( - chain: &ChainRuntime, - people_chain_genesis_hash: [u8; 32], - account_id: [u8; 32], -) -> Result { - let genesis_hash = people_chain_genesis_hash.to_vec(); - let key = resources_consumers_storage_key(&account_id); - let lookup_id = IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed); - let follow_id = format!("truapi:identity:{lookup_id}:{}", hex::encode(account_id)); - let mut follow = chain.remote_chain_head_follow( - follow_id.clone(), - RemoteChainHeadFollowRequest { - genesis_hash: genesis_hash.clone(), - with_runtime: false, - }, - ); - - let hash = wait_for_chain_head_best_hash( - &mut follow, - "People-chain", - LOOKUP_TIMEOUT, - BEST_BLOCK_TIMEOUT, - ) - .await?; - let response = chain - .remote_chain_head_storage(RemoteChainHeadStorageRequest { - genesis_hash: genesis_hash.clone(), - follow_subscription_id: follow_id.clone(), +/// One pinned-block context for the dotNS lookup steps. It owns the +/// `chainHead_v1` follow every read and view runs over. +struct DotnsLookup<'a> { + chain: &'a ChainRuntime, + follow: BoxStream<'static, RemoteChainHeadFollowItem>, + genesis_hash: Vec, + follow_id: String, + hash: Vec, +} + +impl<'a> DotnsLookup<'a> { + /// Opens a follow on Asset Hub and pins it to the current best block. + async fn pinned_to_best_block( + chain: &'a ChainRuntime, + asset_hub_chain_genesis_hash: [u8; 32], + account_id: [u8; 32], + ) -> Result { + let genesis_hash = asset_hub_chain_genesis_hash.to_vec(); + let lookup_id = IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed); + let follow_id = format!("truapi:identity:{lookup_id}:{}", hex::encode(account_id)); + let mut follow = chain.remote_chain_head_follow( + follow_id.clone(), + RemoteChainHeadFollowRequest { + genesis_hash: genesis_hash.clone(), + with_runtime: false, + }, + ); + let hash = wait_for_chain_head_best_hash( + &mut follow, + "Asset Hub", + LOOKUP_TIMEOUT, + BEST_BLOCK_TIMEOUT, + ) + .await?; + Ok(Self { + chain, + follow, + genesis_hash, + follow_id, hash, - items: vec![StorageQueryItem { - key: key.clone(), - query_type: StorageQueryType::Value, - }], - child_trie: None, }) - .await - .map_err(|failure| failure.reason())?; - let operation_id = match response.operation { - OperationStartedResult::Started { operation_id } => operation_id, + } +} + +#[truapi_platform::async_trait] +impl DotnsTransport for DotnsLookup<'_> { + /// Reads one storage value at the pinned block. `Ok(None)` when absent. + async fn storage(&mut self, key: Vec) -> Result>, String> { + let response = self + .chain + .remote_chain_head_storage(RemoteChainHeadStorageRequest { + genesis_hash: self.genesis_hash.clone(), + follow_subscription_id: self.follow_id.clone(), + hash: self.hash.clone(), + items: vec![StorageQueryItem { + key: key.clone(), + query_type: StorageQueryType::Value, + }], + child_trie: None, + }) + .await + .map_err(|failure| failure.reason())?; + let operation_id = started_operation_id(response.operation)?; + let value = wait_for_chain_head_storage_value( + &mut self.follow, + ChainHeadStorageValueLookup { + chain: self.chain, + genesis_hash: &self.genesis_hash, + follow_subscription_id: &self.follow_id, + operation_id: &operation_id, + key: &key, + label: "Asset Hub", + timeout: LOOKUP_TIMEOUT, + }, + ) + .await?; + match value { + ChainHeadStorageValue::Found(value) => Ok(Some(value)), + ChainHeadStorageValue::Missing => Ok(None), + ChainHeadStorageValue::Inaccessible => { + Err("Asset Hub storage was inaccessible".to_string()) + } + } + } + + /// Dry-runs a contract view via `ReviveApi_call` at the pinned block and + /// returns its data. + /// + /// Views originate from the synthetic always-mapped account. They work + /// regardless of the queried account's revive mapping. + async fn view(&mut self, dest: &[u8; 20], input: Vec) -> Result, String> { + let response = self + .chain + .remote_chain_head_call(RemoteChainHeadCallRequest { + genesis_hash: self.genesis_hash.clone(), + follow_subscription_id: self.follow_id.clone(), + hash: self.hash.clone(), + function: "ReviveApi_call".to_string(), + call_parameters: encode_revive_call(&VIEW_CALL_ORIGIN, dest, &input), + }) + .await + .map_err(|failure| failure.reason())?; + let operation_id = started_operation_id(response.operation)?; + let output = wait_for_chain_head_call_output( + &mut self.follow, + &operation_id, + "Asset Hub", + LOOKUP_TIMEOUT, + ) + .await?; + decode_revive_call_output(&output).map_err(|err| err.to_string()) + } +} + +/// Unwraps a started operation id. `LimitReached` maps to an error. +fn started_operation_id(operation: OperationStartedResult) -> Result { + match operation { + OperationStartedResult::Started { operation_id } => Ok(operation_id), OperationStartedResult::LimitReached => { - return Err("People-chain storage lookup limit reached".to_string()); + Err("Asset Hub operation limit reached".to_string()) } - }; - let value = wait_for_chain_head_storage_value( - &mut follow, - ChainHeadStorageValueLookup { - chain, - genesis_hash: &genesis_hash, - follow_subscription_id: &follow_id, - operation_id: &operation_id, - key: &key, - label: "People-chain", - timeout: LOOKUP_TIMEOUT, - }, - ) - .await?; - Ok(match value { - ChainHeadStorageValue::Found(value) => ConsumerRecordLookup::Found(value), - ChainHeadStorageValue::Missing => ConsumerRecordLookup::Missing, - ChainHeadStorageValue::Inaccessible => ConsumerRecordLookup::Inaccessible, - }) + } } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 518af8809..b615fe2d3 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -246,7 +246,7 @@ impl PairingHost { Ok(session) => { let resolved = resolve_session_identity_with_chain( &pairing_host.chain, - pairing_host.host_config.people_chain_genesis_hash, + pairing_host.host_config.asset_hub_chain_genesis_hash, session, ) .await; @@ -519,13 +519,13 @@ impl PairingHost { async fn refresh_current_session_identity(&self) -> Option { let current = self.session_state.current()?; - if current.has_username() || self.host_config.people_chain_genesis_hash == [0; 32] { + if current.has_username() || self.host_config.asset_hub_chain_genesis_hash == [0; 32] { return Some(authority_session(¤t)); } let resolved = resolve_session_identity_with_chain( &self.chain, - self.host_config.people_chain_genesis_hash, + self.host_config.asset_hub_chain_genesis_hash, current.clone(), ) .await; diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index b1f3a2bd1..83d3895ca 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -803,6 +803,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], ) .expect("signing host config is valid"); let services = RuntimeServices::new( diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index f1b822ad8..d16d2ef43 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -1273,6 +1273,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], ) .expect("signing host config is valid"); let services = RuntimeServices::new( diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index ba9f71e8e..81fcb129e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -135,6 +135,7 @@ pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { @@ -347,7 +360,8 @@ pub async fn claim_long_term_storage( let context = slot::derive_long_term_storage_context(period, counter); let call = extrinsic::build_claim_long_term_storage_call(metadata, period, counter, target)?; - let message = extension::build_proof_message(metadata, &call, chain_state)?; + let message = + extension::build_proof_message(metadata, &call, chain_state, extension::AS_RESOURCES)?; let domain = proof::domain_for_ring_exponent(ring.exponent)?; let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; let as_resources_extra = extrinsic::build_long_term_storage_extra( @@ -356,8 +370,13 @@ pub async fn claim_long_term_storage( ring.ring_index, revision, )?; - let extrinsic = - extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + let extrinsic = extrinsic::build_unsigned_extrinsic( + metadata, + chain_state, + &call, + extension::AS_RESOURCES, + &as_resources_extra, + )?; debug!( period, counter, @@ -626,6 +645,7 @@ mod tests { transaction_version: 1, genesis_hash: [0xab; 32], nonce: 0, + restrict_origins: false, }; let entropy = [0x11; 32]; let ring = RingParams { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index 9deccdc46..bf2f2166a 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -27,6 +27,10 @@ use super::StatementAllowanceError; /// Signed-extension identifier that carries the `AsResources` authorization. pub const AS_RESOURCES: &str = "AsResources"; +/// Signed-extension identifier that carries the `AsDotnsGateway` +/// authorization on Asset Hub. +pub const AS_DOTNS_GATEWAY: &str = "AsDotnsGateway"; + /// Error while decoding runtime metadata or resolving allowance-specific /// metadata entries. #[derive(Debug, Error)] @@ -63,9 +67,21 @@ pub enum MetadataError { /// Call name. call: String, }, - /// `AsResources` extension is absent from metadata. - #[error("{AS_RESOURCES} extension not found in metadata")] - MissingAsResourcesExtension, + /// A named transaction extension is absent from metadata. + #[error("{identifier} extension not found in metadata")] + MissingExtension { + /// Extension identifier. + identifier: &'static str, + }, + /// `RegisterFullName` did not have the expected field shape. + #[error( + "AsDotnsGatewayInfo::RegisterFullName fields are [{actual}], expected \ + [proof, ring_index, signature]; the runtime shape drifted" + )] + RegisterFullNameShapeDrift { + /// Actual comma-separated field names. + actual: String, + }, /// `AsResources` extra type did not contain the expected `Option`. #[error("{AS_RESOURCES} extra is not an Option")] AsResourcesExtraNotOption, @@ -139,6 +155,11 @@ pub struct ChainState { pub genesis_hash: [u8; 32], /// Account nonce (CheckNonce extra); ignored by the unsigned path. pub nonce: u32, + /// `RestrictOrigins` extra value. `false` for statement-store allowance + /// calls on People. `true` for restricted-origin dotNS gateway registrations + /// on Asset Hub. It is part of both the signed digest and the extrinsic + /// body, so it lives here to keep the two in lockstep. + pub restrict_origins: bool, } /// A signed extension's identifier plus the type ids of its `extra` and @@ -334,26 +355,22 @@ impl Metadata { Ok([pallet_index, variant.index]) } - /// Resolve `AsResourcesInfo::` and the - /// `MembershipCollection::LitePeople` index it carries, by name, from the - /// `AsResources` extension type. - pub fn as_resources_variant_indices( + /// Resolves the info-enum type carried by extension `identifier`. Its extra + /// is `Option`, with or without a struct wrapper. + fn extension_info_type( &self, - info_variant: &str, - ) -> Result<(u8, u8), StatementAllowanceError> { + identifier: &'static str, + ) -> Result { let ext = self .extensions .iter() - .find(|e| e.identifier == AS_RESOURCES) - .ok_or(MetadataError::MissingAsResourcesExtension)?; - // extra = `AsResources(Option)`, with or without the - // struct wrapper. + .find(|e| e.identifier == identifier) + .ok_or(MetadataError::MissingExtension { identifier })?; let option_type = match &self.resolve_type(ext.extra_type)?.type_def { TypeDef::Composite(_) => self.single_field_type(ext.extra_type)?, _ => ext.extra_type, }; - let info_type = self - .resolve_variant(option_type)? + self.resolve_variant(option_type)? .variants .iter() .find(|v| v.name == "Some") @@ -361,7 +378,46 @@ impl Metadata { [field] => Some(field.ty.id), _ => None, }) - .ok_or(MetadataError::AsResourcesExtraNotOption)?; + .ok_or(MetadataError::AsResourcesExtraNotOption.into()) + } + + /// Resolves `AsDotnsGatewayInfo::RegisterFullName` to its variant index. + /// + /// Asserts the exact `{proof, ring_index, signature}` field shape. A runtime + /// that grows the variant, say with a `revision` field, then fails loudly + /// instead of mis-encoding. + pub fn dotns_register_full_name_variant(&self) -> Result { + let info_type = self.extension_info_type(AS_DOTNS_GATEWAY)?; + let variant = self + .resolve_variant(info_type)? + .variants + .iter() + .find(|v| v.name == "RegisterFullName") + .ok_or_else(|| MetadataError::MissingAsResourcesInfoVariant { + variant: "RegisterFullName".to_string(), + })?; + let fields: Vec<&str> = variant + .fields + .iter() + .map(|field| field.name.as_deref().unwrap_or("")) + .collect(); + if fields != ["proof", "ring_index", "signature"] { + return Err(MetadataError::RegisterFullNameShapeDrift { + actual: fields.join(", "), + } + .into()); + } + Ok(variant.index) + } + + /// Resolves `AsResourcesInfo::` and the + /// `MembershipCollection::LitePeople` index it carries. Both are looked up by + /// name from the `AsResources` extension type. + pub fn as_resources_variant_indices( + &self, + info_variant: &str, + ) -> Result<(u8, u8), StatementAllowanceError> { + let info_type = self.extension_info_type(AS_RESOURCES)?; let variant = self .resolve_variant(info_type)? .variants @@ -466,8 +522,8 @@ impl Metadata { "VerifyMultiSignature" => (vec![0x00], Vec::new()), // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. "ChargeAssetTxPayment" => (vec![0x00, 0x00], Vec::new()), - // extra = bool false = 0x00. - "RestrictOrigins" => (vec![0x00], Vec::new()), + // extra = bool. See `ChainState::restrict_origins`. + "RestrictOrigins" => (vec![state.restrict_origins as u8], Vec::new()), _ => ( self.encode_default(ext.extra_type), self.encode_default(ext.additional_signed_type), @@ -531,28 +587,33 @@ impl Metadata { } } - /// Index of `AsResources` in the extension list, if present. - pub fn as_resources_index(&self) -> Option { + /// Index of the named extension in the extension list, if present. + pub fn extension_index(&self, identifier: &str) -> Option { self.extensions .iter() - .position(|e| e.identifier == AS_RESOURCES) + .position(|e| e.identifier == identifier) } } -/// Build the ring-VRF proof message for an `AsResources`-authorized call: -/// `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ tail.additional_signed)`, where -/// the tail is the extensions ordered strictly after `AsResources`. The leading -/// `0x00` is the General-transaction extension-version byte. +/// Builds the inherited-implication digest for a call authorized by the `pivot` +/// extension. +/// +/// The digest is `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ +/// tail.additional_signed)`. The tail is the extensions ordered strictly after +/// `pivot`. The leading `0x00` is the General-transaction extension-version byte. +/// This digest is the ring-VRF message for `AsResources` calls and the +/// sr25519-signed message for `AsDotnsGateway` registrations. pub fn build_proof_message( metadata: &Metadata, call_data: &[u8], state: &ChainState, + pivot: &'static str, ) -> Result<[u8; 32], StatementAllowanceError> { let all = metadata.encode_signed_extensions(state); let tail_start = metadata - .as_resources_index() + .extension_index(pivot) .map(|i| i + 1) - .ok_or(MetadataError::MissingAsResourcesExtension)?; + .ok_or(MetadataError::MissingExtension { identifier: pivot })?; let tail = &all[tail_start..]; let mut payload = Vec::with_capacity(1 + call_data.len()); @@ -591,6 +652,7 @@ mod tests { transaction_version: 1, genesis_hash: [0xab; 32], nonce: 0, + restrict_origins: false, } } @@ -606,7 +668,8 @@ mod tests { #[test] fn proof_message_matches_frozen_known_answer() { let metadata = Metadata::decode(FIXTURE).unwrap(); - let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state()).unwrap(); + let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state(), AS_RESOURCES) + .unwrap(); assert_eq!( hex::encode(msg), "1d2e6d8d8f421b0857097c6076115507432d66fea47ebe0c3be282a369f6743c", @@ -616,7 +679,7 @@ mod tests { #[test] fn as_resources_tail_is_indices_10_through_20() { let metadata = Metadata::decode(FIXTURE).unwrap(); - let idx = metadata.as_resources_index().unwrap(); + let idx = metadata.extension_index(AS_RESOURCES).unwrap(); // AsResources sits at index 9; the proof tail is everything after it. assert_eq!(idx, 9); let ids = metadata.extension_ids(); @@ -677,13 +740,66 @@ mod tests { ); } + /// Asset Hub fixture metadata captured from paseo-next-v2. That is the + /// runtime the dotNS gateway flows were validated against. + const AH_FIXTURE: &[u8] = + include_bytes!("../../../tests/fixtures/paseo-next-v2-asset-hub-metadata.scale"); + + #[test] + fn dotns_gateway_pipeline_matches_the_reference_transaction_layout() { + let metadata = Metadata::decode(AH_FIXTURE).unwrap(); + let state = ChainState { + restrict_origins: true, + ..fixture_state() + }; + + // RegisterFullName resolves with the asserted 3-field shape. + assert_eq!(metadata.dotns_register_full_name_variant().unwrap(), 0); + assert!( + metadata + .call_indices("DotnsGateway", "register_name") + .is_ok() + ); + + // The extension tail after AsDotnsGateway encodes exactly the bytes the + // reference implementation registerTx.ts hand-builds. Extras are + // RestrictOrigin(true) ‖ CheckEra(Immortal) ‖ CheckNonce(0) ‖ + // ChargePGAS(tip 0, asset None) ‖ CheckMetadataHash(Disabled). The + // implicit half is specVersion ‖ txVersion ‖ genesis ‖ genesis ‖ + // metadata-hash None. + let all = metadata.encode_signed_extensions(&state); + let tail_start = metadata.extension_index(AS_DOTNS_GATEWAY).unwrap() + 1; + let tail = &all[tail_start..]; + let extras: Vec = tail.iter().flat_map(|e| e.extra.clone()).collect(); + assert_eq!( + hex::encode(extras), + "010000000000", + "tail extras: RestrictOrigin true, era, nonce, tip, asset, metadata hash" + ); + let implicit: Vec = tail + .iter() + .flat_map(|e| e.additional_signed.clone()) + .collect(); + assert_eq!( + implicit, + [ + state.spec_version.to_le_bytes().to_vec(), + state.transaction_version.to_le_bytes().to_vec(), + state.genesis_hash.to_vec(), + state.genesis_hash.to_vec(), + vec![0x00], + ] + .concat() + ); + } + #[test] fn dropping_the_version_byte_changes_the_hash() { let metadata = Metadata::decode(FIXTURE).unwrap(); let state = fixture_state(); let call = fixture_call(); let all = metadata.encode_signed_extensions(&state); - let tail = &all[metadata.as_resources_index().unwrap() + 1..]; + let tail = &all[metadata.extension_index(AS_RESOURCES).unwrap() + 1..]; let mut without = call.clone(); for e in tail { without.extend_from_slice(&e.extra); @@ -692,7 +808,7 @@ mod tests { without.extend_from_slice(&e.additional_signed); } assert_ne!( - build_proof_message(&metadata, &call, &state).unwrap(), + build_proof_message(&metadata, &call, &state, AS_RESOURCES).unwrap(), blake2b256(&without), ); } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs index c6e03dd39..bf692b270 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -133,23 +133,28 @@ pub fn build_long_term_storage_extra( Ok(extra) } -/// Assemble the unsigned General (v5) extrinsic: -/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. +/// Assembles the unsigned General (v5) extrinsic. +/// +/// The layout is +/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, pivot = Some(info)) ‖ call`. +/// `pivot` names the extension whose `extra` is replaced by `pivot_extra`: +/// `AsResources` on People, `AsDotnsGateway` on Asset Hub. pub fn build_unsigned_extrinsic( metadata: &Metadata, state: &ChainState, call_data: &[u8], - as_resources_extra: &[u8], + pivot: &'static str, + pivot_extra: &[u8], ) -> Result, StatementAllowanceError> { let all = metadata.encode_signed_extensions(state); - let as_resources_index = metadata - .as_resources_index() - .ok_or(MetadataError::MissingAsResourcesExtension)?; + let pivot_index = metadata + .extension_index(pivot) + .ok_or(MetadataError::MissingExtension { identifier: pivot })?; let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; for (i, ext) in all.iter().enumerate() { - if i == as_resources_index { - body.extend_from_slice(as_resources_extra); + if i == pivot_index { + body.extend_from_slice(pivot_extra); } else { body.extend_from_slice(&ext.extra); } @@ -172,6 +177,7 @@ mod tests { transaction_version: 1, genesis_hash: [0xab; 32], nonce: 0, + restrict_origins: false, } } @@ -285,7 +291,14 @@ mod tests { let metadata = Metadata::decode(FIXTURE).unwrap(); let call = build_set_statement_store_account_call(&metadata, 7, 0, &[0u8; 32]).unwrap(); let extra = build_as_resources_extra(&metadata, &[0xEE; 785], 0).unwrap(); - let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); + let xt = build_unsigned_extrinsic( + &metadata, + &fixture_state(), + &call, + crate::runtime::statement_allowance::extension::AS_RESOURCES, + &extra, + ) + .unwrap(); // Strip the compact length prefix and check the body head + tail. let body = &xt[compact_prefix_len(&xt)..]; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs index aabd53fa5..b128c2a33 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -43,10 +43,24 @@ pub enum RingError { /// Ring status included field failed to decode. #[error("ring status: {0}")] RingStatus(#[source] parity_scale_codec::Error), + /// Member has no `Members.Members` record for the collection. + #[error("member has no Members.Members record for the collection")] + MemberRecordMissing, + /// Member is not included in a ring yet. + #[error("member is not included in a ring (status: {status})")] + MemberNotIncluded { + /// Onboarding or suspended. + status: &'static str, + }, + /// Subscriber ring exponent was absent for the collection. + #[error("MembersSubscriber.RingCollectionExponents missing for the collection")] + SubscriberExponentMissing, } /// LitePeople collection identifier: ASCII, exactly 32 bytes. -const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +pub const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +/// Full-person People collection identifier. ASCII, space-padded to 32 bytes. +pub const PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people "; /// Ring member public key length. const MEMBER_LEN: usize = 32; @@ -113,36 +127,59 @@ fn collections_key() -> Vec { .concat() } -/// `Members.RingKeysStatus[(id, ring_index)]` storage key. -fn ring_keys_status_key(ring_index: u32) -> Vec { +/// `Members.Root[(id, ring_index)]` storage key. +fn ring_root_key(ring_index: u32) -> Vec { [ twox_128(b"Members").as_slice(), - twox_128(b"RingKeysStatus").as_slice(), + twox_128(b"Root").as_slice(), LITE_PEOPLE_IDENTIFIER.as_slice(), &blake2_128_concat(&ring_index.to_le_bytes()), ] .concat() } -/// `Members.Root[(id, ring_index)]` storage key. -fn ring_root_key(ring_index: u32) -> Vec { +/// `Members.RingKeys[(identifier, ring_index, page)]` storage key. +fn collection_ring_keys_key(identifier: &[u8; 32], ring_index: u32, page: u32) -> Vec { [ twox_128(b"Members").as_slice(), - twox_128(b"Root").as_slice(), - LITE_PEOPLE_IDENTIFIER.as_slice(), + twox_128(b"RingKeys").as_slice(), + identifier.as_slice(), &blake2_128_concat(&ring_index.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), ] .concat() } -/// `Members.RingKeys[(id, ring_index, page)]` storage key. -fn ring_keys_key(ring_index: u32, page: u32) -> Vec { +/// `Members.RingKeysStatus[(identifier, ring_index)]` storage key. +fn collection_ring_keys_status_key(identifier: &[u8; 32], ring_index: u32) -> Vec { [ twox_128(b"Members").as_slice(), - twox_128(b"RingKeys").as_slice(), - LITE_PEOPLE_IDENTIFIER.as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + identifier.as_slice(), &blake2_128_concat(&ring_index.to_le_bytes()), - &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Members.Members[(identifier, member)]` storage key. +/// The hashers are `Identity` then `Blake2_128Concat`. +fn member_record_key(identifier: &[u8; 32], member: &[u8; 32]) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Members").as_slice(), + identifier.as_slice(), + &blake2_128_concat(member), + ] + .concat() +} + +/// `MembersSubscriber.RingCollectionExponents[identifier]` storage key. +/// It lives on the subscriber chain, Asset Hub. +fn subscriber_exponent_key(identifier: &[u8; 32]) -> Vec { + [ + twox_128(b"MembersSubscriber").as_slice(), + twox_128(b"RingCollectionExponents").as_slice(), + &blake2_128_concat(identifier), ] .concat() } @@ -209,11 +246,12 @@ pub async fn read_ring_exponent( }) } -/// Read the members of `ring_index`, sliced to the baked-in `included` -/// prefix, with every read pinned to block `at` so pages and status come from -/// one consistent snapshot. -pub async fn read_ring_members_at( +/// Reads the members of collection `identifier`'s `ring_index`, sliced to the +/// baked-in `included` prefix. Every read is pinned to block `at`, so pages and +/// status come from one consistent snapshot. +pub async fn read_collection_ring_members_at( rpc: &RpcClient, + identifier: &[u8; 32], ring_index: u32, at: &str, ) -> Result, StatementAllowanceError> { @@ -221,7 +259,7 @@ pub async fn read_ring_members_at( let mut members = Vec::new(); for page in 0.. { let Some(bytes) = rpc - .get_storage_at(&ring_keys_key(ring_index, page), at) + .get_storage_at(&collection_ring_keys_key(identifier, ring_index, page), at) .await? else { break; @@ -244,7 +282,7 @@ pub async fn read_ring_members_at( // 2. Slice to the baked-in `included` prefix (absent status => all included). if let Some(status) = rpc - .get_storage_at(&ring_keys_status_key(ring_index), at) + .get_storage_at(&collection_ring_keys_status_key(identifier, ring_index), at) .await? { // RingStatus = { total: u32 LE, included: u32 LE, .. }. @@ -256,6 +294,98 @@ pub async fn read_ring_members_at( Ok(members) } +/// Ring coordinates of one member. Projected from the runtime's `RingPosition` +/// enum. +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +enum MemberRingPosition { + /// Waiting in the onboarding queue. + Onboarding { + #[allow(dead_code)] + queue_page: u32, + #[allow(dead_code)] + queued_at: u64, + }, + /// Included in a built ring. + Included { + ring_index: u32, + #[allow(dead_code)] + ring_page: u32, + #[allow(dead_code)] + ring_position: u32, + }, + /// Suspended from all rings. + Suspended, +} + +/// Reads the ring index `member` is included in for collection `identifier`, +/// from `Members.Members`, pinned to block `at`. Errors when the member has no +/// record. Errors too when the member is not `Included` yet. +pub async fn read_member_ring_index( + rpc: &RpcClient, + metadata: &Metadata, + identifier: &[u8; 32], + member: &[u8; 32], + at: &str, +) -> Result { + let value = rpc + .get_storage_at(&member_record_key(identifier, member), at) + .await? + .ok_or(RingError::MemberRecordMissing)?; + let value_type = metadata.storage_value_type("Members", "Members").ok_or( + MetadataError::MissingStorageType { + pallet: "Members", + entry: "Members", + }, + )?; + let mut input = value.as_slice(); + let position = MemberRingPosition::decode_as_type(&mut input, value_type, metadata.registry()) + .map_err(|err| RingError::DecodeAsType { + context: "Members.Members", + source: err, + })?; + match position { + MemberRingPosition::Included { ring_index, .. } => Ok(ring_index), + MemberRingPosition::Onboarding { .. } => Err(RingError::MemberNotIncluded { + status: "onboarding", + } + .into()), + MemberRingPosition::Suspended => Err(RingError::MemberNotIncluded { + status: "suspended", + } + .into()), + } +} + +/// Reads the ring exponent the subscriber chain, Asset Hub, verifies collection +/// `identifier` against. The source is +/// `MembersSubscriber.RingCollectionExponents` at the current best block. +pub async fn read_subscriber_ring_exponent( + rpc: &RpcClient, + metadata: &Metadata, + identifier: &[u8; 32], +) -> Result { + let value = rpc + .get_storage(&subscriber_exponent_key(identifier)) + .await? + .ok_or(RingError::SubscriberExponentMissing)?; + let value_type = metadata + .storage_value_type("MembersSubscriber", "RingCollectionExponents") + .ok_or(MetadataError::MissingStorageType { + pallet: "MembersSubscriber", + entry: "RingCollectionExponents", + })?; + let mut input = value.as_slice(); + RingExponent::decode_as_type(&mut input, value_type, metadata.registry()) + .map(RingExponent::exponent) + .map_err(|err| { + RingError::DecodeAsType { + context: "MembersSubscriber.RingCollectionExponents", + source: err, + } + .into() + }) +} + /// Read `Members.Root[LitePeople][ring_index].revision` pinned to block `at` /// (absent => 0). pub async fn read_ring_revision( @@ -374,13 +504,19 @@ mod tests { let scripted = ScriptedRpc::new([page.as_str(), "null", status]); let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); - let members = futures::executor::block_on(read_ring_members_at(&rpc, 3, "0xat")).unwrap(); + let members = futures::executor::block_on(read_collection_ring_members_at( + &rpc, + LITE_PEOPLE_IDENTIFIER, + 3, + "0xat", + )) + .unwrap(); assert_eq!(members, vec![[0xaa; 32]]); let expected: Vec<(String, String)> = [ - ring_keys_key(3, 0), - ring_keys_key(3, 1), - ring_keys_status_key(3), + collection_ring_keys_key(LITE_PEOPLE_IDENTIFIER, 3, 0), + collection_ring_keys_key(LITE_PEOPLE_IDENTIFIER, 3, 1), + collection_ring_keys_status_key(LITE_PEOPLE_IDENTIFIER, 3), ] .into_iter() .map(|key| { diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index a9331624d..366d6c982 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -194,6 +194,7 @@ pub(crate) fn runtime_config(product_id: &str) -> (PairingHostConfig, ProductCon PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], "polkadotapp".to_string(), ) .expect("test host runtime config is valid"), diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 74ea5998b..af9b24000 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -441,6 +441,7 @@ fn pairing_host_config_from_js(value: &JsValue) -> Result Result &str { "pairing_deeplink_scheme" => "pairing.deeplinkScheme", "people_chain_genesis_hash" => "people.genesisHash", "bulletin_chain_genesis_hash" => "bulletin.genesisHash", + "asset_hub_chain_genesis_hash" => "assetHub.genesisHash", other => other, } } diff --git a/rust/crates/truapi-server/src/ws_bridge.rs b/rust/crates/truapi-server/src/ws_bridge.rs index 00a1b240f..884984515 100644 --- a/rust/crates/truapi-server/src/ws_bridge.rs +++ b/rust/crates/truapi-server/src/ws_bridge.rs @@ -521,6 +521,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], ) .expect("test signing host config is valid"); let runtime = Arc::new(SigningHostRuntime::new(platform, config, test_spawner())); diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index be868eaf2..c59bf50e1 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -58,6 +58,7 @@ pub fn test_runtime_config() -> (PairingHostConfig, ProductContext) { PlatformInfo::default(), [0xa2; 32], [0xbb; 32], + [0xcc; 32], "polkadotapp".to_string(), ) .expect("test host runtime config is valid"), diff --git a/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-asset-hub-metadata.scale b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-asset-hub-metadata.scale new file mode 100644 index 000000000..d61631513 Binary files /dev/null and b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-asset-hub-metadata.scale differ diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs index 1fe502bf8..1e736fa44 100644 --- a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -51,6 +51,7 @@ fn runtime_config() -> PairingHostConfig { }, [0xa2; 32], [0xbb; 32], + [0xcc; 32], "polkadotapp".to_string(), ) .expect("test runtime config is valid") diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a138df037..f782afd89 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -107,6 +107,8 @@ pub mod latest { pub type PreimageSubmitError = LatestOf; /// Transaction creation payload for a product account. pub type ProductAccountTxPayload = LatestOf; + /// Chain-head runtime-API call request. + pub type RemoteChainHeadCallRequest = LatestOf; /// Chain-head subscription item. pub type RemoteChainHeadFollowItem = LatestOf; /// Chain-head subscription request.