Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solidity-version-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/cdm-builder": minor
---

Solidity contracts declare their publish version as a `:X.Y.Z` suffix on the `@custom:cdm` NatSpec tag (`@org/name:1.2.3`); Solidity toolchains build EVM bytecode (plain `forge build`, upstream hardhat artifacts)
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ jobs:
cargo install --force --locked --target "$host_target" --path "$tmp_dir/crates/cargo-pvm-contract"
- name: Build registry contracts
run: pnpm build:registry
# The Solidity e2e suite drives the real foundry pipeline (plain
# `forge build`; solc is auto-fetched on first build).
- name: Install foundry-polkadot
run: |
curl -L https://raw.githubusercontent.com/paritytech/foundry-polkadot/refs/heads/master/foundryup/install | bash
"$HOME/.foundry/bin/foundryup-polkadot"
echo "$HOME/.foundry/bin" >> "$GITHUB_PATH"
# The suite runs against a full local Polkadot product environment
# (PPN: relay + Asset Hub + Bulletin + IPFS gateway), started from
# prebuilt release binaries — nothing is compiled. preview-net-v1
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,15 @@ other.do_something().call(self).expect("OtherCallFailed");

For workspace-local packages, `cdm::import!` resolves the ABI through Cargo metadata when the provider crate declares the matching `[package.metadata.cdm] package`. For external packages, run `cdm i -n paseo @someorg/other-contract` first; the macro falls back to the flat `cdm.json` snapshot and materializes any ABI file it needs under the project-local `.cdm/` directory.

Solidity contracts use NatSpec for their own CDM package name:
Solidity contracts use NatSpec for their own CDM package name and publish version:

```solidity
/// @custom:cdm @yourorg/mycontract
/// @custom:cdm @yourorg/mycontract:0.1.0
contract MyContract {}
```

The `:X.Y.Z` suffix (same colon convention as `cdm i @org/name:1.2.1`) plays the role Cargo.toml `[package].version` plays for Rust crates: a strict `X.Y.Z` semver that `cdm deploy` publishes, skipping versions the registry already has.

To call an installed CDM contract from Solidity, import the generated interface:

```solidity
Expand Down Expand Up @@ -239,7 +241,10 @@ cdm template foundry-counter
```

`hardhat-counter` uses `@parity/hardhat-polkadot` and compiles with `pnpm build`.
`foundry-counter` uses the Polkadot Foundry fork and compiles with `forge build --resolc`.
`foundry-counter` uses the Polkadot Foundry fork and compiles with `forge build`.

Both templates target pallet-revive's EVM backend: contracts compile to plain EVM
bytecode with upstream solc — no resolc involved.

These templates are compile-ready starter projects and can be built, deployed, published, registered, installed, and consumed through CDM.

