Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rust/crates/truapi-host-cli/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
8 changes: 4 additions & 4 deletions rust/crates/truapi-host-cli/src/dotns_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions rust/crates/truapi-host-cli/tests/live_asset_hub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
172 changes: 161 additions & 11 deletions rust/crates/truapi-server/src/host_logic/dotns_gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,25 +576,64 @@ pub trait DotnsTransport {
async fn view(&mut self, dest: &[u8; 20], input: Vec<u8>) -> Result<Vec<u8>, 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<T: DotnsTransport + ?Sized>(
transport: &mut T,
) -> Result<Option<[u8; 20]>, String> {
let Some(value) = transport.storage(dispatcher_address_key()).await? else {
return Ok(None);
};
let dispatcher: [u8; 20] = value.try_into().map_err(|value: Vec<u8>| {
let stored: [u8; 20] = value.try_into().map_err(|value: Vec<u8>| {
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.
Expand Down Expand Up @@ -864,8 +903,9 @@ async fn chain_time_secs<T: DotnsTransport + ?Sized>(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<u8> {
plain_key(b"DotnsGateway", b"DispatcherAddress")
}
Expand Down Expand Up @@ -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<Vec<u8>, DotnsViewError>,
protocol_registry: fn() -> Result<Vec<u8>, DotnsViewError>,
}

#[truapi_platform::async_trait]
impl DotnsTransport for ScriptedDiscovery {
async fn storage(&mut self, _key: Vec<u8>) -> Result<Option<Vec<u8>>, String> {
Ok(Some(self.stored.to_vec()))
}

async fn view(
&mut self,
_dest: &[u8; 20],
input: Vec<u8>,
) -> Result<Vec<u8>, 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<u8> {
let mut word = [0u8; 32];
word[12..].copy_from_slice(&[byte; 20]);
word.to_vec()
}

fn view_reverted() -> Result<Vec<u8>, 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.
Expand Down
27 changes: 22 additions & 5 deletions rust/crates/truapi-server/src/runtime/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,16 @@ mod tests {

/// `ReviveApi_call` output carrying successful return `data`.
fn contract_result(data: &[u8]) -> Vec<u8> {
contract_result_with_flags(0, data)
}

/// `ReviveApi_call` output for a call that reverted.
fn reverted_contract_result() -> Vec<u8> {
contract_result_with_flags(1, &[])
}

/// `flags` is `ReturnFlags`; bit 0 marks a revert.
fn contract_result_with_flags(flags: u32, data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..4 {
Compact(7u64).encode_to(&mut out);
Expand All @@ -428,14 +438,19 @@ 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
}

/// The scripted contract side: `(dest, selector)` → return data.
fn view_output(dest: &[u8; 20], input: &[u8]) -> Vec<u8> {
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)") => {
Expand Down Expand Up @@ -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"
);
}
}