Reference
Registry contracts
Solidity on Portaldot's Revive pallet that does not take an uploader's word for anything: it hashes with Portaldot's own blake2b, decodes IPFS structure, and checks Polkadot wallet signatures.
What the contract proves
A file registry is easy to write and easy to lie to: the uploader says how big a file is and the contract writes it down. PotDrive's registry instead receives the file's root block and works the facts out itself.
- The root is real. It hashes the block with blake2b-256 through Portaldot's System precompile, the exact hash TransactionStorage indexes data under, so the record's key is the chain's own content hash.
- The structure is valid. A protobuf decoder written in Solidity parses the dag-pb node and its UnixFS payload. Every link must be a CIDv1 over blake2b-256, every block size must equal its link's size and point at a raw leaf, the sizes must add up to the file size, and directory entries must be named, slash-free and strictly sorted.
- Size and block count are derived, not claimed. They are what the bytes describe, so keep-alive can be priced against them.
A forged root (one block size changed, every hash still valid) is refused with SizeMismatch(), on the live chain. The only exception is a file that is a single raw block: up to 1 MiB is too much to hash in a transaction, so its size is recorded as claimed and the record is not marked proven.
Portaldot's System precompile
pallet-revive exposes runtime functions to contracts at 0x0000…0900. Portaldot V3 has it enabled; we checked both functions against the live chain before relying on them.
interface ISystem {
function hashBlake256(bytes memory input) external pure returns (bytes32); // = TransactionStorage's hash
function sr25519Verify(uint8[64] calldata signature, bytes calldata message,
bytes32 publicKey) external view returns (bool); // Polkadot wallet keys
}hashBlake256("potdrive") on Portaldot returns 0x5663f503…29707, byte for byte what the SDK computes. The registry calls it with hand-assembled call data rather than the ABI encoder, which saves bytecode.
Gasless registration with wallet signatures
PotDrivePermits lets a sponsor register a file for someone who has no tPOT. The owner signs a permit in their normal Polkadot wallet popup. The contract rebuilds the exact text that popup showed, CID and all, and checks the signature with sr25519Verify:
PotDrive: store and register a file
cid: bafykbzacebnfotgzyu32ypiveqew5czbgjqmqn2wnsbmk7ydzfpzybzw2j6ka
name: deck.pdf
type: application/pdf
private: no
previous: none
registry: 0x…
chain: 420420777
nonce: 0
valid until block: 84700
sponsored upload for: 5CX7…
upload key: 5F3s…
bytes: 3567616- The CID line is computed in Solidity. The contract base32-encodes
0x01 ‖ codec ‖ 0xa0e40220 ‖ digest, so the wallet shows the same ID the Drive shows. - Wallets sign raw text wrapped in
<Bytes>…</Bytes>. The contract builds the text inside that wrapper and verifies over it. - Every field is bound. Changing the name, the budget or the nonce invalidates the signature. Names cannot contain line breaks, so they cannot forge another line.
- The owner is the signer's Revive address:
keccak256(publicKey)[12..], or the first 20 bytes for Ethereum-derived accounts. We checked this againstreviveApi.addresson chain. - One signature, two uses. The relay checks the same signature before paying for the storage through the MetaTx pallet. Its own terms (upload key, byte budget) are the last lines of the text.
Keeper accountability
The keeper renews files natively and claims the cost from each file's prepaid balance. The contract cannot read TransactionStorage, so instead of trusting the claim it bounds it by what it proved:
- At most
feeCapper stored byte (content plus root block). The default is 1.5× the measured 0.1135 tPOT per KiB. - Only within about 2 days of expiry, and the new expiry may be at most one retention period from now, so a file pays at most once per period.
- Never more than the file's balance, and every claim is a public
Renewedevent the owner can audit.
Verify anything for free
PotDriveCheck is never deployed. Its creation code goes through eth_call: Portaldot's runtime runs the constructor, which hashes the block with the precompile, decodes it, optionally checks a signature, and returns the report as the call's result. Nothing is stored or paid. The landing page does this from your browser, and the file page and Drive do it for every stored file through /api/verify/<cid>.
import { checkOnChain } from "@potdrive/sdk/onchain";
const r = await checkOnChain(cid); // fetches the root block over bitswap, then eth_call
// { matches: true, kind: "file", size: 3150049, links: 4, chainId: 420420777, block: 84376 }Contracts that want to use stored bytes can deploy PotDriveVerifier and call proveBlock(root, rootNode, index, data), which returns true only if data is block index of that file.
Bytecode budget
Revive charges a deposit for contract code at about 0.078 tPOT per byte, measured by dry-running each deploy. That is roughly 700 times what TransactionStorage charges per byte, so the design is driven by code size:
| Contract | Init code | Deposit | Deployed |
|---|---|---|---|
PotDriveRegistry | 5,619 B | ≈ 447 tPOT | Always. Proves and records files, keep-alive, sharing. |
PotDrivePermits | 4,112 B | ≈ 323 tPOT | Optional. Gasless registration with wallet signatures. |
PotDriveVerifier | 2,999 B | ≈ 245 tPOT | Optional. proveBlock for other contracts. |
PotDriveCheck | 3,028 B | 0 | Never deployed. Runs through eth_call. |
The first all-in-one version was 12,165 bytes, a 952 tPOT deposit. What brought it down:
- Read-only checks moved out of the registry into contracts that run through
eth_callor are optional. - Gasless registration is a separate module, paid for only when sponsorship is switched on.
- One
registerentry point. The IR optimizer had inlined the registration logic into two entry points, so it was duplicated. - Precompile calls and the permit text are assembled by hand: one buffer and one cursor, instead of the ABI encoder and chained
abi.encodePacked. - A record is two packed storage slots. Names, types and version links live in event logs, which cost no deposit, and no metadata hash is emitted.
Interface
struct Upload { bytes rootNode; bytes32 leaf; uint64 leafSize; } // rootNode = dag-pb root block
struct Meta { bool isPrivate; string name; string mime; bytes32 parent; }
function register(Upload u, Meta m, address owner, bytes32 signer) payable returns (bytes32 root);
function fund(bytes32 root) payable; // anyone
function claimRenewal(bytes32 root, uint32 expiresAt, uint256 cost); // keeper, bounded as above
function withdraw(bytes32 root); // owner
function transfer(bytes32 root, address to); // owner
function setListed(bytes32 root, bool listed); // owner
function setInboxKey(bytes32 key); // X25519 key for receiving shares
function share(bytes32 root, address to, bytes sealedKey); // owner
function files(bytes32 root) view returns (address owner, uint64 size, uint8 flags, uint8 kind,
uint32 chunks, uint32 storedAt, uint32 expiresAt, uint32 rootLen, uint128 keepAlive);
event Registered(bytes32 indexed root, address indexed owner, uint64 size, uint32 chunks, bool isPrivate,
string name, string mime, bytes32 parent, bool proven, bytes32 signer);owner and signer are only honoured when the caller is the permit module. flags holds private (1), unlisted (2) and proven (4). kind is 0 for a single raw block, 1 for a directory, 2 for a file, which is how readers rebuild the CID from a record.
Deploying through Revive
POTDRIVE_SEED="…" KEEPER_SEED="…" bun scripts/deploy.ts # registry
POTDRIVE_SEED="…" KEEPER_SEED="…" bun scripts/deploy.ts --permits # + gasless module, set as sponsorThings we learned on Revive
- A contract cannot create another contract inside a dry run or
eth_callhere, so end-to-end checks run each contract on its own. - The public EVM RPC caps request bodies, so a block sent through
eth_callmust stay under about 32 KiB. Root blocks are a few hundred bytes. block.numberis the Substrate block number andblock.chainidis 420420777, so expiries and permits use the same numbers as TransactionStorage.
Tests
32 Foundry tests run against fixtures the SDK generates (bun scripts/contract-fixtures.ts): real UnixFS roots, a website directory, forged and unsorted roots, and a permit signed with a real sr25519 key. A stand-in precompile computes real blake2b on the EVM's BLAKE2 F precompile, so local hashes equal Portaldot's. Then bun scripts/onchain-check.ts runs the checks against Portaldot itself:
✓ file root: blake2b + UnixFS decode bafykbzacebwga3xh6… size 3150049 in 4 blocks, chain 420420777
✓ directory root 2 entries
✓ raw block hashed by the runtime 32768 bytes
✓ forged root refused SizeMismatch()
✓ sr25519 wallet signature owner 0xc7f9…1d26
✓ tampered permit rejected signatureValid = false
all 6 checks passed on Portaldot V3 Testnet