From ee8180af7c4b087cd68264062cefe7c4673f48c8 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Wed, 19 Aug 2026 22:41:26 -0400 Subject: [PATCH 1/2] Solidity contracts declare their publish version via @custom:cdm-version --- .changeset/solidity-version-source.md | 5 ++ README.md | 5 +- src/lib/contracts/src/detection.ts | 7 ++- src/lib/contracts/src/pipeline.ts | 61 ++++++++++++++++--- src/lib/contracts/src/solidity.ts | 33 ++++++++++ .../foundry-counter/contracts/CounterA.sol | 1 + .../foundry-counter/contracts/CounterB.sol | 1 + .../hardhat-counter/contracts/CounterA.sol | 1 + .../hardhat-counter/contracts/CounterB.sol | 1 + 9 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 .changeset/solidity-version-source.md diff --git a/.changeset/solidity-version-source.md b/.changeset/solidity-version-source.md new file mode 100644 index 0000000..d78e7ae --- /dev/null +++ b/.changeset/solidity-version-source.md @@ -0,0 +1,5 @@ +--- +"@parity/cdm-builder": minor +--- + +Solidity contracts declare their publish version with the `@custom:cdm-version` NatSpec tag diff --git a/README.md b/README.md index 0e6a823..e2a0801 100644 --- a/README.md +++ b/README.md @@ -110,13 +110,16 @@ 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-version 0.1.0 contract MyContract {} ``` +`@custom:cdm-version` 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 diff --git a/src/lib/contracts/src/detection.ts b/src/lib/contracts/src/detection.ts index e777e4f..77f7b32 100644 --- a/src/lib/contracts/src/detection.ts +++ b/src/lib/contracts/src/detection.ts @@ -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 + * `@custom:cdm-version` NatSpec tag. Absent when the target declares + * neither. */ version?: string; /** Source toolchain that produced or will produce this contract artifact. */ diff --git a/src/lib/contracts/src/pipeline.ts b/src/lib/contracts/src/pipeline.ts index 9649ddd..04e6c51 100644 --- a/src/lib/contracts/src/pipeline.ts +++ b/src/lib/contracts/src/pipeline.ts @@ -80,18 +80,26 @@ 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" + ? `a "/// @custom:cdm-version X.Y.Z" NatSpec tag next to the contract's @custom:cdm tag` + : "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 in a + * `@custom:cdm-version` 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; @@ -100,14 +108,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 }; @@ -1127,7 +1135,8 @@ export async function deployContracts(opts: DeployContractsOptions): Promise = {}, cdm: Record = {}, versions: Record = {}, + toolchains: Record = {}, ): DeploymentOrderLayered { const contractMap = new Map(); for (const layer of layers) { @@ -1668,6 +1678,7 @@ if (import.meta.vitest) { contractMap.set(crate, { name: crate, version: versions[crate] ?? "0.1.0", + toolchain: toolchains[crate], cdmPackage: cdm[crate] ?? null, description: null, authors: [], @@ -2250,5 +2261,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-version/); + }); }); } diff --git a/src/lib/contracts/src/solidity.ts b/src/lib/contracts/src/solidity.ts index 0d07b34..d0972b6 100644 --- a/src/lib/contracts/src/solidity.ts +++ b/src/lib/contracts/src/solidity.ts @@ -163,6 +163,7 @@ 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; @@ -244,6 +245,7 @@ function parsePrecedingNatSpec(source: string, declarationIndex: number) { const tags = parseNatSpecTags(comment); return { cdmPackage: comment?.match(CDM_NATSPEC_RE)?.[1] ?? null, + version: firstNatSpecValue(tags, ["custom:cdm-version"]), description: firstNatSpecValue(tags, ["custom:description", "notice", "dev"]), authors: [...(tags.get("author") ?? []), ...(tags.get("custom:author") ?? [])].filter( (author) => author.trim().length > 0, @@ -496,6 +498,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, @@ -965,6 +968,36 @@ if (import.meta.vitest) { ]); }); + test("parses @custom:cdm-version from NatSpec", () => { + 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 + /// @custom:cdm-version 1.2.3 + contract CounterA {} + + /** + * @custom:cdm @example/counter-b + * @custom:cdm-version 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])); + + expect(byName.get("CounterA")?.version).toBe("1.2.3"); + expect(byName.get("CounterB")?.version).toBe("4.5.6"); + expect(byName.get("CounterC")?.version).toBeUndefined(); + }); + test("uses contract NatSpec metadata before project package metadata", () => { const root = makeProject(); mkdirSync(join(root, "contracts"), { recursive: true }); diff --git a/src/templates/foundry-counter/contracts/CounterA.sol b/src/templates/foundry-counter/contracts/CounterA.sol index df4ff9d..bb452c8 100644 --- a/src/templates/foundry-counter/contracts/CounterA.sol +++ b/src/templates/foundry-counter/contracts/CounterA.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; /// @notice Stores a counter that other contracts can increment through CDM. /// @custom:cdm @example/counter-a +/// @custom:cdm-version 0.1.0 contract CounterA { uint256 public count; diff --git a/src/templates/foundry-counter/contracts/CounterB.sol b/src/templates/foundry-counter/contracts/CounterB.sol index 749630f..a87a425 100644 --- a/src/templates/foundry-counter/contracts/CounterB.sol +++ b/src/templates/foundry-counter/contracts/CounterB.sol @@ -5,6 +5,7 @@ import "../.cdm/solidity/example/counter-a.sol"; /// @notice Demonstrates a Solidity contract calling CounterA through a generated CDM import. /// @custom:cdm @example/counter-b +/// @custom:cdm-version 0.1.0 contract CounterB { uint256 public localCount; diff --git a/src/templates/hardhat-counter/contracts/CounterA.sol b/src/templates/hardhat-counter/contracts/CounterA.sol index df4ff9d..bb452c8 100644 --- a/src/templates/hardhat-counter/contracts/CounterA.sol +++ b/src/templates/hardhat-counter/contracts/CounterA.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; /// @notice Stores a counter that other contracts can increment through CDM. /// @custom:cdm @example/counter-a +/// @custom:cdm-version 0.1.0 contract CounterA { uint256 public count; diff --git a/src/templates/hardhat-counter/contracts/CounterB.sol b/src/templates/hardhat-counter/contracts/CounterB.sol index 749630f..a87a425 100644 --- a/src/templates/hardhat-counter/contracts/CounterB.sol +++ b/src/templates/hardhat-counter/contracts/CounterB.sol @@ -5,6 +5,7 @@ import "../.cdm/solidity/example/counter-a.sol"; /// @notice Demonstrates a Solidity contract calling CounterA through a generated CDM import. /// @custom:cdm @example/counter-b +/// @custom:cdm-version 0.1.0 contract CounterB { uint256 public localCount; From 93dbeeaeae14250ae9f425b343b7cc3d767c03f3 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Wed, 19 Aug 2026 23:37:14 -0400 Subject: [PATCH 2/2] Colon version suffix on @custom:cdm; EVM-backend solidity builds; solidity e2e on PPN --- .changeset/solidity-version-source.md | 2 +- .github/workflows/test.yml | 7 + README.md | 10 +- src/lib/contracts/src/detection.ts | 6 +- src/lib/contracts/src/pipeline.ts | 13 +- src/lib/contracts/src/solidity.ts | 41 ++- src/lib/contracts/tests/e2e/harness.ts | 66 +++- .../contracts/tests/e2e/solidity.e2e.test.ts | 286 ++++++++++++++++++ .../foundry-counter/contracts/CounterA.sol | 3 +- .../foundry-counter/contracts/CounterB.sol | 3 +- src/templates/foundry-counter/package.json | 2 +- .../hardhat-counter/contracts/CounterA.sol | 3 +- .../hardhat-counter/contracts/CounterB.sol | 3 +- .../hardhat-counter/hardhat.config.ts | 4 +- vitest.e2e.config.ts | 9 + 15 files changed, 422 insertions(+), 36 deletions(-) create mode 100644 src/lib/contracts/tests/e2e/solidity.e2e.test.ts diff --git a/.changeset/solidity-version-source.md b/.changeset/solidity-version-source.md index d78e7ae..c0afef8 100644 --- a/.changeset/solidity-version-source.md +++ b/.changeset/solidity-version-source.md @@ -2,4 +2,4 @@ "@parity/cdm-builder": minor --- -Solidity contracts declare their publish version with the `@custom:cdm-version` NatSpec tag +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) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83912d5..1528f39 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/README.md b/README.md index e2a0801..a3e8bcb 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,11 @@ For workspace-local packages, `cdm::import!` resolves the ABI through Cargo meta Solidity contracts use NatSpec for their own CDM package name and publish version: ```solidity -/// @custom:cdm @yourorg/mycontract -/// @custom:cdm-version 0.1.0 +/// @custom:cdm @yourorg/mycontract:0.1.0 contract MyContract {} ``` -`@custom:cdm-version` 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. +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: @@ -242,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. diff --git a/src/lib/contracts/src/detection.ts b/src/lib/contracts/src/detection.ts index 77f7b32..6f453ea 100644 --- a/src/lib/contracts/src/detection.ts +++ b/src/lib/contracts/src/detection.ts @@ -11,9 +11,9 @@ export interface ContractInfo { displayName?: string; /** * The semver a `cdm deploy` publishes. Rust crates source it from - * Cargo.toml `[package].version`, Solidity contracts from the - * `@custom:cdm-version` NatSpec tag. Absent when the target declares - * neither. + * 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. */ diff --git a/src/lib/contracts/src/pipeline.ts b/src/lib/contracts/src/pipeline.ts index 04e6c51..c735bbd 100644 --- a/src/lib/contracts/src/pipeline.ts +++ b/src/lib/contracts/src/pipeline.ts @@ -83,14 +83,15 @@ async function queryRegistryStableAddress( /** Where a contract's toolchain declares its publish version, for error advice. */ function versionSource(contract: ContractInfo): string { return contract.toolchain === "foundry" || contract.toolchain === "hardhat" - ? `a "/// @custom:cdm-version X.Y.Z" NatSpec tag next to the contract's @custom:cdm tag` + ? `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 — Rust crates declare it in - * Cargo.toml `[package].version`, Solidity contracts in a - * `@custom:cdm-version` NatSpec tag. Strict `X.Y.Z` only — anything else (or + * 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. */ @@ -1136,8 +1137,8 @@ export async function deployContracts(opts: DeployContractsOptions): Promise comment.replace(/[^\n]/g, " ")); @@ -243,9 +249,10 @@ function firstNatSpecValue(tags: Map, 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, - version: firstNatSpecValue(tags, ["custom:cdm-version"]), + 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, @@ -728,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") && @@ -848,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 @@ -968,20 +983,18 @@ if (import.meta.vitest) { ]); }); - test("parses @custom:cdm-version from NatSpec", () => { + 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 - /// @custom:cdm-version 1.2.3 + /// @custom:cdm @example/counter-a:1.2.3 contract CounterA {} /** - * @custom:cdm @example/counter-b - * @custom:cdm-version 4.5.6 + * @custom:cdm @example/counter-b:4.5.6 */ contract CounterB {} @@ -993,8 +1006,13 @@ if (import.meta.vitest) { 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(); }); @@ -1168,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: [], diff --git a/src/lib/contracts/tests/e2e/harness.ts b/src/lib/contracts/tests/e2e/harness.ts index 5f4bd44..e40ae2f 100644 --- a/src/lib/contracts/tests/e2e/harness.ts +++ b/src/lib/contracts/tests/e2e/harness.ts @@ -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 @@ -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 { + 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; diff --git a/src/lib/contracts/tests/e2e/solidity.e2e.test.ts b/src/lib/contracts/tests/e2e/solidity.e2e.test.ts new file mode 100644 index 0000000..ff2c5e6 --- /dev/null +++ b/src/lib/contracts/tests/e2e/solidity.e2e.test.ts @@ -0,0 +1,286 @@ +// End-to-end Solidity pathway validation against a local PPN. +// +// Drives the REAL pipeline (`deployContracts` from @parity/cdm-builder) on a +// copy of the foundry-counter template and proves the parts of the Solidity +// story that only a live chain can prove: +// +// - foundry contracts build to plain EVM bytecode (no resolc) and publish at +// the version declared by the `@custom:cdm @org/name:X.Y.Z` NatSpec tag; +// - the PolkaVM per-name proxy delegate-calls the EVM implementation — +// cross-VM delegation is the load-bearing assumption of putting Solidity +// contracts behind CDM's proxies; +// - the layered deploy bakes dependency proxy addresses into downstream +// contracts, so an EVM contract calls its dependency through the +// dependency's stable address; +// - the NatSpec version gates deploys end to end: redeploys skip as +// up-to-date, a tag bump republished behind the same proxy keeps storage, +// and `[MAGIC][key]` versioned calls pin the older version over it. +// +// Requires a running PPN and `forge` (foundry-polkadot fork) on PATH. + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import type { HexString } from "polkadot-api"; +import { createCdmChainClient, prepareSigner, type CdmChainClient } from "@parity/cdm-env"; +import { ALICE_SS58 } from "@parity/cdm-utils"; +import { + CONTRACTS_REGISTRY_ABI, + deployContracts, + encodeVersionedCall, + generateSolidityLocalBuildImport, + packVersionKey, + type DeployEvent, + type DeploySummary, +} from "@parity/cdm-builder"; +import { createContractFromClient } from "@parity/product-sdk-contracts"; +import { connectPpn, deployRegistry, dryRunCall, rawCallTx, type PpnHandle } from "./harness"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATE_DIR = resolve(__dirname, "../../../../../src/templates/foundry-counter"); + +// Unique per run so the suite holds on networks with prior registry state. +const RUN = Date.now().toString(36); +const NAME_A = `@test/sol-a-${RUN}`; +const NAME_B = `@test/sol-b-${RUN}`; +const KEY_0_1_0 = packVersionKey(0, 1, 0); +const KEY_0_2_0 = packVersionKey(0, 2, 0); + +// The generated local-build import for NAME_A — its path is what CounterB +// must import and its `library` identifier is what CounterB's source calls. +// Derived through the real generator so the test can never drift from it. +const GENERATED_A = generateSolidityLocalBuildImport({ + library: NAME_A, + contractName: "CounterA", + sourceImportPath: "./unused.sol", +}); +const LIB_A = GENERATED_A.content.match(/\blibrary (\w+)/)![1]; + +// keccak256(signature)[..4] as calldata hex. +function selector(signature: string): `0x${string}` { + const hash = keccak_256(new TextEncoder().encode(signature)).subarray(0, 4); + return `0x${Array.from(hash) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}` as `0x${string}`; +} + +const INCREMENT = selector("increment()"); +const COUNT = selector("count()"); +const INCREMENT_A = selector("incrementA()"); +const READ_A = selector("readA()"); + +const PVM_MAGIC = [0x50, 0x56, 0x4d, 0x00]; // "PVM\0" + +let ppn: PpnHandle; +let chainClient: CdmChainClient; +let registryAddress: HexString; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let api: any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let signer: any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let registry: any; +let projectDir: string; +let proxyA: string; +let proxyB: string; + +function lc(v: unknown): string { + return String(v).toLowerCase(); +} + +function rewrite(path: string, edits: Array<[string | RegExp, string]>): void { + let content = readFileSync(path, "utf8"); + for (const [from, to] of edits) { + content = content.replaceAll(from as string, to); + } + writeFileSync(path, content); +} + +/** Run the real deploy pipeline on the temp project, collecting events. */ +async function deploy(): Promise<{ summary: DeploySummary; events: DeployEvent[] }> { + const events: DeployEvent[] = []; + const summary = await deployContracts({ + rootDir: projectDir, + client: chainClient, + signer, + origin: ALICE_SS58, + registryAddress, + onEvent: (event) => events.push(event), + }); + return { summary, events }; +} + +function byCrate(summary: DeploySummary) { + return new Map(summary.contracts.map((contract) => [contract.crate, contract])); +} + +async function latestKey(name: string): Promise { + const r = await registry.getLatestKey.query(name); + expect(r.success).toBe(true); + return BigInt(r.value as bigint); +} + +async function stableAddress(name: string): Promise { + const r = await registry.getAddress.query(name); + const opt = r.value as { isSome: boolean; value: string }; + expect(opt.isSome).toBe(true); + return String(opt.value); +} + +async function countOf(dest: string): Promise { + const r = await dryRunCall(api, dest, COUNT); + expect(r.success).toBe(true); + expect(r.reverted).toBe(false); + return BigInt(r.data); +} + +beforeAll(async () => { + ppn = await connectPpn(); + const deployed = await deployRegistry(ppn.wsUrl); + registryAddress = deployed.address; + + signer = prepareSigner("Alice"); + chainClient = await createCdmChainClient({ + assethubUrl: ppn.wsUrl, + bulletinUrl: ppn.bulletinUrl, + chainName: "local", + }); + await chainClient.raw.assetHub.getChainSpecData(); + api = chainClient.raw.assetHub.getUnsafeApi(); + + registry = await createContractFromClient( + chainClient.raw.assetHub, + chainClient.descriptors.assetHub, + deployed.address, + CONTRACTS_REGISTRY_ABI, + { defaultSigner: signer, defaultOrigin: ALICE_SS58 }, + ); + + // A private copy of the template with per-run package names. CounterB's + // import path and library identifier follow NAME_A's generated import. + projectDir = mkdtempSync(join(tmpdir(), "cdm-solidity-e2e-")); + cpSync(TEMPLATE_DIR, projectDir, { recursive: true }); + rewrite(join(projectDir, "contracts", "CounterA.sol"), [["@example/counter-a", NAME_A]]); + rewrite(join(projectDir, "contracts", "CounterB.sol"), [ + ["@example/counter-b", NAME_B], + ["../.cdm/solidity/example/counter-a.sol", `../${GENERATED_A.path}`], + ["ExampleCounterA", LIB_A], + ]); +}, 300_000); + +afterAll(async () => { + chainClient?.destroy(); + if (projectDir) rmSync(projectDir, { recursive: true, force: true }); +}); + +describe("deploying the foundry template", () => { + test("publishes both contracts at the NatSpec-tagged version, dependency first", async () => { + const { summary, events } = await deploy(); + const detect = events.find((event) => event.type === "detect"); + expect(detect && detect.type === "detect").toBe(true); + if (detect?.type !== "detect") throw new Error("unreachable"); + + // Detection: two foundry targets, versions from the :X.Y.Z tag + // suffix, CounterB layered after its CounterA dependency. + const detected = new Map(detect.contracts.map((c) => [c.name, c])); + expect(detected.get(NAME_A)?.toolchain).toBe("foundry"); + expect(detected.get(NAME_A)?.version).toBe("0.1.0"); + expect(detected.get(NAME_B)?.version).toBe("0.1.0"); + expect(detected.get(NAME_B)?.dependsOnCrates).toEqual([NAME_A]); + expect(detect.layers).toEqual([[NAME_A], [NAME_B]]); + + const contracts = byCrate(summary); + expect(contracts.get(NAME_A)).toMatchObject({ status: "done", version: "0.1.0" }); + expect(contracts.get(NAME_B)).toMatchObject({ status: "done", version: "0.1.0" }); + + // The published key is exactly the tag's version. + expect(await latestKey(NAME_A)).toBe(KEY_0_1_0); + expect(await latestKey(NAME_B)).toBe(KEY_0_1_0); + + // The summary addresses are the names' stable (per-name proxy) + // addresses, not the implementation blobs. + proxyA = await stableAddress(NAME_A); + proxyB = await stableAddress(NAME_B); + expect(lc(contracts.get(NAME_A)?.address)).toBe(lc(proxyA)); + expect(lc(contracts.get(NAME_B)?.address)).toBe(lc(proxyB)); + }, 240_000); + + test("the built artifacts are EVM bytecode, not PolkaVM blobs", () => { + const outDir = join(projectDir, "target", "cdm", "foundry"); + const artifacts = readdirSync(outDir); + expect(artifacts.length).toBe(2); + for (const artifact of artifacts) { + const bytes = readFileSync(join(outDir, artifact)); + expect(bytes.length).toBeGreaterThan(0); + expect([...bytes.subarray(0, 4)]).not.toEqual(PVM_MAGIC); + } + }); + + test("a plain call through the PolkaVM proxy executes the EVM implementation", async () => { + // THE cross-VM assertion: proxyA is a PolkaVM contract delegate- + // calling solc-built EVM bytecode over the proxy's own storage. + expect(await countOf(proxyA)).toBe(0n); + await rawCallTx(api, signer, proxyA, INCREMENT); + expect(await countOf(proxyA)).toBe(1n); + }); + + test("an EVM contract calls its dependency through the dependency's proxy", async () => { + // The layer-two build baked proxyA's stable address into CounterB's + // generated import: EVM (B) → PolkaVM proxy (A) → EVM (A). + await rawCallTx(api, signer, proxyB, INCREMENT_A); + expect(await countOf(proxyA)).toBe(2n); + + const readA = await dryRunCall(api, proxyB, READ_A); + expect(readA.reverted).toBe(false); + expect(BigInt(readA.data)).toBe(2n); + }); +}); + +describe("the NatSpec version gate", () => { + test("an immediate second deploy skips everything as up-to-date", async () => { + const { summary } = await deploy(); + const contracts = byCrate(summary); + expect(contracts.get(NAME_A)).toMatchObject({ + status: "up-to-date", + version: "0.1.0", + }); + expect(contracts.get(NAME_B)).toMatchObject({ + status: "up-to-date", + version: "0.1.0", + }); + expect(await latestKey(NAME_A)).toBe(KEY_0_1_0); + }, 120_000); + + test("a tag bump republishes behind the same proxy over the same storage", async () => { + rewrite(join(projectDir, "contracts", "CounterA.sol"), [ + [`${NAME_A}:0.1.0`, `${NAME_A}:0.2.0`], + ]); + + const { summary } = await deploy(); + const contracts = byCrate(summary); + expect(contracts.get(NAME_A)).toMatchObject({ status: "done", version: "0.2.0" }); + expect(contracts.get(NAME_B)).toMatchObject({ + status: "up-to-date", + version: "0.1.0", + }); + expect(await latestKey(NAME_A)).toBe(KEY_0_2_0); + + // Same stable address, same storage: the counter written through + // v0.1.0 reads back through v0.2.0. + expect(lc(await stableAddress(NAME_A))).toBe(lc(proxyA)); + expect(await countOf(proxyA)).toBe(2n); + }, 240_000); + + test("versioned calls pin the previous EVM implementation over shared storage", async () => { + const pinnedRead = await dryRunCall(api, proxyA, encodeVersionedCall(KEY_0_1_0, COUNT)); + expect(pinnedRead.reverted).toBe(false); + expect(BigInt(pinnedRead.data)).toBe(2n); + + // Write via pinned 0.1.0, read via latest 0.2.0: one counter. + await rawCallTx(api, signer, proxyA, encodeVersionedCall(KEY_0_1_0, INCREMENT)); + expect(await countOf(proxyA)).toBe(3n); + }); +}); diff --git a/src/templates/foundry-counter/contracts/CounterA.sol b/src/templates/foundry-counter/contracts/CounterA.sol index bb452c8..d65b609 100644 --- a/src/templates/foundry-counter/contracts/CounterA.sol +++ b/src/templates/foundry-counter/contracts/CounterA.sol @@ -2,8 +2,7 @@ pragma solidity ^0.8.28; /// @notice Stores a counter that other contracts can increment through CDM. -/// @custom:cdm @example/counter-a -/// @custom:cdm-version 0.1.0 +/// @custom:cdm @example/counter-a:0.1.0 contract CounterA { uint256 public count; diff --git a/src/templates/foundry-counter/contracts/CounterB.sol b/src/templates/foundry-counter/contracts/CounterB.sol index a87a425..9b65f36 100644 --- a/src/templates/foundry-counter/contracts/CounterB.sol +++ b/src/templates/foundry-counter/contracts/CounterB.sol @@ -4,8 +4,7 @@ pragma solidity ^0.8.28; import "../.cdm/solidity/example/counter-a.sol"; /// @notice Demonstrates a Solidity contract calling CounterA through a generated CDM import. -/// @custom:cdm @example/counter-b -/// @custom:cdm-version 0.1.0 +/// @custom:cdm @example/counter-b:0.1.0 contract CounterB { uint256 public localCount; diff --git a/src/templates/foundry-counter/package.json b/src/templates/foundry-counter/package.json index 9fa6281..6233bc5 100644 --- a/src/templates/foundry-counter/package.json +++ b/src/templates/foundry-counter/package.json @@ -10,7 +10,7 @@ "url": "https://github.com/paritytech/contract-dependency-manager" }, "scripts": { - "build": "forge build --resolc", + "build": "forge build", "clean": "forge clean" } } diff --git a/src/templates/hardhat-counter/contracts/CounterA.sol b/src/templates/hardhat-counter/contracts/CounterA.sol index bb452c8..d65b609 100644 --- a/src/templates/hardhat-counter/contracts/CounterA.sol +++ b/src/templates/hardhat-counter/contracts/CounterA.sol @@ -2,8 +2,7 @@ pragma solidity ^0.8.28; /// @notice Stores a counter that other contracts can increment through CDM. -/// @custom:cdm @example/counter-a -/// @custom:cdm-version 0.1.0 +/// @custom:cdm @example/counter-a:0.1.0 contract CounterA { uint256 public count; diff --git a/src/templates/hardhat-counter/contracts/CounterB.sol b/src/templates/hardhat-counter/contracts/CounterB.sol index a87a425..9b65f36 100644 --- a/src/templates/hardhat-counter/contracts/CounterB.sol +++ b/src/templates/hardhat-counter/contracts/CounterB.sol @@ -4,8 +4,7 @@ pragma solidity ^0.8.28; import "../.cdm/solidity/example/counter-a.sol"; /// @notice Demonstrates a Solidity contract calling CounterA through a generated CDM import. -/// @custom:cdm @example/counter-b -/// @custom:cdm-version 0.1.0 +/// @custom:cdm @example/counter-b:0.1.0 contract CounterB { uint256 public localCount; diff --git a/src/templates/hardhat-counter/hardhat.config.ts b/src/templates/hardhat-counter/hardhat.config.ts index e3604e1..a00c464 100644 --- a/src/templates/hardhat-counter/hardhat.config.ts +++ b/src/templates/hardhat-counter/hardhat.config.ts @@ -13,7 +13,9 @@ const config: HardhatUserConfig = { }, networks: { hardhat: { - polkadot: true, + // target: "evm" keeps compilation on upstream solc (EVM bytecode + // for pallet-revive's EVM backend) while the node stays polkadot. + polkadot: { target: "evm" }, nodeConfig: { nodeBinaryPath: process.env.ANVIL_POLKADOT_BINARY ?? "./bin/anvil-polkadot", }, diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index a844772..ab7a3b7 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -16,5 +16,14 @@ export default defineConfig({ testTimeout: 60_000, hookTimeout: 300_000, fileParallelism: false, + server: { + deps: { + // Same as vitest.config.ts: resolve workspace package dists + // like node instead of letting vite inline and re-transform + // their code-split tsup chunks (which breaks root-entry + // exports like @parity/cdm-builder's `deployContracts`). + external: [/src\/lib\/(contracts|env|utils)\/dist\//], + }, + }, }, });