Add a Smart Contract to Your Product
Introduction
Section titled “Introduction”Some Products need on-chain logic and shared state that no single user owns: a leaderboard, a registry, an escrow, a game whose rules must be enforced for everyone. That is what a smart contract gives you. This guide adds a PolkaVM contract to a Product, deploys it to Asset Hub, and calls it from your frontend with the @parity/product-sdk-contracts package.
Contracts on Polkadot run as PolkaVM bytecode through the pallet-revive runtime on Asset Hub. You author them in Rust or Solidity, and the Contract Dependency Manager (cdm) builds, deploys, and registers them, the same tool the playground CLI runs for you when it deploys a Product that has contracts. cdm fills the role npm fills for libraries, but for on-chain contracts: it publishes each contract under a global name (@scope/name) in an on-chain registry, so your frontend resolves it by name instead of hardcoding an address.
Prerequisites
Section titled “Prerequisites”Before starting, ensure you have:
- A Polkadot Product project running locally. See Set Up Your Project.
- PAS funds and a Bulletin Chain authorization for the account
cdmwill sign with. See Get TestNet Tokens. Deploying a contract writes to Asset Hub (fees) and publishes metadata to the Bulletin Chain (authorization). The next two sections cover installingcdmand creating that account. - A workstation rather than a browser alone. Contracts compile to PolkaVM, so unlike the frontend capabilities this step needs a local toolchain.
Install cdm
Section titled “Install cdm”Install the cdm binary. The installer also pulls the Rust nightly toolchain with rust-src and the cargo-pvm-contract build tool:
curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/main/install.sh | bashUpdate an existing install with cdm update.
Set Up a Signing Account
Section titled “Set Up a Signing Account”cdm signs from the CLI with its own keypair, separate from the account your phone holds. Generate one for the network, then map it for pallet-revive:
cdm init -n paseocdm account map -n paseoThe mapping step is required before your first deploy: pallet-revive needs each signing account bound to its H160 address, and without it the deploy fails. Two related subcommands are useful while you work:
cdm account bal -n paseo: Prints the account's balance and its Bulletin allowances, and links to a top-up when they run low.cdm account set -n paseo --mnemonic "…": Imports an existing account instead of generating one.
How Contracts Fit Together
Section titled “How Contracts Fit Together”Four things happen when you publish a contract, and cdm handles all of them in one flow:
- Build: Your Rust or Solidity contract compiles to PolkaVM bytecode targeting
pallet-revive(Solidity viaresolc). - Deploy: The bytecode is instantiated on Asset Hub at a deterministic address.
- Publish metadata: The contract's ABI and docs are uploaded to the Bulletin Chain, addressed by CID.
- Register: The contract's global name (
@scope/name) is recorded in the on-chainContractRegistry, mapping the name to its address and metadata CID.
Your frontend then reads a project-local manifest, cdm.json, which holds the deployed address and ABI for each contract your Product depends on. The @parity/product-sdk-contracts package turns that manifest into typed contract objects.
Scaffold a Contract
Section titled “Scaffold a Contract”cdm ships example templates. Scaffold the shared-counter template, which defines a minimal counter contract you can adapt:
cdm template shared-counterThat writes a Cargo workspace and a cdm.json manifest into ./shared-counter. Pass a target directory to override the location, or . to scaffold into the current directory. The template ships three crates under contracts/ that demonstrate a dependency graph: counter holds the shared count, counter-writer calls counter.increment() through a CDM reference, and counter-reader queries counter.get_count().
Each crate declares its CDM package name in its own Cargo.toml:
[package.metadata.cdm]package = "@example/counter"The contract itself is a module holding a storage struct and an impl block, annotated with the PolkaVM contract SDK macros:
#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)]
#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 1024)]mod counter { use pvm_contract_sdk::Lazy;
pub struct Counter { // Storage slots are auto-numbered in declaration order (`count` gets slot 0). count: Lazy<u32>, }
impl Counter { #[pvm_contract_sdk::constructor] pub fn new(&mut self) { self.count.set(&0); }
#[pvm_contract_sdk::method] pub fn increment(&mut self) { let current = self.count.get(); self.count.set(&(current + 1)); }
#[pvm_contract_sdk::method] pub fn get_count(&self) -> u32 { self.count.get() } }}Note that the constructor takes &mut self and initializes storage in place; it does not return Self. Storage fields are wrapped in Lazy<T> so each is read and written on demand rather than loaded wholesale.
Before deploying, change every [package.metadata.cdm] package = "@example/…" entry in the workspace to a scope you control, for example @my-app/counter. Package names are global per registry, the scaffolded @example scope is a placeholder, and registration is first-writer-owns, so you want your own scope on all three crates.
Verify the Toolchain
Section titled “Verify the Toolchain”The cdm installer already set up the Rust nightly, rust-src, and cargo-pvm-contract that the contract compiler needs. Confirm they are in place before your first build:
cdm setup --checkIf anything is missing or was broken by an unrelated Rust change, cdm setup installs or repairs it:
cdm setupBuild and Deploy
Section titled “Build and Deploy”Deploy with cdm deploy, selecting the target network with -n. This builds the bytecode, deploys it to Asset Hub, uploads the ABI to the Bulletin Chain, and registers the name, all in one flow:
cdm deploy -n paseo --suri "INSERT_ACCOUNT_SECRET_URI"cdm signs from the CLI with the account you pass as --suri, or with the keypair cdm init generated for the network. It does not sign through the Polkadot App or a phone. The -n preset also selects the registry for the network, so you do not set a registry address by hand.
Add the Contract to Your Manifest
Section titled “Add the Contract to Your Manifest”Your frontend resolves contracts from cdm.json, and cdm deploy does not write that file — cdm install does. After deploying, install your contract to write its address and ABI into the manifest. Installing works the same for a contract someone else published, so pass whichever @scope/name you need:
cdm install @my-app/counter -n paseocdm install resolves the name against the on-chain registry, fetches the ABI from the Bulletin Chain, and writes the entry into cdm.json:
{ "dependencies": { "@my-app/counter": "latest" }, "contracts": { "@my-app/counter": { "version": 1, "address": "0x…", "abi": [ /* … */ ], "metadataCid": "bafy…" } }}The shared-counter template ships its cdm.json already populated for the example contracts, so you only run cdm install when you deploy your own contract or add someone else's.
Call the Contract From Your Frontend
Section titled “Call the Contract From Your Frontend”Install the contracts package (or use the umbrella @parity/product-sdk):
npm install @parity/product-sdk-contracts @parity/product-sdk-chain-client @parity/product-sdk-descriptors @parity/product-sdk-signerBuild a ContractManager from the manifest and the host-routed chain client, map your signing account once (every contract write fails with AccountNotMapped until you do), then get a typed handle by name. Reads use query (a dry run — check .success), and writes use tx (which signs through the Host — check .ok):
import { SignerManager } from '@parity/product-sdk-signer';import { createChainClient } from '@parity/product-sdk-chain-client';import { paseo_asset_hub } from '@parity/product-sdk-descriptors/paseo-asset-hub';import { ContractManager, ensureContractAccountMapped,} from '@parity/product-sdk-contracts';import cdmJson from './cdm.json';
async function useCounter() { // The same SignerManager setup as Sign and Submit Transactions. const signerManager = new SignerManager({ ss58Prefix: 0, dappName: 'my-app.dot' }); const connected = await signerManager.connect(); if (!connected.ok) return;
const productAccount = await signerManager.getProductAccount('my-app.dot', 0); if (!productAccount.ok) return;
const account = productAccount.value; const signer = account.getSigner();
const client = await createChainClient({ chains: { assetHub: paseo_asset_hub } });
const manager = ContractManager.fromClient( cdmJson, client.raw.assetHub, paseo_asset_hub, { signerManager }, );
// pallet-revive requires each signing account to be mapped once. await ensureContractAccountMapped(manager.getRuntime(), account.address, signer);
const counter = manager.getContract('@my-app/counter');
// Read: a dry run. Check .success before reading .value. const count = await counter.getCount.query(); if (count.success) { console.log(count.value); }
// Write: signs through the Host. Returns a Result — check .ok. const result = await counter.increment.tx({ signer }); if (!result.ok) { console.error(result.error.message); }}Passing signerManager to fromClient lets the manager resolve the current account at call time, so an account switch is picked up without rebuilding it. See Sign and Submit Transactions for the signing setup in depth. Contract writes are signed by your Product-scoped account, so they route to the user's phone for approval like any other transaction. For the full frontend surface, see Contracts.
Redeploy After a Contract Change
Section titled “Redeploy After a Contract Change”Contracts are immutable once deployed. Changing contract code means deploying a new version: run cdm deploy -n paseo --suri ... again (or choose yes at the contract prompt in playground deploy). The registry appends a new version under the same name; run cdm install @my-app/counter -n paseo afterward to refresh the address and ABI in cdm.json so your frontend picks up the new deployment.
Because a redeploy gives your contract a new on-chain address, existing users pointed at the old address keep using the old contract until they load the new bundle. Storage does not move with it: the new instance starts empty, and the old one keeps serving whoever has not reloaded.
For a contract with live users and stored state, that makes a redeploy a migration rather than an update. What it takes:
- Snapshot the old state before you redeploy. Read it out through the old contract's
querymethods while its address is still the one incdm.json, since afterward you need the previous address to reach it. - Seed the new instance from that snapshot, or give the contract a method that accepts it, before pointing users at the new address.
- Keep reading from the old address until the new one is populated, so users who reload mid-migration do not see an empty contract.
- Expect a window where both are live. Users on the old bundle keep writing to the old address until they reload, so either accept losing those writes or drain them after the fact.
None of that is automated. If your contract will hold state you cannot afford to lose, design for it up front — an explicit initializer that accepts prior state costs far less than reconstructing one later.
Where to Go Next
Section titled “Where to Go Next”-
Deploy Your App
Deploy the whole Product, contracts and frontend together, and register a
.dotname with theplaygroundCLI. -
Product SDK API Reference
The full
@parity/product-sdk-contractssurface:ContractManager, contract handles, and thequery,tx, andpreparemethods. -
Contract Dependency Manager
The
cdmtoolchain in depth: templates, the registry model, versioning, and installing published contracts.