Add support for operating a Polkadot fork - #17
Draft
csmnprschv wants to merge 11 commits into
Draft
csmnprschv wants to merge 11 commits into
csmnprschv wants to merge 11 commits into
Conversation
csmnprschv
force-pushed
the
cosmin-polkadot-fork
branch
from
September 9, 2026 03:45
5930dc5 to
2663b81
Compare
mordamax
reviewed
Sep 9, 2026
Collaborator
There was a problem hiding this comment.
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
Collaborator
Author
There was a problem hiding this comment.
Yeah, I won't merge it. I'm switching it to draft.
mordamax
requested changes
Sep 9, 2026
csmnprschv
marked this pull request as draft
September 9, 2026 16:53
csmnprschv
force-pushed
the
cosmin-polkadot-fork
branch
2 times, most recently
from
September 16, 2026 14:42
2663b81 to
c3b9a4f
Compare
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>
csmnprschv
force-pushed
the
cosmin-polkadot-fork
branch
from
September 17, 2026 19:40
c3b9a4f to
99d60bd
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.