Skip to content

Add support for operating a Polkadot fork - #17

Draft
csmnprschv wants to merge 11 commits into
mainfrom
cosmin-polkadot-fork
Draft

csmnprschv wants to merge 11 commits into
mainfrom
cosmin-polkadot-fork

Conversation

@csmnprschv

Copy link
Copy Markdown
Collaborator

No description provided.

@csmnprschv csmnprschv self-assigned this Sep 9, 2026
@csmnprschv
csmnprschv requested a review from mordamax as a code owner September 9, 2026 03:41

@mordamax mordamax Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think we need to merge this (whole deployment) here, it should either fo to preview-net-v1 or keep unmerged, as deployment details are not something we want to ship here, especially deploy/polkadot-test/vm.env

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I won't merge it. I'm switching it to draft.

@csmnprschv
csmnprschv marked this pull request as draft September 9, 2026 16:53
@csmnprschv
csmnprschv force-pushed the cosmin-polkadot-fork branch 2 times, most recently from 2663b81 to c3b9a4f Compare September 16, 2026 14:42
mordamax and others added 11 commits September 17, 2026 22:36
Two things stand between a bitten fork and a working dotNS, and a fork
of a chain without Sudo can fix neither after the spawn.

The gateway has to be told which contract to dispatch into.
set_dispatcher_address() takes DispatcherAddressManager, which the
runtime binds to RootOrWhitelist in
system-parachains/asset-hubs/asset-hub-polkadot/src/individuality.rs,
and such a fork reaches neither arm: nothing holds Root, and
whitelisting a call needs a referendum. Until it is set, reserve_name()
and register_name() fail DispatcherAddressNotSet, the two calls that
reach the contract through call_dispatcher(). A network with Sudo needs
none of this, since the set-dispatcher-address service makes the same
choice from the fetched manifest and dispatches it after the spawn.
Without Sudo the bite is the only origin-free moment, which is already
how the seeded runtime upgrade gets its authorization.

Seeding an address for contracts that do not exist yet is not circular.
Every dotNS address is CREATE3(factory, keccak of a constant namespace,
label and kind) and the factory is CREATE(deployer, nonce 0), so the
whole set follows from one key and is computable offline.

The contracts are deployed afterwards by an Ethereum wallet, and no
wallet we hold has funds on a fork of a real chain. Unfunded, the deploy
does not fail cleanly: the pipeline is idempotent and resumable by
design, so it stops part way and leaves a half-deployed set for someone
to resume. A secp256k1 wallet owns no AccountId32, so revive spends from
a fallback account, the twenty address bytes followed by twelve 0xEE,
per to_fallback_account_id() in substrate/frame/revive/src/address.rs.
Endowing that at import costs one System::Account entry and saves a
signed transfer that would otherwise have to be repeated on every
rebite.

dotnsDeployer takes a list because the two roles differ, and the
descriptor names the factory key first. That key is single-purpose and
its nonce must be 0, since the factory address is what fixes the rest of
the set; dotns publishes it as FACTORY_DEPLOYER in
.github/workflows/deploy-contracts.yml. The pipeline runs from another
key, which _broadcastDeployUups() in scripts/deploy/BaseDeployer.s.sol
records as the proxy owner. Reproducing another network's ownership as
well as its addresses needs both funded.

One thing about the dispatcher inject is worth knowing before someone
tightens it: verifyInjects() reports it skipped, because DotnsGateway
arrives with the runtime the bite authorizes and the live metadata has
no such pallet, and for the same reason index.pallets.has() is the wrong
guard. The descriptor is.

Signed-off-by: Cosmin Paraschiv <cosmin@parity.io>
A bitten Bulletin cannot store anything. authorize_account() takes
Authorizer, which is Root, a sibling parachain in AllowedParachainIds,
or an account in AllowedAuthorizers. On a fork of Polkadot the first is
unreachable and the other two are empty: live Polkadot Bulletin has no
entry in either, because there authorizers arrive by governance rather
than being configured. bulletin-deploy's bulletinAutoAuthorize fails
BadSigner as a result, which is how this surfaced.

previewnet never meets it because the bulletin runtime's genesis preset
seeds Sr25519Keyring::Eve with 100_000 transactions and 100 GiB. A fork
has no genesis, so the same entry is written at import instead, with the
budget taken from that preset rather than invented; valid_until None and
feeless true are what BuildGenesisConfig sets alongside it.

