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
9 changes: 9 additions & 0 deletions docs/TRINITY-STACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ The descriptor is built from the dashboard model, so its URLs are the ones the d
SHA-256.
- `services`: the DUB, TURN and STUN, the IPFS gateway, Bulletin as `hop`, eth-rpc with its
chain id, and the storage provider.
- `dotns`: the dotNS contract addresses by `DotnsConstants` key, such as `storeFactory`, plus
`protocolRegistry` for the registry itself, so readers take them from the descriptor rather
than read them from Asset Hub.
- A genesis network takes them from `bin/dotns-addresses.json`, the address list `ppn fetch`
takes from the dotns release. Contracts with no key, such as the store beacons, are left out.
- A fork takes the keys from its source's descriptor, at `stack.pointer`, and each address from
its own registry's `get(key)`, since the bite copied the source's registry. A key the registry
does not hold, such as a release's `multicall3`, keeps the source's address. While the source
descriptor or eth-rpc does not answer, the fork publishes no `dotns` and tries again in 30s.
- `fork` and each chain's `forkedAt`: on a fork whose spawn stamp records the bite and whose
network file names the source's descriptor in `stack.pointer`. `forkedAt` is the source's
block, whose hash `ppn bite` reads from the source and records as `biteHashes`: the fork's
Expand Down
2 changes: 1 addition & 1 deletion networks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ or `PPN_NETWORK=<name>` in the environment of the scripts.
| `dotns.resolver` | DotnsContentResolver address for `pin-bulletin-products` — per network, since every DotNS deployment has its own. Omitted → read from `bin/dotns-addresses.json` (previewnet's release artifact); still absent → the import is skipped. |
| `dotns.gateway` | IPFS gateway the product bytes are fetched from. Omitted → `bite.source`, which is right where the source network's front door is also its gateway (previewnet, devnet) and wrong where it is not: paseo-next-v2's `https://dot.li` is a single-page app that returns its own HTML for every path, 200 included. |
| `stack.tld` | The dotNS TLD the network's Trinity stack descriptor names. Omitted → `genesisConfig.networkSuffix`; still absent → no descriptor is written. See `docs/TRINITY-STACK.md`. |
| `stack.pointer` | `{ url, key? }` where the live network publishes its own descriptor. A fork names it as `fork.of`; omitted → the fork's descriptor carries no `fork`. |
| `stack.pointer` | `{ url, key? }` where the live network publishes its own descriptor. A fork names it as `fork.of` and takes its dotNS keys from it; omitted → the fork's descriptor carries no `fork` and no `dotns`. |

`_todo` fields (at any level) are blocking stubs: human notes about what still has
to be confirmed. The loader ignores them; `make bite` refuses to bite a network
Expand Down
6 changes: 6 additions & 0 deletions networks/devnet.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,11 @@
"StoreFactory": "0xD81DC23FAa69B311C1FC553Ea63798772e7D253D",
"Multicall3": "0x929EdB8d61461c29d07deC834ef747EbFDcf0B74"
}
},
"stack": {
"tld": "dot",
"pointer": {
"url": "https://stack.api.polkadotcommunity.foundation/.well-known/trinity-stack.json"
}
}
}
7 changes: 7 additions & 0 deletions networks/paseo-next-v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,12 @@
"_note": "Same DotNS deployment as previewnet — the factory deployer key pins these addresses, so the resolver is at the same address on both. Verified 2026-08-19 by scanning it on paseo-asset-hub-next. The gateway is named explicitly because bite.source (https://dot.li) is the app, not an IPFS gateway.",
"resolver": "0x7F74D7CD50f5a834270E2ad395a01b01891AB37d",
"gateway": "https://paseo-bulletin-next-ipfs.polkadot.io"
},
"stack": {
"tld": "paseo",
"pointer": {
"url": "https://stack-paseo-next.parity-testnet.parity.io/.well-known/trinity-stack.json",
"key": "0x2b89d8ca74aa2e11ab55f5af1168cd6929b88d1998aecd979b42cfc1078d5377"
}
}
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@polkadot/util": "13.5.9",
"@polkadot/util-crypto": "^13.5.9",
"@parity/ppn-network-config": "workspace:*",
"@parity/trinity-stack": "^0.1.0",
"@parity/trinity-stack": "^0.2.0",
"commander": "^15.0.0",
"polkadot-api": "^1.23.3",
"toml": "^4.2.0"
Expand Down
67 changes: 65 additions & 2 deletions packages/cli/src/commands/trinity-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import path from 'node:path';
import {
chainKey,
dashboardModel,
dotnsFromContracts,
trinityStackModel,
trinityStackUrl,
type ChainKey,
type DashboardChain,
type StackChainFacts,
type StackFacts,
} from '@parity/ppn-network-config';
import { encodeLink, lightSpecGenesis, publicKey, publish } from '@parity/trinity-stack';
import { encodeLink, lightSpecGenesis, publicKey, publish, read, type Fetch, type Pointer } from '@parity/trinity-stack';
import type { ServiceContext } from './service-context.js';
import { rpc } from '../fork/rpc.js';
import { chainSpecNames } from '../lib/chain-specs.js';
Expand Down Expand Up @@ -67,6 +68,10 @@ export interface Settings {
faucet?: string;
expiresAt?: number;
validFor?: number;
/** The dotNS deployment's contract addresses by contract name, from the dotns release. */
dotnsContracts?: Record<string, string>;
/** Fetches a fork's source descriptor; a fake in tests. */
fetch: Fetch;
}

/** One chain's genesis, its light spec written to disk, and its specs' hashes. */
Expand Down Expand Up @@ -124,6 +129,44 @@ async function ethChainId(s: Settings, directUrl: string): Promise<number | unde
}
}

/** The selector of the dotNS protocol registry's `get(bytes32)`. */
const REGISTRY_GET = '0x8eaa6ac0';

/**
* A fork's dotNS addresses: each key its source's descriptor lists, with the address the fork's
* own registry returns for it, since a registry rewired after the bite differs from the source.
* A key the registry does not hold, such as a release's Multicall3, keeps the source's address.
* Incomplete while the source descriptor or the fork's eth-rpc does not answer.
*/
async function forkDotns(
s: Settings,
source: Pointer,
ethUrl: string
): Promise<{ dotns?: Record<string, string>; complete: boolean }> {
let listed: Record<string, string> | undefined;
try {
listed = (await read(source, { fetch: s.fetch })).descriptor.dotns;
} catch {
return { complete: false };
}
const registry = listed?.protocolRegistry;
if (!listed || !registry) return { complete: true };

const dotns: Record<string, string> = {};
for (const [key, address] of Object.entries(listed)) {
const data = REGISTRY_GET + Buffer.from(key).toString('hex').padEnd(64, '0');
let word: string;
try {
word = await s.call<string>(ethUrl, 'eth_call', [{ to: registry, data }, 'latest']);
} catch {
return { complete: false };
}
const registered = `0x${word.slice(-40)}`.toLowerCase();
dotns[key] = /^0x0{40}$/.test(registered) ? address.toLowerCase() : registered;
}
return { dotns, complete: true };
}

/**
* Writes the light specs and the descriptor once. Returns what the dashboard shows, and whether
* the descriptor is complete: a relay has no checkpoint until it finalizes a block, and eth-rpc
Expand All @@ -150,13 +193,20 @@ export async function writeStack(
}
const eth = model.services.find((e) => e.id === 'eth-rpc');
const chainId = eth && (await ethChainId(s, eth.directUrl));
// A genesis network runs the release's dotNS; a fork runs whatever its source had at the bite.
const source = ctx.net.stack?.pointer;
const dotnsFacts =
forked && source && eth
? await forkDotns(s, source, eth.directUrl)
: { dotns: forked ? undefined : dotnsFromContracts(s.dotnsContracts), complete: true };
const now = s.now();
const stack = trinityStackModel(ctx.net, model, {
chains,
ethChainId: chainId,
forked,
forkAt: bite ? Math.floor(Date.parse(bite.at) / 1000) : undefined,
faucet: s.faucet,
dotns: dotnsFacts.dotns,
expiresAt: s.expiresAt,
validUntil: s.validFor ? Math.floor(now.getTime() / 1000) + s.validFor : undefined,
});
Expand All @@ -177,7 +227,16 @@ export async function writeStack(
const written: StackStateFile = { generation, state: published.state, pointer, link: encodeLink(pointer) };
writeIfChanged(stateFile, new TextEncoder().encode(JSON.stringify(written, null, 2) + '\n'));
const specsComplete = Object.values(chains).every((c) => !c?.fullSpec || c.lightSpec);
return { written, complete: specsComplete && (!eth || chainId !== undefined) };
return { written, complete: specsComplete && (!eth || chainId !== undefined) && dotnsFacts.complete };
}

/** The address list `ppn fetch` writes from the dotns release, or undefined when there is none. */
function readDotnsAddresses(binDir: string): Record<string, string> | undefined {
try {
return JSON.parse(fs.readFileSync(path.join(binDir, 'dotns-addresses.json'), 'utf-8')) as Record<string, string>;
} catch {
return undefined;
}
}

