diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index e1bef1fa4..0990e4810 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -1979,7 +1979,7 @@ ended. This preserves the child status but bypasses later Rust destructors. | `HOST_CLI_SIGNER_MNEMONIC` | Mnemonic for `dev`, `signing-host`, `identity-check`, `register-name`, `alloc-check` and `pgas-check` when `--mnemonic` is omitted. | | `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. For registration its subject must be the candidate `uid.dot` account. Unset, the CLI mints one itself through the backend's `auth/challenges` → `auth/token` sr25519 handshake with that identity key. | -| `HOST_CLI_DOTNS_POP_CONTROLLER` | `DotnsPopController` H160 override, skipping on-chain discovery (`DotnsGateway.DispatcherAddress` → dispatcher `TARGET()`). Only needed where discovery fails. The controller is `0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b` on paseo-next-v2 and previewnet; `DEPLOYMENTS.md` in paritytech/dotns is the authority per network. | +| `HOST_CLI_DOTNS_POP_CONTROLLER` | `DotnsPopController` H160 override, skipping on-chain discovery (`DotnsGateway.DispatcherAddress`, used directly when `protocolRegistry()` answers on it, otherwise resolved through `TARGET()`). Only needed where discovery fails. The controller is `0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b` on paseo-next-v2 and previewnet; `DEPLOYMENTS.md` in paritytech/dotns is the authority per network. | | `XDG_STATE_HOME` | Preferred default state parent. | | `HOME` | Fallback default state parent. | | `VISUAL` | Preferred script editor. | diff --git a/rust/crates/truapi-host-cli/src/dotns_read.rs b/rust/crates/truapi-host-cli/src/dotns_read.rs index 7a99f889a..2f43d95ce 100644 --- a/rust/crates/truapi-host-cli/src/dotns_read.rs +++ b/rust/crates/truapi-host-cli/src/dotns_read.rs @@ -18,8 +18,9 @@ use truapi_server::host_logic::dotns_gateway::{ /// Env var overriding the `DotnsPopController` H160 (hex), skipping on-chain /// discovery. /// -/// Discovery reads `DotnsGateway.DispatcherAddress` and asks the dispatcher -/// for its `TARGET()`; the override is for networks where that fails. The +/// Discovery reads `DotnsGateway.DispatcherAddress`, which holds either a +/// dispatcher or the controller itself, and resolves whichever it is; the +/// override is for networks where that fails. The /// controller is `0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b` on paseo-next-v2 /// and previewnet; `DEPLOYMENTS.md` in paritytech/dotns is the authority per /// network. @@ -92,8 +93,7 @@ impl AssetHubReader { } /// `DotnsPopController` address, resolved once per reader. The env override - /// wins, otherwise on-chain discovery through the dispatcher's `TARGET()` - /// getter. + /// wins, otherwise on-chain discovery from `DotnsGateway.DispatcherAddress`. async fn pop_controller(&mut self) -> Result<[u8; 20]> { if let Some(controller) = self.pop_controller { return Ok(controller); diff --git a/rust/crates/truapi-host-cli/tests/live_asset_hub.rs b/rust/crates/truapi-host-cli/tests/live_asset_hub.rs index e972e172e..693104035 100644 --- a/rust/crates/truapi-host-cli/tests/live_asset_hub.rs +++ b/rust/crates/truapi-host-cli/tests/live_asset_hub.rs @@ -278,7 +278,8 @@ async fn live_asset_hub_declares_the_register_full_name_shape() { } /// End to end over the same resolution steps the CLI and the in-core runtime -/// share: pallet storage → dispatcher `TARGET()` → controller → registry → +/// share: pallet storage → controller (directly, or through a dispatcher's +/// `TARGET()`) → registry → /// store factory → the owner's `LabelStore` → bare labels. The owner is found /// through the registrar, so this covers the warm path (a settled store, whose /// labels carry the network TLD) rather than only pending claims. @@ -291,7 +292,7 @@ async fn live_asset_hub_resolves_a_settled_store_over_dotns_discovery() { let controller = discover_pop_controller(&mut transport) .await .expect("discovery") - .expect("the gateway is deployed with a dispatcher"); + .expect("the gateway names a controller"); println!("live DotnsPopController: 0x{}", hex::encode(controller)); // ownerOf(namehash) on the ERC721 registrar. Eth-derived owners map back diff --git a/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs b/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs index fdde69cc0..c3b117e62 100644 --- a/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs +++ b/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs @@ -576,8 +576,24 @@ pub trait DotnsTransport { async fn view(&mut self, dest: &[u8; 20], input: Vec) -> Result, DotnsViewError>; } -/// Resolves the `DotnsPopController` address: `DotnsGateway.DispatcherAddress` -/// names the `RootGatewayDispatcher`, whose `TARGET()` is the controller. +/// Resolves the `DotnsPopController` address from `DotnsGateway.DispatcherAddress`. +/// +/// The stored address is either the controller itself or a `RootGatewayDispatcher` +/// whose `TARGET()` is the controller. Both are in service: a chain keeps its dispatcher +/// until the gateway pallet is repointed. +/// +/// `protocolRegistry()` decides which. Only the controller answers it: the dispatcher has +/// no such function, and its fallback is Root-gated, so a dry-run view reverts there +/// instead of being forwarded. A revert therefore means the address is not the +/// controller, and `TARGET()` resolves it as a dispatcher. A contract answering neither +/// is reported by address rather than mistaken for either. +/// +/// The order is deliberate: keying on a function the controller has, rather than one it +/// lacks, keeps discovery correct even if the controller later grows a `TARGET()`. +/// +/// A transport failure is never read as an answer: it propagates, so an unreachable node +/// cannot masquerade as a repointed chain. +/// /// `None` when the gateway is not deployed on the chain at all. pub async fn discover_pop_controller( transport: &mut T, @@ -585,16 +601,39 @@ pub async fn discover_pop_controller( let Some(value) = transport.storage(dispatcher_address_key()).await? else { return Ok(None); }; - let dispatcher: [u8; 20] = value.try_into().map_err(|value: Vec| { + let stored: [u8; 20] = value.try_into().map_err(|value: Vec| { format!("DotnsGateway.DispatcherAddress is {} bytes", value.len()) })?; - let output = transport - .view(&dispatcher, call_no_args("TARGET()")) + match transport + .view(&stored, call_no_args("protocolRegistry()")) .await - .map_err(|err| format!("RootGatewayDispatcher.TARGET(): {err}"))?; - decode_address(&output) - .map(Some) - .map_err(|err| format!("RootGatewayDispatcher.TARGET(): {err}")) + { + Ok(output) => { + decode_address(&output) + .map_err(|err| format!("DotnsPopController.protocolRegistry(): {err}"))?; + Ok(Some(stored)) + } + // A chain still storing its dispatcher pays this second view; a repointed one + // answers on the first. Both hops go once no chain stores a dispatcher. + Err(DotnsViewError::Reverted(_)) => { + match transport.view(&stored, call_no_args("TARGET()")).await { + Ok(output) => decode_address(&output) + .map(Some) + .map_err(|err| format!("RootGatewayDispatcher.TARGET(): {err}")), + Err(DotnsViewError::Reverted(_)) => Err(format!( + "DotnsGateway.DispatcherAddress {} has neither protocolRegistry() nor \ + TARGET()", + hex::encode(stored) + )), + Err(DotnsViewError::Failed(reason)) => { + Err(format!("RootGatewayDispatcher.TARGET(): {reason}")) + } + } + } + Err(err @ DotnsViewError::Failed(_)) => { + Err(format!("DotnsPopController.protocolRegistry(): {err}")) + } + } } /// Resolves the bare contract labels `account` holds. @@ -864,8 +903,9 @@ async fn chain_time_secs(transport: &mut T) -> Resul } /// `DotnsGateway.DispatcherAddress` storage key. -/// The entry holds the `RootGatewayDispatcher`, whose `TARGET()` is the -/// `DotnsPopController`. +/// The entry holds either a `RootGatewayDispatcher`, whose `TARGET()` is the +/// `DotnsPopController`, or that controller directly. See +/// [`discover_pop_controller`], which tells them apart. pub fn dispatcher_address_key() -> Vec { plain_key(b"DotnsGateway", b"DispatcherAddress") } @@ -1260,6 +1300,116 @@ mod tests { assert!(!is_dotted_lite_username(&format!("{}.01", "a".repeat(31)))); } + /// A transport for controller discovery: `DispatcherAddress` holds `stored`, + /// and `TARGET()` / `protocolRegistry()` answer with scripted results. + struct ScriptedDiscovery { + stored: [u8; 20], + target: fn() -> Result, DotnsViewError>, + protocol_registry: fn() -> Result, DotnsViewError>, + } + + #[truapi_platform::async_trait] + impl DotnsTransport for ScriptedDiscovery { + async fn storage(&mut self, _key: Vec) -> Result>, String> { + Ok(Some(self.stored.to_vec())) + } + + async fn view( + &mut self, + _dest: &[u8; 20], + input: Vec, + ) -> Result, DotnsViewError> { + let sel: [u8; 4] = input[..4].try_into().expect("selector prefix; qed"); + if sel == selector("TARGET()") { + (self.target)() + } else if sel == selector("protocolRegistry()") { + (self.protocol_registry)() + } else { + panic!("unscripted view {}", hex::encode(sel)); + } + } + } + + fn address_word(byte: u8) -> Vec { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(&[byte; 20]); + word.to_vec() + } + + fn view_reverted() -> Result, DotnsViewError> { + Err(DotnsViewError::Reverted(DotnsContractError::Reverted { + detail: "(empty)".to_string(), + })) + } + + #[test] + fn discovery_follows_target_when_the_stored_address_is_a_dispatcher() { + let mut transport = ScriptedDiscovery { + stored: [0xdd; 20], + target: || Ok(address_word(0xcc)), + protocol_registry: view_reverted, + }; + let found = futures::executor::block_on(discover_pop_controller(&mut transport)) + .expect("discovery"); + assert_eq!(found, Some([0xcc; 20]), "TARGET() names the controller"); + } + + #[test] + fn discovery_uses_the_stored_address_once_the_pallet_points_at_the_controller() { + let mut transport = ScriptedDiscovery { + stored: [0xcc; 20], + target: || panic!("must not probe the dispatcher view"), + protocol_registry: || Ok(address_word(0x9e)), + }; + let found = futures::executor::block_on(discover_pop_controller(&mut transport)) + .expect("discovery"); + assert_eq!( + found, + Some([0xcc; 20]), + "protocolRegistry() answers, so the stored address is the controller" + ); + } + + #[test] + fn discovery_reports_an_address_that_is_neither() { + let mut transport = ScriptedDiscovery { + stored: [0xab; 20], + target: view_reverted, + protocol_registry: view_reverted, + }; + let err = futures::executor::block_on(discover_pop_controller(&mut transport)) + .expect_err("neither"); + assert!(err.contains("neither protocolRegistry() nor"), "{err}"); + } + + #[test] + fn discovery_separates_a_failed_confirmation_from_a_wrong_contract() { + let mut transport = ScriptedDiscovery { + stored: [0xdd; 20], + target: || Err(DotnsViewError::Failed("node unreachable".into())), + protocol_registry: view_reverted, + }; + let err = futures::executor::block_on(discover_pop_controller(&mut transport)) + .expect_err("failure"); + assert!(err.contains("TARGET(): node unreachable"), "{err}"); + assert!( + !err.contains("neither"), + "a node failure is not a wrong contract: {err}" + ); + } + + #[test] + fn discovery_propagates_a_transport_failure_instead_of_guessing() { + let mut transport = ScriptedDiscovery { + stored: [0xcc; 20], + target: || panic!("must not fall back on a transport failure"), + protocol_registry: || Err(DotnsViewError::Failed("node unreachable".into())), + }; + let err = futures::executor::block_on(discover_pop_controller(&mut transport)) + .expect_err("failure"); + assert!(err.contains("node unreachable"), "{err}"); + } + /// A transport answering views by selector: `tld()` with a scripted /// result, `get(bytes32)` with a registry address, `recordExists(bytes32)` /// with a scripted bool. diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index c5a919e2d..79b4398ab 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -419,6 +419,16 @@ mod tests { /// `ReviveApi_call` output carrying successful return `data`. fn contract_result(data: &[u8]) -> Vec { + contract_result_with_flags(0, data) + } + + /// `ReviveApi_call` output for a call that reverted. + fn reverted_contract_result() -> Vec { + contract_result_with_flags(1, &[]) + } + + /// `flags` is `ReturnFlags`; bit 0 marks a revert. + fn contract_result_with_flags(flags: u32, data: &[u8]) -> Vec { let mut out = Vec::new(); for _ in 0..4 { Compact(7u64).encode_to(&mut out); @@ -428,7 +438,7 @@ mod tests { } 0u128.encode_to(&mut out); out.push(0x00); - 0u32.encode_to(&mut out); + flags.encode_to(&mut out); data.encode_to(&mut out); out } @@ -436,6 +446,11 @@ mod tests { /// The scripted contract side: `(dest, selector)` → return data. fn view_output(dest: &[u8; 20], input: &[u8]) -> Vec { let sel: [u8; 4] = input[..4].try_into().unwrap(); + // Discovery probes `protocolRegistry()` first and the dispatcher reverts it; that + // revert is how the two contracts are told apart, so the mock reproduces it. + if *dest == DISPATCHER && sel == selector("protocolRegistry()") { + return reverted_contract_result(); + } let data = match (*dest, sel) { (DISPATCHER, s) if s == selector("TARGET()") => abi_address(&CONTROLLER), (CONTROLLER, s) if s == selector("pendingClaims(address)") => { @@ -673,11 +688,13 @@ mod tests { .iter() .filter(|request| request.contains("chainHead_v1_call")) .count(); - // TARGET, pendingClaims, reservationDuration, protocolRegistry, - // get(storeFactory), getLabelStore, tld, one short getLabels page. + // protocolRegistry (reverts on the dispatcher), TARGET, pendingClaims, + // reservationDuration, protocolRegistry, get(storeFactory), getLabelStore, tld, + // one short getLabels page. A repointed chain resolves on the first probe and + // needs eight. assert_eq!( - calls, 8, - "the discovery and label chain is exactly eight views" + calls, 9, + "the discovery and label chain is exactly nine views on a dispatcher chain" ); } }