Alice rather than Eve: the bite already endows Alice on every chain of a
shared relay, and endowing Eve as well would be a second inject for no
gain. paseo-next-v2 has Alice among its three authorizers, so an Alice
authorizer is precedented on a live network, and the pallet does not
check that an authorizer and the account it authorizes differ.

The map entry goes in alone, without the provider reference the pallet's
own paths add. That is safe while the authorizer holds a balance, which
provides for it, and it cannot be supplied afterwards.

Unlike the dispatcher and the endowments this inject is type-checkable:
TransactionStorage is on the chain being bitten, so verifyInjects()
decodes it against real metadata instead of skipping it. Guarded on the
pallet being present, so the same descriptor field is inert on a chain
without it.

Signed-off-by: Cosmin Paraschiv <cosmin@parity.io>
A Coinage instance wraps one asset as coins, and needs it on both Asset
Hub, its reserve chain, and People, which holds it by cross-chain
location. People is what forces the seed: CreateOrigin for its
by-Location instance is EnsureNever, so no signed origin can register
the foreign representation whatever deposit it offers, and
force_create() takes ForceOrigin, which is Root, which on a fork of
Polkadot is a 28-day referendum. Asset Hub would take a signed
create() against a deposit, so seeding it too is for one mechanism and
no post-spawn step rather than because nothing else could.

Nothing is hand-encoded. The index now carries a map's key type
alongside its value type and hashers, and encodeMapEntry() builds an
entry through the chain's own metadata: an Assets::Asset key is a u32 on
the reserve chain and a Location elsewhere, and only the metadata knows
which. It refuses a map whose hasher does not keep the key, and one with
more than one key.

The seeded state is only what do_force_create() produces: owner, issuer,
admin and freezer the same account, supply, deposit, accounts,
sufficients and approvals zero, status Live. There is deliberately no
balance state here. Minting, the conversion pool and the instance itself
are ordinary signed calls that run afterwards and compute their own;
seeding those instead would mean hand-building state whose parts must
agree, which verifyInjects() checks item by item and cannot catch across
items. It would also skip testing the extrinsics the fork exists to
test.

The descriptor test needs the same scratch PPN_HOME the dotNS checks
already built, so that helper moves up a scope, takes the key to patch
as an argument, and takes its description with it. Nothing else about
those checks changes.

The asset reuses XTRNL's id, decimals and minimum balance from
individuality-community's initial-setup, so a Coinage instance built the
same way behaves the same, and is named CASH. Owner is //Alice: the bite
endows Alice on every chain of a shared relay, and no other key here can
pay to mint. The location matches people_foreign_location() from those
same scripts.

Signed-off-by: Cosmin Paraschiv <cosmin@parity.io>
Two quotas share the AttestationAllowance name. PeopleLite's counts how
many people a verifier may attest, spent by attest(); DotnsGateway's
counts how many names an attester may reserve, spent by reserve_name().
The identity backend needs both, and a fresh fork gives it neither, so
nobody becomes a lite person and nobody who did could take a username.

Granting either takes an origin a fork cannot raise. PeopleLite's
AttestationAllowanceManager is EnsureRoot on people-polkadot, and
DotnsGateway's is RootOrWhitelist on asset-hub-polkadot, whose second
arm is an OpenGov track. Neither has a signed arm and neither is
reachable after the spawn on a fork of a chain whose Root is a 28-day
referendum, so this is a seed or it is nothing.

One function covers both because the storage is identical:
StorageMap<Blake2_128Concat, AccountId, u32, ValueQuery>, and both
increase_attestation_allowance() implementations only saturating-add a
count. So the count is the whole state the call produces, with nothing
else to construct.

The rest follows from one fact: both pallets arrive with the runtime the
bite authorizes, so neither is in the metadata of the chain being
bitten. That rules out both obvious designs. Deriving the key from that
metadata resolves to nothing, silently, on exactly the chains this is
for; writing it unconditionally instead puts the key on every chain,
Bulletin included. So the key is hand-built, for the same reason
dotnsDispatcherInject() gives, and the descriptor pairs each chain with
its pallet in attestation.on.

Alice as the attester, since the bite already endows her on every chain
of a shared relay, and a million each to match
PEOPLELITE_ATTESTATION_ALLOWANCE and DOTNSGATEWAY_ATTESTATION_ALLOWANCE
in individuality-community's initial-setup.

Signed-off-by: Cosmin Paraschiv <cosmin@parity.io>
polkadot-js overrides toHex() on its integer types to emit big-endian,
and createType(type, '0x...') reads a hex string as a big-endian number.
Only bare multi-byte integers are affected.