function hexSecret(value: string | undefined): Uint8Array | undefined {
Expand Down Expand Up @@ -207,6 +266,7 @@ export async function trinityStack(ctx: ServiceContext): Promise<void> {
}
const s: Settings = {
call: rpc,
fetch,
now: () => new Date(),
baseUrl: ctx.baseUrl,
dataDir: ctx.dataDir,
Expand All @@ -215,6 +275,9 @@ export async function trinityStack(ctx: ServiceContext): Promise<void> {
faucet: setting('PPN_STACK_FAUCET_URL'),
expiresAt: optionalNumber('PPN_STACK_EXPIRES_AT', setting('PPN_STACK_EXPIRES_AT')),
validFor: optionalNumber('PPN_STACK_VALID_FOR', setting('PPN_STACK_VALID_FOR')),
// `ppn fetch` writes the release's list only for a genesis network, so on any other one a
// file in bin/ is left over from another network. A fork reads its source's instead.
dotnsContracts: ctx.net.genesis ? readDotnsAddresses(ctx.binDir) : undefined,
};
// A copy must be replaced before its validUntil, or readers show the stack as stale.
const refresh = Math.min(
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/tests/trinity-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const failing = (failed: string): Settings['call'] => async (url, method, params
let dataDir: string;
const settings = (extra: Partial<Settings> = {}): Settings => ({
call: fakeNodes,
// No source descriptor answers unless a test serves one.
fetch: async () => new Response(null, { status: 404 }),
now: () => T0,
baseUrl: 'http://127.0.0.1:8090',
dataDir,
Expand Down Expand Up @@ -155,6 +157,53 @@ describe('writeStack — what it writes', () => {
assert.equal(descriptor.fork, undefined);
assert.ok(descriptor.chains.every((c) => c.forkedAt === undefined));
});

// The bite copies the source's registry, so the fork has the source's keys; a key rewired on
// either side since then is what the fork's own registry now returns.
it("publishes its source's dotNS keys with the addresses the fork's registry returns", async () => {
const registry = '0xd19e3d0c97cf501125a04a97405e3e6592fa846e';
const sourceDir = dataDir;
await writeStack(
{ net },
settings({
dotnsContracts: {
DotnsProtocolRegistry: registry,
DotnsRegistrar: '0x4f06e818ba3d987704fd91cf3d868e4b019106ab',
Multicall3: '0xb4468000abd87d3c56cbfbd153161223d7b109e5',
},
})
);
const sourceDocument = fs.readFileSync(path.join(sourceDir, 'trinity-stack.json'));

dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ppn-stack-'));
writeSpecs('127.0.0.1');
stamp({ mode: 'fork', bite: bite({ hashes }) });
const rewiredRegistrar = '0x' + '12'.repeat(20);
const forkRegistry: Record<string, string> = { protocolRegistry: registry, registrar: rewiredRegistrar };
const call: Settings['call'] = async (url, method, params = []) => {
if (method !== 'eth_call') return fakeNodes(url, method, params);
const { data } = params[0] as { data: string };
const key = Buffer.from(data.slice(10), 'hex').toString().replace(/\0+$/, '');
return ('0x' + (forkRegistry[key] ?? '0x' + '00'.repeat(20)).slice(2).padStart(64, '0')) as never;
};
const fetch: Settings['fetch'] = async (url) =>
url === net.stack!.pointer!.url ? new Response(sourceDocument) : new Response(null, { status: 404 });

const { written, complete } = await writeStack({ net }, settings({ call, fetch }));
assert.equal(complete, true);
assert.deepEqual(open(read('trinity-stack.json'), written.pointer).descriptor.dotns, {
multicall3: '0xb4468000abd87d3c56cbfbd153161223d7b109e5',
protocolRegistry: registry,
registrar: rewiredRegistrar,
});
});

it('publishes a fork without dotNS while its source descriptor does not answer, and tries again', async () => {
stamp({ mode: 'fork', bite: bite({ hashes }) });
const { written, complete } = await writeStack({ net }, settings());
assert.equal(complete, false);
assert.equal(open(read('trinity-stack.json'), written.pointer).descriptor.dotns, undefined);
});
});

describe('writeStack — generation and sequence', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/network-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"postpack": "rm -rf config && rm -f LICENSE"
},
"dependencies": {
"@parity/trinity-stack": "^0.1.0",
"@parity/trinity-stack": "^0.2.0",
"@polkadot/keyring": "^13.5.9",
"@polkadot/types": "^15.10.2",
"@polkadot/util": "13.5.9",
Expand Down
2 changes: 1 addition & 1 deletion packages/network-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export type {
DashboardEndpoint,
DashboardIce,
} from './dashboard-model.js';
export { trinityStackModel, trinityStackUrl, chainKey } from './trinity-stack.js';
export { trinityStackModel, trinityStackUrl, chainKey, dotnsFromContracts } from './trinity-stack.js';
export type { StackFacts, StackChainFacts } from './trinity-stack.js';
export { hrmpChannels } from './toml-generator.js';
export type { HrmpChannel } from './toml-generator.js';
Expand Down
36 changes: 36 additions & 0 deletions packages/network-config/src/trinity-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,40 @@ export interface StackFacts {
faucet?: string;
expiresAt?: number;
validUntil?: number;
/** dotNS contract addresses by protocol registry key. */
dotns?: Record<string, string>;
}

/**
* dotNS's `DotnsConstants` key for each contract name in a dotns release's address list, plus
* `protocolRegistry` for the registry itself. The beacons and the pricing contract have no key.
*/
const DOTNS_KEYS: Record<string, string> = {
DotnsRegistrar: 'registrar',
DotnsRegistrarController: 'controller',
DotnsRegistry: 'registry',
DotnsReverseResolver: 'reverseResolver',
PopRules: 'popRules',
StoreFactory: 'storeFactory',
DotnsResolver: 'resolver',
DotnsContentResolver: 'contentResolver',
DotnsPopController: 'popController',
DotnsPopResolver: 'popResolver',
DotnsPopLens: 'popLens',
DotnsNameEscrow: 'nameEscrow',
Multicall3: 'multicall3',
Create3Factory: 'create3Factory',
DotnsNameWhitelist: 'nameWhitelist',
DotnsCostModelRegistry: 'costModel',
DotnsProtocolRegistry: 'protocolRegistry',
};

/** A descriptor's `dotns` from a dotns release's contract-name address list; undefined when no name maps. */
export function dotnsFromContracts(contracts: Record<string, string> | undefined): Record<string, string> | undefined {
const entries = Object.entries(contracts ?? {}).flatMap(([name, address]) =>
DOTNS_KEYS[name] ? [[DOTNS_KEYS[name], address.toLowerCase()] as const] : []
);
return entries.length > 0 ? Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b))) : undefined;
}

/** The chain key a dashboard chain entry belongs to: every `relay-*` validator is the relay. */
Expand Down Expand Up @@ -110,6 +144,7 @@ export function trinityStackModel(net: NetworkDef, model: DashboardModel, facts:
const storage = service('storage-provider-node');
if (storage) services.push({ kind: 'storage-provider', urls: [withSlash(storage.url)] });
if (facts.faucet) services.push({ kind: 'faucet', urls: [withSlash(facts.faucet)] });
const dotns = chains.some((c) => c.role === 'AssetHub') ? facts.dotns : undefined;

return {
id: facts.forked ? `${net.name}-fork` : net.name,
Expand All @@ -121,5 +156,6 @@ export function trinityStackModel(net: NetworkDef, model: DashboardModel, facts:
...(fork && { fork }),
chains,
services,
...(dotns && { dotns }),
};
}
24 changes: 22 additions & 2 deletions packages/network-config/tests/trinity-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { dashboardModel } from '../src/dashboard-model.js';
import { loadDescriptorOnly } from '../src/load.js';
import { trinityStackModel, trinityStackUrl, type StackFacts } from '../src/trinity-stack.js';
import { dotnsFromContracts, trinityStackModel, trinityStackUrl, type StackFacts } from '../src/trinity-stack.js';

const BASE = 'https://previewnet.substrate.dev';
const WSS = 'wss://previewnet.substrate.dev';
Expand Down Expand Up @@ -72,6 +72,26 @@ describe('trinityStackModel — previewnet', () => {
assert.equal(configured.expiresAt, 2000000000);
assert.equal(configured.validUntil, 1900000000);
});

// Readers look a contract up by its DotnsConstants key, so a contract name left as the release
// spells it, or a beacon with no key, would be an address nobody finds.
it('states the dotNS addresses by DotnsConstants key, lowercase, without contracts that have none', () => {
const dotns = dotnsFromContracts({
DotnsRegistrar: '0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab',
DotnsProtocolRegistry: '0xD19e3D0C97CF501125a04A97405e3e6592fa846E',
DotnsCostModelRegistry: '0x8bfd1f0957e73716732e725802f13830B5682da4',
Multicall3: '0xB4468000abD87D3c56cbFBd153161223D7b109e5',
LabelStoreBeacon: '0x2227d9807f5a71332aaa0640643030f2a3bf84cd',
DotnsFlatPricing: '0xD839B281dF72Df44fF275305E72cAEEc0fDAA648',
});
assert.deepEqual(dotns, {
costModel: '0x8bfd1f0957e73716732e725802f13830b5682da4',
multicall3: '0xb4468000abd87d3c56cbfbd153161223d7b109e5',
protocolRegistry: '0xd19e3d0c97cf501125a04a97405e3e6592fa846e',
registrar: '0x4f06e818ba3d987704fd91cf3d868e4b019106ab',
});
assert.equal(dotnsFromContracts({ LabelStoreBeacon: '0x2227d9807f5a71332aaa0640643030f2a3bf84cd' }), undefined);
});
});

describe('trinityStackModel — forks', () => {
Expand Down Expand Up @@ -112,7 +132,7 @@ describe('trinityStackModel — forks', () => {
});

it('refuses a network with no TLD', () => {
const devnet = loadDescriptorOnly('devnet');
const devnet = { ...loadDescriptorOnly('devnet'), stack: undefined };
assert.throws(() => trinityStackModel(devnet, dashboardModel(devnet, 'http://127.0.0.1:8090'), facts()), /no stack\.tld/);
});

Expand Down
Loading
Loading