Skip to content

Add a Smart Contract to Your Product

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.

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 cdm will 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 installing cdm and 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 the cdm binary. The installer also pulls the Rust nightly toolchain with rust-src and the cargo-pvm-contract build tool:

Terminal window
curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/main/install.sh | bash

Update an existing install with cdm update.

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:

Terminal window
cdm init -n paseo
cdm account map -n paseo

The 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.

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 via resolc).
  • 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-chain ContractRegistry, 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.

cdm ships example templates. Scaffold the shared-counter template, which defines a minimal counter contract you can adapt:

Terminal window
cdm template shared-counter

That 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.

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:

Terminal window
cdm setup --check

If anything is missing or was broken by an unrelated Rust change, cdm setup installs or repairs it:

Terminal window
cdm setup

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:

Terminal window
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.

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:

Terminal window
cdm install @my-app/counter -n paseo

cdm 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.

Install the contracts package (or use the umbrella @parity/product-sdk):

Terminal window
npm install @parity/product-sdk-contracts @parity/product-sdk-chain-client @parity/product-sdk-descriptors @parity/product-sdk-signer

Build 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.

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 query methods while its address is still the one in cdm.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.

  • Deploy Your App


    Deploy the whole Product, contracts and frontend together, and register a .dot name with the playground CLI.

    → Deploy Your App

  • Product SDK API Reference


    The full @parity/product-sdk-contracts surface: ContractManager, contract handles, and the query, tx, and prepare methods.

    → Visit Site

  • Contract Dependency Manager


    The cdm toolchain in depth: templates, the registry model, versioning, and installing published contracts.

    → Visit Repo

Last updated: