PotDrive
Open Drive

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.

  1. 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.
  2. 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.
  3. 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.

lib/ISystem.sol
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:

What the wallet shows
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 against reviveApi.address on 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 feeCap per 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 Renewed event 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>.

@potdrive/sdk/onchain
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:

ContractInit codeDepositDeployed
PotDriveRegistry5,619 B≈ 447 tPOTAlways. Proves and records files, keep-alive, sharing.
PotDrivePermits4,112 B≈ 323 tPOTOptional. Gasless registration with wallet signatures.
PotDriveVerifier2,999 B≈ 245 tPOTOptional. proveBlock for other contracts.
PotDriveCheck3,028 B0Never 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_call or are optional.
  • Gasless registration is a separate module, paid for only when sponsorship is switched on.
  • One register entry 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

PotDriveRegistry.sol
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

Terminal
POTDRIVE_SEED="…" KEEPER_SEED="…" bun scripts/deploy.ts            # registry
POTDRIVE_SEED="…" KEEPER_SEED="…" bun scripts/deploy.ts --permits  # + gasless module, set as sponsor

Things we learned on Revive

  • A contract cannot create another contract inside a dry run or eth_call here, so end-to-end checks run each contract on its own.
  • The public EVM RPC caps request bodies, so a block sent through eth_call must stay under about 32 KiB. Root blocks are a few hundred bytes.
  • block.number is the Substrate block number and block.chainid is 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:

bun scripts/onchain-check.ts
✓ 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

Edit this page on GitHub