Skip to content
Merged
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: 2 additions & 0 deletions apps/protocol/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const PROTOCOL_APP_ERRORS = {
RESOLVE_DOT_NAME_UNSUPPORTED:
"resolveDotName is not served by this protocol mode",
RESOLVE_OWNER_UNSUPPORTED: "resolveOwner is not served by this protocol mode",
RESOLVE_SEITY_SLOT_UNSUPPORTED:
"resolveSeitySlot is not served by this protocol mode",
RESOLVE_EXECUTABLE_MANIFEST_UNSUPPORTED:
"resolveExecutableManifest is not served by this protocol mode",
RESOLVE_ROOT_MANIFEST_UNSUPPORTED:
Expand Down
26 changes: 26 additions & 0 deletions apps/protocol/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
RootManifest,
} from "@dotli/resolver/manifest";
import type { ResolveOptions } from "@dotli/resolver/resolve";
import type { SeitySlot } from "@dotli/resolver/seity-registry";
import { isExecutableKind } from "@dotli/shared/executables";
import {
MAX_CONNECTIONS_PER_ORIGIN,
Expand Down Expand Up @@ -859,6 +860,7 @@ async function initDirectMode(): Promise<void> {
resolveExecutableManifest,
resolveOwner,
resolveRootManifest,
resolveSeitySlot,
setResolverAssetHubProvider,
setResolverPeopleProvider,
waitForPeopleFinalized,
Expand Down Expand Up @@ -1029,6 +1031,7 @@ async function initDirectMode(): Promise<void> {
},
resolveDotName,
resolveOwner,
resolveSeitySlot,
resolveExecutableManifest,
resolveRootManifest,
});
Expand Down Expand Up @@ -1359,6 +1362,10 @@ interface EngineOptions {
label: string,
opts?: ResolveOptions,
) => Promise<string | null>;
resolveSeitySlot?: (
lookupKey: `0x${string}`,
opts?: ResolveOptions,
) => Promise<SeitySlot | null>;
/**
* Product-manifest readers.
*
Expand Down Expand Up @@ -1451,6 +1458,25 @@ function createEngine(options: EngineOptions): ProtocolEngine {
return;
}

case "resolveSeitySlot": {
if (!options.resolveSeitySlot) {
throw new Error(PROTOCOL_APP_ERRORS.RESOLVE_SEITY_SLOT_UNSUPPORTED);
}
const payload = request.payload as ProtocolRequestMap["resolveSeitySlot"];
assertStr(payload.lookupKey, "lookupKey");
const slot = await options.resolveSeitySlot(
payload.lookupKey as `0x${string}`,
{ syncTimeoutMs },
);
respond({
namespace: "dotli:protocol",
kind: "response",
id: request.id,
ok: true,
result: slot === null ? null : { ...slot, version: slot.version.toString() },
});
return;
}
case "resolveOwner": {
if (!options.resolveOwner) {
throw new Error(PROTOCOL_APP_ERRORS.RESOLVE_OWNER_UNSUPPORTED);
Expand Down
16 changes: 16 additions & 0 deletions apps/protocol/src/protocol-shared-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
resolveExecutableManifest,
resolveOwner,
resolveRootManifest,
resolveSeitySlot,
setResolverAssetHubProvider,
setResolverPeopleProvider,
waitForAssetHubFinalized,
Expand Down Expand Up @@ -383,6 +384,21 @@ async function handleRequest(
return;
}

case "resolveSeitySlot": {
const payload = request.payload as ProtocolRequestMap["resolveSeitySlot"];
assertString(payload.lookupKey, "lookupKey");
const slot = await resolveSeitySlot(payload.lookupKey as `0x${string}`, {
syncTimeoutMs,
});
sendToPort(port, {
namespace: "dotli:protocol",
kind: "response",
id: request.id,
ok: true,
result: slot === null ? null : { ...slot, version: slot.version.toString() },
});
return;
}
case "resolveOwner": {
const payload = request.payload as ProtocolRequestMap["resolveOwner"];
assertString(payload.label, "label");
Expand Down
4 changes: 4 additions & 0 deletions packages/config/src/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface ServicesConfig {
readonly decimals: number;
};
readonly dotns: DotnsContracts;
/** Seity's slot registry on Asset Hub; absent where Seity is not deployed. */
readonly seity?: { readonly REGISTRY: string };
}

const BUILTIN_NETWORK_SERVICES: Record<NetworkName, ServicesConfig> = {
Expand Down Expand Up @@ -112,6 +114,8 @@ const BUILTIN_NETWORK_SERVICES: Record<NetworkName, ServicesConfig> = {
STORAGE_SLOTS: { REGISTRY_RECORDS: 0, CONTENTHASH: 0, TEXT_RECORDS: 1 },
TLD: "paseo",
},
// paritytech/seity registry/deployments/paseo-next-v2/deployment.json
seity: { REGISTRY: "0x90ddece099d26f66d13bb46b9db49dbb2083cb15" },
},
[NetworkName.PREVIEWNET]: {
label: "Previewnet",
Expand Down
17 changes: 17 additions & 0 deletions packages/protocol/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,7 @@ async function postRequest<M extends ProtocolRequestMethod>(
type ResolverRequestMethod =
| "resolveDotName"
| "resolveOwner"
| "resolveSeitySlot"
| "resolveExecutableManifest"
| "resolveRootManifest";

Expand Down Expand Up @@ -730,6 +731,22 @@ export async function resolveOwnerRemote(
string | null;
}

/** A Seity registry slot as the protocol returns it; `version` is a decimal string. */
export interface RemoteSeitySlot {
readonly owner: `0x${string}`;
readonly cidDigest: `0x${string}`;
readonly version: string;
}

/** Remote proxy for the Seity registry reader (null when the registry is absent). */
export async function resolveSeitySlotRemote(
lookupKey: `0x${string}`,
): Promise<RemoteSeitySlot | null> {
return (await postResolverRequest("resolveSeitySlot", {
lookupKey,
})) as RemoteSeitySlot | null;
}

/**
* Remote proxy for the executable-manifest reader.
*
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ProtocolRequestMap {
warmup: Record<string, never>;
resolveDotName: { label: string };
resolveOwner: { label: string };
resolveSeitySlot: { lookupKey: string };
resolveExecutableManifest: {
label: string;
kind: "app" | "widget" | "worker";
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/method-timeouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const METHOD_TIMEOUTS: Partial<Record<ProtocolRequestMethod, number>> = {
chainConnect: 30_000,
resolveDotName: 90_000,
resolveOwner: 90_000,
resolveSeitySlot: 90_000,
resolveExecutableManifest: 30_000,
resolveRootManifest: 30_000,
};
19 changes: 19 additions & 0 deletions packages/resolver/src/abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,25 @@ export function computeMappingSlot(
);
}

/**
* Compute the slot for `mapping(bytes32 => mapping(bytes32 => T))` at slot N,
* which is also how a pvm-contract-sdk `Mapping<([u8; 32], [u8; 32]), T>`
* lays out (pvm-storage chains tuple keys like Solidity nested mappings):
* keccak256(inner ++ keccak256(outer ++ uint256(N)))
*/
export function computeNestedBytes32MappingSlot(
outerKey: `0x${string}`,
innerKey: `0x${string}`,
slotNumber: number,
): `0x${string}` {
const midSlot = computeMappingSlot(outerKey, slotNumber);
return toHex(
new Uint8Array(
keccak_256(concatBytes(hexToBytes(innerKey), hexToBytes(midSlot))),
),
);
}

/**
* Compute the data slot for long Solidity `bytes` storage.
*
Expand Down
17 changes: 17 additions & 0 deletions packages/resolver/src/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { readMappingBytes, readMappingAddress } from "./access-raw-storage";
import type { PhaseCallback, StatusCallback } from "./access-raw-storage";
import { createRawApi, type Api } from "./api";
import { readExecutableManifest, readRootManifest } from "./manifest";
import { readSeitySlot, type SeitySlot } from "./seity-registry";
import type {
ExecutableKind,
ExecutableManifest,
Expand Down Expand Up @@ -429,3 +430,19 @@ export async function resolveOwner(
dotns.STORAGE_SLOTS.REGISTRY_RECORDS,
);
}

/**
* Read one Seity registry slot on the active network's Asset Hub. Rejects
* when the network has no Seity registry configured.
*/
export async function resolveSeitySlot(
lookupKey: `0x${string}`,
opts: ResolveOptions = {},
): Promise<SeitySlot | null> {
const registry = getActiveServicesConfig().seity?.REGISTRY;
if (registry === undefined) {
throw new Error("no Seity registry is configured for this network");
}
const api = await ensureClient(opts);
return readSeitySlot(api, registry, lookupKey);
}
48 changes: 48 additions & 0 deletions packages/resolver/src/seity-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { hexToBytes } from "@noble/hashes/utils.js";
import type { Api, ContractStorage } from "./api";
import { computeNestedBytes32MappingSlot } from "./abi";
import { readSeitySlot } from "./seity-registry";

// The storage seity's registry contract wrote after `anchor(0x01…, 0x02…,
// 0x09…)` from 0xa1…, dumped by its mock host. The same keys are pinned in
// paritytech/seity `registry/lib.rs` (`storage_layout_is_what_raw_readers_compute`).
const DOMAIN = `0x${"01".repeat(32)}` as const;
const LOOKUP = `0x${"02".repeat(32)}` as const;
const DUMP: Record<string, string> = {
"0x0178a5f3f52848d455b30bd72f348037540dbb9a30ce8884a49afb59d0a67708": `${"00".repeat(12)}${"a1".repeat(20)}`,
"0xf88c0b9de2397b913259aa0787c071c0b839f71070f539761a5e34e4dff53926": "09".repeat(32),
"0x3cb365a0a73e582cda498139ee151defc9301d55b78c813e1fd1149e92817660": `${"00".repeat(31)}01`,
};

function fakeApi(dump: Record<string, string>): Api {
const storage: ContractStorage = {
readSlot: (key) =>
Promise.resolve(key in dump ? hexToBytes(dump[key]) : null),
};
return {
whenReady: () => Promise.resolve(),
withContract: (_address, read) => read(storage),
onStop: () => () => undefined,
destroy: () => undefined,
};
}

describe("seity registry", () => {
it("computes the slots the contract writes", () => {
expect(Object.keys(DUMP)).toEqual([0, 1, 2].map((n) => computeNestedBytes32MappingSlot(DOMAIN, LOOKUP, n)));
});

it("reads an anchored slot", async () => {
await expect(readSeitySlot(fakeApi(DUMP), "0xregistry", LOOKUP, DOMAIN)).resolves.toEqual({
owner: `0x${"a1".repeat(20)}`,
cidDigest: `0x${"09".repeat(32)}`,
version: 1n,
});
});

it("reads a never-anchored slot as version 0", async () => {
const slot = await readSeitySlot(fakeApi({}), "0xregistry", LOOKUP, DOMAIN);
expect(slot?.version).toBe(0n);
});
});
61 changes: 61 additions & 0 deletions packages/resolver/src/seity-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2026 Parity Technologies (UK) Ltd.
// SPDX-License-Identifier: AGPL-3.0-only

// Seity's slot registry, read from raw contract storage.
//
// The registry (paritytech/seity `registry/lib.rs`) keeps three
// `Mapping<(domain, lookupKey), _>` fields: owners (slot 0, right-aligned
// 20 bytes), digests (slot 1, bytes32) and versions (slot 2, uint64). The
// layout is pinned on both sides by the same storage keys (seity's
// `storage_layout_is_what_raw_readers_compute`, and this package's test). pvm-storage lays
// them out as Solidity nested mappings, so the resolver's slot maths applies
// and no runtime call is needed. Version 0 means never anchored; a zero
// digest means revoked.

import type { Api } from "./api";
import { computeNestedBytes32MappingSlot, toHex, wordToBigInt } from "./abi";

/** blake2b-256("seity:domain:v1"): the registry domain every seity slot uses. */
export const SEITY_REGISTRY_DOMAIN =
"0x5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53" as const;

const SLOT_OWNERS = 0;
const SLOT_DIGESTS = 1;
const SLOT_VERSIONS = 2;

export interface SeitySlot {
/** Right-aligned in its word, like an `address` (pinned by seity's registry layout test). */
readonly owner: `0x${string}`;
readonly cidDigest: `0x${string}`;
readonly version: bigint;
}

export async function readSeitySlot(
api: Api,
registry: string,
lookupKey: `0x${string}`,
domain: `0x${string}` = SEITY_REGISTRY_DOMAIN,
): Promise<SeitySlot | null> {
return api.withContract(registry, async (storage) => {
const slot = (n: number): `0x${string}` =>
computeNestedBytes32MappingSlot(domain, lookupKey, n);
const versionWord = await storage.readSlot(slot(SLOT_VERSIONS));
const version = versionWord === null ? 0n : wordToBigInt(versionWord);
if (version === 0n) {
return {
owner: `0x${"00".repeat(20)}`,
cidDigest: `0x${"00".repeat(32)}`,
version,
};
}
const [ownerWord, digestWord] = await Promise.all([
storage.readSlot(slot(SLOT_OWNERS)),
storage.readSlot(slot(SLOT_DIGESTS)),
]);
return {
owner: toHex((ownerWord ?? new Uint8Array(32)).slice(12)),
cidDigest: toHex(digestWord ?? new Uint8Array(32)),
version,
};
});
}
Loading
Loading