Expand Down
7 changes: 4 additions & 3 deletions src/lib/contracts/src/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export interface ContractInfo {
/** Human-readable display name when it differs from the stable internal name. */
displayName?: string;
/**
* Crate version from Cargo.toml `[package].version` — the source of truth
* for the semver a `cdm deploy` publishes. Absent for targets without a
* Cargo manifest (e.g. Solidity contracts).
* The semver a `cdm deploy` publishes. Rust crates source it from
* Cargo.toml `[package].version`, Solidity contracts from the `:X.Y.Z`
* suffix of the `@custom:cdm` NatSpec tag (`@org/name:1.2.3`). Absent
* when the target declares neither.
*/
version?: string;
/** Source toolchain that produced or will produce this contract artifact. */
Expand Down
64 changes: 54 additions & 10 deletions src/lib/contracts/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,27 @@ async function queryRegistryStableAddress(
return option.value as HexString;
}

/** Where a contract's toolchain declares its publish version, for error advice. */
function versionSource(contract: ContractInfo): string {
return contract.toolchain === "foundry" || contract.toolchain === "hardhat"
? `the :X.Y.Z suffix of the contract's @custom:cdm NatSpec tag ` +
`("/// @custom:cdm @org/name:X.Y.Z")`
: "the crate's Cargo.toml [package].version";
}

/**
* A deployable contract's publish version, resolved from the crate's
* Cargo.toml `[package].version`. Strict `X.Y.Z` only — anything else (or a
* missing version, e.g. a Solidity target without one) is a configuration
* error worth failing the whole deploy for before any build starts.
* A deployable contract's publish version — Rust crates declare it in
* Cargo.toml `[package].version`, Solidity contracts as the `:X.Y.Z` suffix
* of the `@custom:cdm` NatSpec tag. Strict `X.Y.Z` only — anything else (or
* a missing version) is a configuration error worth failing the whole deploy
* for before any build starts.
*/
function resolveContractVersion(contract: ContractInfo): { version: string; key: bigint } {
const raw = contract.version;
if (raw === undefined || raw === "") {
throw new Error(
`Contract "${contract.name}" has no version — cdm deploy publishes the exact ` +
`"X.Y.Z" semver from the crate's Cargo.toml [package].version.`,
`"X.Y.Z" semver from ${versionSource(contract)}.`,
);
}
let key: bigint;
Expand All @@ -100,14 +109,14 @@ function resolveContractVersion(contract: ContractInfo): { version: string; key:
} catch {
throw new Error(
`Contract "${contract.name}" has invalid version "${raw}" — cdm deploy requires ` +
`an exact "X.Y.Z" semver in Cargo.toml [package].version ` +
`an exact "X.Y.Z" semver in ${versionSource(contract)} ` +
`(no ranges, prerelease, or build tags).`,
);
}
if (!isPublishableKey(key)) {
throw new Error(
`Contract "${contract.name}" version "${raw}" is reserved — bump Cargo.toml ` +
`[package].version to at least 0.0.1.`,
`Contract "${contract.name}" version "${raw}" is reserved — bump the version in ` +
`${versionSource(contract)} to at least 0.0.1.`,
);
}
return { version: keyToSemver(key), key };
Expand Down Expand Up @@ -1127,8 +1136,9 @@ export async function deployContracts(opts: DeployContractsOptions): Promise<Dep

// ---- 3. resolve crate versions + skip already-published versions ----
//
// Version source of truth is each crate's Cargo.toml [package].version;
// an invalid or missing version is a configuration error that aborts
// Version source of truth is each crate's Cargo.toml [package].version
// (Rust) or the @custom:cdm tag's :X.Y.Z suffix (Solidity); an
// invalid or missing version is a configuration error that aborts
// the deploy before anything is built or submitted. A crate whose
// packed key is not strictly greater than the registry's latest for
// its package is already published — it's marked "up-to-date" and
Expand Down Expand Up @@ -1661,13 +1671,15 @@ if (import.meta.vitest) {
deps: Record<string, string[]> = {},
cdm: Record<string, string> = {},
versions: Record<string, string> = {},
toolchains: Record<string, ContractToolchain> = {},
): DeploymentOrderLayered {
const contractMap = new Map<string, CI>();
for (const layer of layers) {
for (const crate of layer) {
contractMap.set(crate, {
name: crate,
version: versions[crate] ?? "0.1.0",
toolchain: toolchains[crate],
cdmPackage: cdm[crate] ?? null,
description: null,
authors: [],
Expand Down Expand Up @@ -2250,5 +2262,37 @@ if (import.meta.vitest) {
expect(mockBuild).not.toHaveBeenCalled();
expect(events.at(-1)).toMatchObject({ type: "pipeline-error" });
});

test("missing-version error points Rust contracts at Cargo.toml", async () => {
(mockDetect as any).mockReturnValue(
makeOrder([["a"]], {}, { a: "@example/a" }, { a: "" }, { a: "rust" }),
);

await expect(
deployContracts({
rootDir: "/fake",
client: makeFakeClient(),
signer: makePolkadotSigner(1),
origin: "5GrwvaEF5zXb26Fz9rcQpDWSJm8VAz5tK7gU3QF8JKpt5M7" as SS58String,
registryAddress: getRegistryAddress("paseo") as HexString,
}),
).rejects.toThrow(/Cargo\.toml \[package\]\.version/);
});

test("missing-version error points Solidity contracts at the NatSpec tag", async () => {
(mockDetect as any).mockReturnValue(
makeOrder([["a"]], {}, { a: "@example/a" }, { a: "" }, { a: "foundry" }),
);

await expect(
deployContracts({
rootDir: "/fake",
client: makeFakeClient(),
signer: makePolkadotSigner(1),
origin: "5GrwvaEF5zXb26Fz9rcQpDWSJm8VAz5tK7gU3QF8JKpt5M7" as SS58String,
registryAddress: getRegistryAddress("paseo") as HexString,
}),
).rejects.toThrow(/@custom:cdm @org\/name:X\.Y\.Z/);
});
});
}
62 changes: 57 additions & 5 deletions src/lib/contracts/src/solidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,20 @@ function collectSolidityFiles(dir: string, out: string[] = []): string[] {
interface SolidityContractDefinition {
contractName: string;
cdmPackage: string | null;
version: string | null;
description: string | null;
authors: string[];
homepage: string | null;
repository: string | null;
}

const CDM_NATSPEC_RE = /@custom:cdm\s+(@[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+)/;
/**
* `@custom:cdm @org/name[:X.Y.Z]` — the CDM package name, optionally suffixed
* with the publish version (same colon convention as `cdm i @org/name:1.2.1`;
* package names can never contain `:`). The version is captured loosely here —
* semver validation stays centralized in the deploy pipeline.
*/
const CDM_NATSPEC_RE = /@custom:cdm\s+(@[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+)(?::(\S+))?/;

function blankBlockComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "));
Expand Down Expand Up @@ -242,8 +249,10 @@ function firstNatSpecValue(tags: Map<string, string[]>, names: string[]): string
function parsePrecedingNatSpec(source: string, declarationIndex: number) {
const comment = precedingNatSpecComment(source, declarationIndex);
const tags = parseNatSpecTags(comment);
const cdmTag = comment?.match(CDM_NATSPEC_RE);
return {
cdmPackage: comment?.match(CDM_NATSPEC_RE)?.[1] ?? null,
cdmPackage: cdmTag?.[1] ?? null,
version: cdmTag?.[2] ?? null,
description: firstNatSpecValue(tags, ["custom:description", "notice", "dev"]),
authors: [...(tags.get("author") ?? []), ...(tags.get("custom:author") ?? [])].filter(
(author) => author.trim().length > 0,
Expand Down Expand Up @@ -496,6 +505,7 @@ export function detectSolidityBuildTargets(rootDir: string): SolidityBuildTarget
name: definition.cdmPackage ?? definition.contractName,
displayName: definition.cdmPackage ?? definition.contractName,
toolchain,
version: definition.version ?? undefined,
cdmPackage: definition.cdmPackage,
description: definition.description ?? meta.description,
authors: definition.authors.length > 0 ? definition.authors : meta.authors,
Expand Down Expand Up @@ -725,9 +735,13 @@ function collectJsonFiles(
}

function isHardhatArtifact(artifact: SolidityArtifactJson): boolean {
// "hh-sol-artifact-" is upstream hardhat's format — what
// @parity/hardhat-polkadot emits in EVM mode (`polkadot: { target: "evm" }`
// or no polkadot flag). "hh-resolc-artifact-" is its resolc/PolkaVM mode.
return (
typeof artifact._format === "string" &&
artifact._format.startsWith("hh-resolc-artifact-") &&
(artifact._format.startsWith("hh-sol-artifact-") ||
artifact._format.startsWith("hh-resolc-artifact-")) &&
typeof artifact.contractName === "string" &&
typeof artifact.sourceName === "string" &&
artifact.sourceName.endsWith(".sol") &&
Expand Down Expand Up @@ -845,9 +859,13 @@ export async function buildSolidityToolchain(
artifacts: SolidityBuildArtifact[];
missing: SolidityBuildTarget[];
}> {
// Solidity contracts target pallet-revive's EVM backend: plain upstream
// builds producing EVM bytecode (no resolc). The chain distinguishes EVM
// initcode from PolkaVM blobs by magic bytes at upload, so the deploy
// path is shared.
const command =
toolchain === "foundry"
? { cmd: "forge", args: ["build", "--resolc"] }
? { cmd: "forge", args: ["build"] }
: { cmd: "npx", args: ["hardhat", "compile"] };

const result = options.skipBuild
Expand Down Expand Up @@ -965,6 +983,39 @@ if (import.meta.vitest) {
]);
});

test("parses the version suffix from @custom:cdm @org/name:X.Y.Z", () => {
const root = makeProject();
mkdirSync(join(root, "contracts"), { recursive: true });
writeFileSync(join(root, "foundry.toml"), 'src = "contracts"\n');
writeFileSync(
join(root, "contracts", "Counters.sol"),
`
/// @custom:cdm @example/counter-a:1.2.3
contract CounterA {}

/**
* @custom:cdm @example/counter-b:4.5.6
*/
contract CounterB {}

/// @custom:cdm @example/counter-c
contract CounterC {}
`,
);

const targets = detectSolidityBuildTargets(root);
const byName = new Map(targets.map((target) => [target.contractName, target]));

// The suffix never leaks into the package name.
expect(byName.get("CounterA")?.cdmPackage).toBe("@example/counter-a");
expect(byName.get("CounterA")?.version).toBe("1.2.3");
expect(byName.get("CounterB")?.cdmPackage).toBe("@example/counter-b");
expect(byName.get("CounterB")?.version).toBe("4.5.6");
// A tag without the suffix still detects — just with no version.
expect(byName.get("CounterC")?.cdmPackage).toBe("@example/counter-c");
expect(byName.get("CounterC")?.version).toBeUndefined();
});

test("uses contract NatSpec metadata before project package metadata", () => {
const root = makeProject();
mkdirSync(join(root, "contracts"), { recursive: true });
Expand Down Expand Up @@ -1135,8 +1186,9 @@ if (import.meta.vitest) {
writeFileSync(join(root, "src", "Counter.sol"), "contract CounterA {}\n");
writeFileSync(
join(root, "build-artifacts", "src", "Counter.sol", "CounterA.json"),
// Upstream hardhat format — what EVM-mode compiles emit.
JSON.stringify({
_format: "hh-resolc-artifact-1",
_format: "hh-sol-artifact-1",
contractName: "CounterA",
sourceName: "src/Counter.sol",
abi: [],
Expand Down
66 changes: 65 additions & 1 deletion src/lib/contracts/tests/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import type { HexString } from "polkadot-api";
import { submitAndWatch, type SubmittableTransaction } from "@parity/product-sdk-tx";
import { GAS_LIMIT, STORAGE_DEPOSIT_LIMIT } from "@parity/cdm-utils";
import { ALICE_SS58, GAS_LIMIT, STORAGE_DEPOSIT_LIMIT } from "@parity/cdm-utils";

// Deploy via `bun run src/lib/scripts/deploy-registry.ts` rather than
// invoking `ContractDeployer` programmatically: the deploy dry-run behaves
Expand Down Expand Up @@ -175,6 +175,70 @@ export async function deployBlob(api: any, signer: any, pvmPath: string): Promis
return instantiated[0].payload.contract as HexString;
}

/** Plain hex → bytes, matching product-sdk's own calldata handling. */
export function hexBytes(hex: string): Uint8Array {
const stripped = hex.startsWith("0x") ? hex.slice(2) : hex;
const out = new Uint8Array(stripped.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(stripped.slice(i * 2, i * 2 + 2), 16);
}
return out;
}

/** Dry-run a raw contract call (versioned/meta wire formats have no ABI).
* Argument types mirror product-sdk's own `dryRunCall` exactly — a hex
* string `dest` and `Uint8Array` calldata — which is the combination PPN's
* runtime metadata encodes without an `Incompatible runtime entry` error. */
export async function dryRunCall(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api: any,
dest: string,
input: string,
origin: string = ALICE_SS58,
): Promise<{ success: boolean; reverted: boolean; data: string }> {
const r = await api.apis.ReviveApi.call(
origin,
dest,
0n,
undefined,
undefined,
hexBytes(input),
{ at: "best" },
);
if (!r.result.success) {
return { success: false, reverted: false, data: "0x" };
}
const flags = Number(r.result.value.flags);
const raw = r.result.value.data;
const data =
typeof raw === "string"
? raw
: raw instanceof Uint8Array
? `0x${Array.from(raw)
.map((b: number) => b.toString(16).padStart(2, "0"))
.join("")}`
: raw.asHex();
return { success: true, reverted: (flags & 1) === 1, data: String(data).toLowerCase() };
}

/** Submit a raw contract call as a transaction (fixed generous limits). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function rawCallTx(api: any, signer: any, dest: string, input: string): Promise<void> {
const tx = api.tx.Revive.call({
dest,
value: 0n,
weight_limit: { ref_time: GAS_LIMIT.refTime, proof_size: GAS_LIMIT.proofSize },
storage_deposit_limit: STORAGE_DEPOSIT_LIMIT,
data: hexBytes(input),
});
const result = await submitAndWatch(tx as unknown as SubmittableTransaction, signer, {
waitFor: "best-block",
});
if (!result.ok) {
throw new Error(`rawCallTx failed: ${JSON.stringify(result.error)}`);
}
}

export interface DeployedRegistry {
/** The stable registry address — the EIP-1967 proxy, CREATE3-derived. */
address: HexString;
Expand Down
Loading
Loading