The one integer in the set is a map key. Assets::Asset is keyed by u32
on Asset Hub, so the seeded asset landed at 502462978 instead of
50000413.

Nothing caught it because verify() re-encoded with toHex() and decoded
from a hex string, so a wrong write agreed with a wrong read.

scaleHex() goes through toU8a(), and every encode goes through it,
including the struct ones toHex() would have got right; every decode
passes hexToU8a() for the same reason. The test stubs take bytes now, as
the real registry does, and fork-codec pins what a u32 encodes to, what
0x01000000 decodes to from bytes and from a hex string, and the storage
key a u32-keyed asset lands under.

Signed-off-by: Cosmin Paraschiv <cosmin@parity.io>
previewnet and paseo-next-v2 are configured with
individuality-community's initial-setup scripts after a wipe. Those
reach their origins through Sudo.sudo(), which a fork of a public
network has no way to do.

add_chunks() and subscribe_whitelisted() carry pallet::authorize, so
they need no origin at all. dot --unsigned submits them directly, which
is why these scripts need no governance.

The Coinage instance is Sponsored rather than Sufficient.
create_sufficient_instance() takes AdminOrigin, and blessing a sponsored
one to Sufficient takes Root by referendum; create_sponsored_instance()
takes SponsorOrigin, which is EnsureSigned here. The cost is a held
creation deposit and a pot the sponsor keeps funded, and fund_pot() is
permissionless.

The scripts are run by hand, not wired as custom processes: everything
they configure arrives with the runtime upgrade, and zombienet gives
custom processes no ordering, so a wired script would fire at spawn
against a runtime without the pallets. The scripts are idempotent.

Ports come from config/ports.env, and the asset-hub para id and seeded
asset from the network descriptor. setup-common.sh refuses any network
but polkadot: the scripts configure a fork of Polkadot.

dot is already a dependency through scripts/ensure-dot-cli.sh and jq
through the Makefile; bc is new. setup-common.sh checks for all three.

Signed-off-by: Cosmin Paraschiv <cosmin@parity.io>
Stripping bootNodes was meant to keep a fork off the source network and
does not. Protocol names are built from the genesis hash plus the fork
id, and a fork keeps the source's genesis hash, so without a fork id its
names are byte-identical to the source's: it sits on the source's DHT
and finds nodes there. An asset-hub collator was found holding
established connections to public mainnet nodes while its spec carried
zero bootNodes, which is a forked node rejoining the source network and
following its longer chain: success on every metric while not being a
fork at all.

The chain spec's fork_id field is what takes it off that DHT. Nodes only
synchronize with other nodes holding the same value, and it names
protocols and nothing else, so it is not part of the genesis hash or the
state and snapshots taken before it still restore.

Leaving the source's DHT is also what makes the missing bootNodes fatal,
which is why the fork id and the bootnode land together. A fork with a
fork id has a DHT of its own, and Kademlia inserts peers manually, so
connecting to a peer never adds it to the routing table: the table fills
from bootnodes or from identify, and a spec shipping neither leaves
every node's table empty and authority discovery resolving no addresses.
Measured on a fork with a fork id and no bootnode, every one of a
validator's random Kademlia walks returned peers-not-found. With one
bootnode injected, the same node reported nine walks and nine
peers-found, six records stored, and published its own addresses.

Only the first relay node can be named ahead of the spawn, because it is
the only one whose p2p port the generator pins; the rest take ephemeral
ports that change every time. One entry is enough, since Kademlia walks
out from it to the rest. Every node then bootstraps through that one,
which the fork already depends on anyway: it is the node every collator
reaches the relay through. Parachain specs ship none, because a fork
runs a single collator per parachain and has no parachain peer to find.

Only the shipped specs get a fork id or a bootnode. The copies the bite
itself runs against keep the source's bootNodes and no fork id, because
it has to reach the real network to warp-sync from it.

These changes need a patched polkadot-parachain, and the fork runs one.
Cumulus hardcodes fork_id: None when it builds the relay node a collator
embeds (relay-chain-minimal-node/src/network.rs), so a stock collator
keeps its relay node on /<genesis>/kad while the validators move to
/<genesis>/<fork id>/kad. Its collation protocol still carries the fork
id, so it connects to them and then resolves none of them, and the relay
keeps producing blocks while the parachains stop. I will fix that
upstream.

Signed-off-by: Cosmin Paraschiv <cosmin@parity.io>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants