Web3-storage exposes its client-side surface to Solidity / PolkaVM contracts via pallet_revive and two custom precompiles. A dApp can buy storage on behalf of its users, end agreements, or stitch the storage pallets into larger on-chain workflows — without ever signing a substrate-native extrinsic.
This document covers the on-chain shape of that integration. For the example dApp, see examples/contracts/; for the test driver, see examples/papi/sc-flow.js.
JS test → pallet_revive.call (or eth_transact)
↓
PolkaVM contract (StorageMarketplace.sol)
↓ CALL 0x…0901
StorageProviderPrecompile (Rust, native)
↓ ensure_signed(contract_account)
pallet_storage_provider::Pallet::* → provider HTTP node
The contract's caller is computed by AccountId32Mapper (substrate-native, identity-of-derivation): keccak_256(account_bytes)[12..] forward, OriginalAccount reverse-lookup with [H160 || 0xEE*12] fallback. The precompile builds RawOrigin::Signed(env.caller()) and dispatches as if the contract had signed the extrinsic itself.
AddressMatcher::Fixed(u16) places the u16 at bytes 16-17 of the H160 (big-endian) with a 0x0000 suffix at bytes 18-19. Bytes 18-19 are reserved for built-in precompiles only. So Fixed(0x0901) yields address 0x00000000000000000000000000000000_0901_0000:
| Address | Matcher | Crate | Pallet |
|---|---|---|---|
0x0000000000000000000000000000000009010000 |
Fixed(0x0901) |
pallet-storage-provider-precompile |
pallet_storage_provider |
0x0000000000000000000000000000000009020000 |
Fixed(0x0902) |
pallet-drive-registry-precompile |
pallet_drive_registry |
0x0000000000000000000000000000000009030000 |
Fixed(0x0903) |
pallet-s3-registry-precompile |
pallet_s3_registry |
Both use HAS_CONTRACT_INFO = false (no storage deposits or contract metadata; pure stateless dispatch).
| Selector | Underlying extrinsic |
|---|---|
createBucket(uint32 minProviders) → uint64 |
create_bucket |
createBucketWithStorage(uint64 maxBytes, uint32 duration, uint128 maxPricePerByte) → uint64 |
create_bucket_with_storage (auto-matches provider) |
freezeBucket(uint64 bucketId) |
freeze_bucket |
setMember(uint64 bucketId, bytes32 member, uint8 role) |
set_member |
removeMember(uint64 bucketId, bytes32 member) |
remove_member |
requestPrimaryAgreement(uint64 bucketId, bytes32 provider, uint64 maxBytes, uint32 duration, uint128 maxPayment) |
request_primary_agreement |
topUpAgreement(uint64 bucketId, bytes32 provider, uint64 additionalBytes, uint128 maxPayment) |
top_up_agreement |
extendAgreement(uint64 bucketId, bytes32 provider, uint32 additionalDuration, uint128 maxPayment) |
extend_agreement |
endAgreementPay(uint64 bucketId, bytes32 provider) |
end_agreement (action: Pay) |
endAgreementBurn(uint64 bucketId, bytes32 provider, uint8 burnPercent) |
end_agreement (action: Burn { burn_percent }) |
challengeCheckpoint(uint64 bucketId, bytes32 provider, uint64 leafIndex, uint64 chunkIndex) |
challenge_checkpoint |
| Selector | Underlying extrinsic |
|---|---|
createDrive(string name, uint64 maxCapacity, uint32 storagePeriod, uint128 payment, uint8 minProviders) → uint64 |
create_drive |
deleteDrive(uint64 driveId) |
delete_drive |
shareDrive(uint64 driveId, bytes32 member, uint8 role) |
share_drive |
unshareDrive(uint64 driveId, bytes32 member) |
unshare_drive |
| Selector | Underlying extrinsic |
|---|---|
createS3Bucket(string name, uint32 minProviders) → uint64 |
create_s3_bucket |
createS3BucketWithStorage(string name, uint64 maxCapacity, uint32 duration, uint128 maxPayment) → uint64 |
create_s3_bucket_with_storage |
deleteS3Bucket(uint64 s3BucketId) |
delete_s3_bucket |
putObjectMetadata(uint64 s3BucketId, string key, bytes32 cid, uint64 size, string contentType) |
put_object_metadata (no user metadata in v1) |
deleteObjectMetadata(uint64 s3BucketId, string key) |
delete_object_metadata |
copyObjectMetadata(uint64 srcBucketId, string srcKey, uint64 dstBucketId, string dstKey) |
copy_object_metadata |
| Substrate | Solidity | Notes |
|---|---|---|
BucketId / DriveId (u64) |
uint64 |
Synthesized pre-dispatch from NextBucketId / NextDriveId so the contract gets back the id assigned to this call. |
BlockNumberFor<T> (u32) |
uint32 |
|
BalanceOf<T> (u128) |
uint128 |
Substrate atomic units — not eth wei. |
AccountId (AccountId32) |
bytes32 |
Raw 32-byte sr25519 pubkey. Substrate-only providers can't be safely round-tripped through AccountId32Mapper.to_address(). |
Role |
uint8 |
0 = Admin, 1 = Writer, 2 = Reader. |
EndAction |
(split) | endAgreementPay / endAgreementBurn(uint8 burnPercent) — cleaner Solidity than encoding the Rust enum directly. |
Option<Vec<u8>> (drive name) |
string |
Empty string ⇒ None. |
Option<u8> (min_providers) |
uint8 |
0 ⇒ None (use runtime default); 1..=255 ⇒ Some(n). |
pallet_revive charges the caller's substrate-mapped account for storage deposits, fees, and any T::Currency::reserve the dispatched extrinsic performs. For paid agreements, this means:
- User sends
msg.value(eth-side units) to the contract via apayablefunction. msg.valuelands in the contract's substrate-mapped account, automatically converted:wei / NativeToEthRatio = substrate atomic units. OurNativeToEthRatio = 10^6(Eth's 18 decimals minus our 12), so1 UNITsubstrate =10^18wei.- The contract calls the precompile (e.g.
createBucketWithStorage). - The precompile dispatches
pallet_storage_provider::create_bucket_with_storage(Origin::Signed(contract_account), …). - The pallet computes
payment = maxBytes × duration × matched_priceand runsT::Currency::reserve(&contract_account, payment)against the contract's balance.
Side-effect: the contract owns the bucket on-chain. Per-user ownership ("which of my dApp users bought this") lives in the contract's own state (mapping(uint64 => address) bucketOwner in StorageMarketplace.sol).
env.caller()returnsOrigin<AccountId>frompallet_revive. For a contract-to-contract call this is the immediate caller; for an EOA-initiated tx this is the substrate-mapped EOA.- The precompile builds
RawOrigin::Signed(account).into()and passes it to the pallet's dispatchable. No custom origins, no admin elevation — pure substrateSigned. - The precompile is stateless (
HAS_CONTRACT_INFO = false). It does not own storage; it only dispatches on behalf of its caller.
Each precompile branch starts with env.charge(<Runtime as PalletConfig>::WeightInfo::extrinsic_name())? — the weight of the underlying extrinsic, conservatively pre-charged. Per the Precompile trait doc: "Pre-compiles are unmetered code. Hence they have to charge an appropriate amount of weight themselves."
For extrinsics with parameterized weights (e.g. end_agreement(a: u32)), we pass 1 as a conservative bound — refining these with proper benchmarks is a follow-up.
- Add the function to
src/IWeb3Storage.sol(orsrc/IDriveRegistry.sol) in the precompile crate. - Add a
matcharm insrc/lib.rsfor the new variant:env.charge(WeightInfo::extrinsic_name())?,- decode args, mapping
bytes32toAccountIdviadecode_account::<Runtime>, - dispatch with
RawOrigin::Signed(env.caller_account_id()), - encode the return value via
SolValue::abi_encode().
- Mirror the function declaration into
examples/contracts/IWeb3Storage.sol(the vendored ABI) and any contract that calls it. - Add a happy-path exercise to
examples/papi/sc-coverage.js— directRevive.call(precompileAddr, calldata)plus an assertion on the resulting on-chain state.
Four scripts cover the surface end-to-end:
just sc-demo(examples/papi/sc-flow.js) — full marketplace dApp story viaStorageMarketplace.sol: deploy,buyStoragewithmsg.value, off-chain upload/challenge round-trip,endMyAgreement. Asserts provider earned tokens + contract events fired.just sc-coverage(examples/papi/sc-coverage.js) — direct precompile invocations (no intermediate contract) for every selector across all three precompiles. Each call submits as a signed substrate tx whosedestis the precompile address; on success, the script asserts the underlying pallet's storage / event was updated.just sc-team-drive(examples/papi/sc-team-drive.js) — drive-registry dApp viaSharedTeamDrive.sol: deploy,createTeam,invite/kick,disband.just sc-token-gated(examples/papi/sc-token-gated.js) — s3-registry dApp viaTokenGatedDrive.sol: deploy,initialize,mintan NFT-shaped access token per S3 object,transfer,burn(deletes object metadata),shutdown.
All four run in CI under .github/workflows/integration-tests.yml against both runtime matrix entries.
In v1:
- Provider-side selectors are not exposed.
register_provider,add_stake,accept_agreement,respond_to_challenge,confirm_replica_sync,claim_checkpoint_rewards,fund_checkpoint_pool,deregister_*all stay native — a dApp is a user of storage, not a provider. - Checkpoint extrinsics are not exposed.
checkpoint/provider_checkpointtakeBucketSnapshot+MmrProof+Vec<Signature>— the ABI design needs a follow-up. challenge_offchainis not exposed (itsMerkleProofargument needs the same treatment).- S3
put_object_metadatadrops user metadata in v1. The Rust extrinsic takesVec<(Vec<u8>, Vec<u8>)>; the Solidity ABI for nested dynamic-bytes tuples is awkward, so the precompile selector accepts no user metadata and always passes an empty vector. Use the substrate extrinsic directly if you need it. - No
pallet_revive_eth_rpcserver. PAPI drives revive directly via the substrate-native dispatchables (call,instantiate_with_code). A separate "real dApp UX" follow-up can ship the JSON-RPC node for MetaMask / viem / hardhat compatibility. - No benchmarks for the precompile crates. Weights borrow the underlying extrinsic's weight info.