diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index c0c719b..fff4752 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -7,7 +7,7 @@ on: branches: ["*"] env: - DEVNET_VERSION: "0.7.2" + DEVNET_VERSION: "0.8.1" DEVNET_DIR: "/tmp/devnet-ci-storage" DEVNET_PATH: "/tmp/devnet-ci-storage/starknet-devnet" FORKED_DEVNET_PORT: 5051 diff --git a/README.md b/README.md index ce0ff57..31e4c22 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ npm i starknet-devnet ## Devnet compatibility -This library version is compatible with Devnet `v0.7.2`. +This library version is compatible with Devnet `v0.8.1`. [Devnet's balance checking functionality](https://0xspaceshard.github.io/starknet-devnet/docs/balance#check-balance) is not provided in this library because it is simply replaceable using starknet.js, as witnessed by the [getAccountBalance](./test/util.ts#L61) function. @@ -157,6 +157,50 @@ const block = await starknetProvider.getBlock("latest"); Assuming there is an L1 provider running (e.g. [anvil](https://github.com/foundry-rs/foundry/tree/master/crates/anvil)), use the `postman` property of `DevnetProvider` to achieve [L1-L2 communication](https://0xspaceshard.github.io/starknet-devnet/docs/postman). See [this example](https://github.com/0xSpaceShard/starknet-devnet-js/blob/master/test/l1-l2-postman.test.ts) for more info. +## Transaction Proofs + +Use the `proofs` property of `DevnetProvider` to prove INVOKE v3 transactions. This is useful for testing proof-aware flows in your application. See the [Devnet proofs documentation](https://0xspaceshard.github.io/starknet-devnet/docs/proofs) for more details on proof modes. + +**Note:** Before calling `proveTransaction`, Devnet requires at least 10 blocks to exist. Start Devnet with `--proof-mode devnet` to enable this feature. + +```typescript +import { Devnet } from "starknet-devnet"; +import * as starknet from "starknet"; + +const devnet = await Devnet.spawnInstalled({ args: ["--proof-mode", "devnet"] }); + +// ... ensure at least 10 blocks exist (e.g. via devnet.provider.createBlock()) ... + +// Build an INVOKE v3 transaction payload +const invokeTx = { + type: "INVOKE" as const, + version: "0x3" as const, + sender_address: account.address, + calldata: ["0x1", "0x2"], + signature: ["0x...", "0x..."], + nonce: "0x0", + resource_bounds: { + l1_gas: { max_amount: "0x100", max_price_per_unit: "0x100" }, + l1_data_gas: { max_amount: "0x100", max_price_per_unit: "0x100" }, + l2_gas: { max_amount: "0x100", max_price_per_unit: "0x100" }, + }, + tip: "0x0", + paymaster_data: [], + account_deployment_data: [], + nonce_data_availability_mode: "L1" as const, + fee_data_availability_mode: "L1" as const, +}; + +// Prove the transaction +const proofResult = await devnet.provider.proofs.proveTransaction("latest", invokeTx); + +console.log(proofResult.proof); // Base64-encoded mock proof +console.log(proofResult.proof_facts); // Array of 9 hex strings +console.log(proofResult.l2_to_l1_messages); // L2 to L1 messages from simulation +``` + +See [this example](https://github.com/0xSpaceShard/starknet-devnet-js/blob/master/test/proofs.test.ts) for a complete test with transaction building and signing. + ## Configuration modification and retrieval Devnet's configuration can be modified, other than [on startup (as already described)](#specify-devnet-arguments), via `setGasPrice`. It can be retrieved via `getConfig`. diff --git a/package-lock.json b/package-lock.json index 7602bf3..ee79b48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "starknet-devnet", - "version": "0.7.2", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "starknet-devnet", - "version": "0.7.2", + "version": "0.8.1", "license": "MIT", "dependencies": { "axios": "^1.7.4", @@ -421,7 +421,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.7.tgz", "integrity": "sha512-uTr2m2IbJJucF3KUxgnGOZvYbN0QgkGyWxG6973HCpMYFy2KfcgYuIwkJQMQkt1VbBMlvWRbpshFTLxnxCZjKQ==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -689,7 +688,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1469,7 +1467,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3676,7 +3673,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index dce2a8b..a1a3c90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "starknet-devnet", - "version": "0.7.2", + "version": "0.8.1", "description": "Starknet Devnet provider", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/devnet-provider.ts b/src/devnet-provider.ts index b9ac28a..aa877dc 100644 --- a/src/devnet-provider.ts +++ b/src/devnet-provider.ts @@ -1,6 +1,7 @@ import axios, { AxiosInstance } from "axios"; import { Postman } from "./postman"; import { Cheats } from "./cheats"; +import { Proofs } from "./proofs"; import { RpcProvider } from "./rpc-provider"; import { BalanceUnit, BlockId, PredeployedAccount, toRpcBlockId } from "./types"; import { DEFAULT_DEVNET_URL, DEFAULT_HTTP_TIMEOUT } from "./constants"; @@ -52,6 +53,9 @@ export class DevnetProvider { /** Contains methods for cheating, e.g. account impersonation. */ public readonly cheats: Cheats; + /** Contains methods for transaction proofs. */ + public readonly proofs: Proofs; + public constructor(config?: DevnetProviderConfig) { this.url = config?.url || DEFAULT_DEVNET_URL; this.httpProvider = axios.create({ @@ -61,6 +65,7 @@ export class DevnetProvider { this.rpcProvider = new RpcProvider(this.httpProvider, this.url); this.postman = new Postman(this.rpcProvider); this.cheats = new Cheats(this.rpcProvider); + this.proofs = new Proofs(this.rpcProvider); } /** diff --git a/src/index.ts b/src/index.ts index dc103d6..ec85e94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from "./devnet"; export * from "./devnet-provider"; export * from "./types"; +export * from "./proofs"; diff --git a/src/proofs.ts b/src/proofs.ts new file mode 100644 index 0000000..99d821e --- /dev/null +++ b/src/proofs.ts @@ -0,0 +1,100 @@ +import { L2ToL1Message } from "./postman"; +import { RpcProvider } from "./rpc-provider"; +import { BlockId, toRpcBlockId } from "./types"; + +/** + * Resource bounds for a transaction (gas/data gas). + * Compatible with starknet.js RPC.RESOURCE_BOUNDS type. + */ +export interface ResourceBounds { + max_amount: string; + max_price_per_unit: string; +} + +/** + * Full resource bounds for INVOKE v3 transaction. + * Compatible with starknet.js RPC.RESOURCE_BOUNDS_MAPPING type. + */ +export interface ResourceBoundsMapping { + l1_gas: ResourceBounds; + l1_data_gas: ResourceBounds; + l2_gas: ResourceBounds; +} + +export type DataAvailabilityMode = "L1" | "L2"; + +/** + * INVOKE v3 transaction payload. + * Compatible with starknet.js RPC.INVOKE_TXN_V3 type. + * https://0xspaceshard.github.io/starknet-devnet/docs/proofs + */ +export interface InvokeV3Transaction { + type: "INVOKE"; + version: "0x3"; + sender_address: string; + calldata: string[]; + signature: string[]; + nonce: string; + resource_bounds: ResourceBoundsMapping; + tip: string; + paymaster_data: string[]; + account_deployment_data: string[]; + nonce_data_availability_mode: DataAvailabilityMode; + fee_data_availability_mode: DataAvailabilityMode; +} + +/** + * L2 to L1 message included in the proof response, ordered by emission. + */ +export interface ProofL2ToL1Message extends L2ToL1Message { + order: number; +} + +/** + * Response from `starknet_proveTransaction` + * https://0xspaceshard.github.io/starknet-devnet/docs/proofs + */ +export interface ProveTransactionResponse { + /** Base64-encoded mock proof */ + proof: string; + /** Array of 9 hex strings (includes messages_hash in devnet mode) */ + proof_facts: string[]; + /** L2 to L1 messages extracted by simulating the transaction */ + l2_to_l1_messages: ProofL2ToL1Message[]; +} + +/** + * Transaction proofs handler for Devnet. + * + * This covers `starknet_proveTransaction`, a Devnet extension for proving/validating + * `INVOKE v3` transaction payloads. For configuration (proof modes), see: + * https://0xspaceshard.github.io/starknet-devnet/docs/proofs + */ +export class Proofs { + constructor(private rpcProvider: RpcProvider) {} + + /** + * Prove an INVOKE v3 transaction payload. + * + * Returns a deterministic mock proof and proof facts in `devnet` proof mode (default). + * Returns an error in `none` mode (disabled) or `full` mode (not implemented). + * + * If the transaction simulation fails (e.g. execution reverts), an error is returned + * instead of a proof. + * + * https://0xspaceshard.github.io/starknet-devnet/docs/proofs + * + * @param blockId the block context for proving (e.g. "latest", block number, or block hash) + * @param transaction the INVOKE v3 transaction payload to prove + * @returns proof data including the proof, proof_facts, and l2_to_l1_messages + */ + public async proveTransaction( + blockId: BlockId, + transaction: InvokeV3Transaction, + ): Promise { + return await this.rpcProvider.sendRequest("starknet_proveTransaction", { + block_id: toRpcBlockId(blockId), + transaction, + }); + } +} diff --git a/test/proofs.test.ts b/test/proofs.test.ts new file mode 100644 index 0000000..17183fe --- /dev/null +++ b/test/proofs.test.ts @@ -0,0 +1,247 @@ +import { expect } from "chai"; +import * as starknet from "starknet"; +import { Devnet, InvokeV3Transaction } from ".."; +import { getContractArtifact, getEnvVar, getPredeployedAccount } from "./util"; +import { SIMPLE_CONTRACT_PATH, SIMPLE_CONTRACT_CASM_HASH } from "./constants"; + +describe("Transaction proofs", function () { + this.timeout(60_000); // ms + + let devnet: Devnet; + let starknetProvider: starknet.RpcProvider; + let account: starknet.Account; + let contract: starknet.Contract; + + before("Set up Devnet with proof mode and deploy test contract", async function () { + devnet = await Devnet.spawnCommand(getEnvVar("DEVNET_PATH"), { + args: ["--proof-mode", "devnet"], + }); + starknetProvider = new starknet.RpcProvider({ nodeUrl: devnet.provider.url }); + account = await getPredeployedAccount(devnet.provider, starknetProvider); + + // Deploy the simple contract for testing + const contractArtifact = getContractArtifact(SIMPLE_CONTRACT_PATH); + const deployment = await account.declareAndDeploy({ + contract: contractArtifact, + compiledClassHash: SIMPLE_CONTRACT_CASM_HASH, + constructorCalldata: { initial_balance: 100 }, + }); + + contract = new starknet.Contract({ + abi: contractArtifact.abi, + address: deployment.deploy.contract_address, + providerOrAccount: account, + }); + + // Devnet's proof mode requires at least 10 blocks to exist before proving. + for (let i = 0; i < 10; i++) { + await devnet.provider.createBlock(); + } + }); + + after("close devnet", async function () { + devnet?.kill(); + }); + + it("should prove a transaction and return proof data", async function () { + // Build an invoke call + const call = contract.populate("increase_balance", [50, 0]); + + // Get account nonce + const nonce = await account.getNonce(); + + // Build the invoke transaction payload + const invokeTx = await buildInvokeV3Transaction(account, [call], nonce); + + // Prove the transaction + const proofResult = await devnet.provider.proofs.proveTransaction("latest", invokeTx); + + // Verify proof response structure + expect(proofResult).to.have.property("proof"); + expect(proofResult).to.have.property("proof_facts"); + expect(proofResult).to.have.property("l2_to_l1_messages"); + + expect(proofResult.proof).to.be.a("string"); + expect(proofResult.proof.length).to.be.greaterThan(0); + + expect(proofResult.proof_facts).to.be.an("array"); + expect(proofResult.proof_facts.length).to.equal(9); // devnet mode returns 9 elements + + expect(proofResult.l2_to_l1_messages).to.be.an("array"); + }); + + it("should prove and then execute a transaction successfully", async function () { + const initialBalance = await contract.get_balance(); + const incrementAmount = 25n; + + // Build an invoke call + const call = contract.populate("increase_balance", [incrementAmount, 0]); + + // Get account nonce + const nonce = await account.getNonce(); + + // Build the invoke transaction payload + const invokeTx = await buildInvokeV3Transaction(account, [call], nonce); + + // First, prove the transaction + const proofResult = await devnet.provider.proofs.proveTransaction("latest", invokeTx); + + expect(proofResult.proof).to.be.a("string"); + expect(proofResult.proof_facts).to.have.lengthOf(9); + + // Now execute the same transaction + const { transaction_hash } = await account.execute([call]); + const receipt = await starknetProvider.waitForTransaction(transaction_hash); + + expect(receipt.isSuccess()).to.be.true; + + // Verify the state changed + const newBalance = await contract.get_balance(); + expect(newBalance).to.equal(initialBalance + incrementAmount); + }); + + it("should fail to prove a transaction that would revert", async function () { + const badCall: starknet.Call = { + contractAddress: contract.address, + entrypoint: "nonexistent_function", + calldata: [], + }; + + // Estimation would itself fail for a reverting call, so hand-pick bounds + // to ensure the failure surfaces from proveTransaction and not estimateFee. + const fixedBounds: starknet.ResourceBoundsBN = { + l1_gas: { max_amount: 0x1000n, max_price_per_unit: 0x1000n }, + l1_data_gas: { max_amount: 0x1000n, max_price_per_unit: 0x1000n }, + l2_gas: { max_amount: 0x100000000n, max_price_per_unit: 0x1000n }, + }; + + const nonce = await account.getNonce(); + const invokeTx = await buildInvokeV3Transaction(account, [badCall], nonce, fixedBounds); + + try { + await devnet.provider.proofs.proveTransaction("latest", invokeTx); + expect.fail("Should have thrown an error for reverting transaction"); + } catch (err) { + // rpc-provider throws the raw JSON-RPC error object: { code, message, ... } + const rpcErr = err as { code?: unknown; message?: unknown }; + expect(rpcErr.code, `unexpected error shape: ${JSON.stringify(err)}`).to.be.a("number"); + expect(rpcErr.message).to.be.a("string").and.not.empty; + } + }); + + it("should prove multiple transactions in sequence", async function () { + const call1 = contract.populate("increase_balance", [10, 0]); + const call2 = contract.populate("increase_balance", [20, 0]); + + // Get current nonce + let nonce = await account.getNonce(); + + // Prove first transaction + const invokeTx1 = await buildInvokeV3Transaction(account, [call1], nonce); + const proof1 = await devnet.provider.proofs.proveTransaction("latest", invokeTx1); + expect(proof1.proof_facts).to.have.lengthOf(9); + + // Execute first transaction to advance nonce + await account.execute([call1]); + + // Get new nonce + nonce = await account.getNonce(); + + // Prove second transaction with updated nonce + const invokeTx2 = await buildInvokeV3Transaction(account, [call2], nonce); + const proof2 = await devnet.provider.proofs.proveTransaction("latest", invokeTx2); + expect(proof2.proof_facts).to.have.lengthOf(9); + + // Proofs should be different (different transactions) + expect(proof1.proof).to.not.equal(proof2.proof); + }); +}); + +/** + * Build an INVOKE v3 transaction payload compatible with starknet_proveTransaction. + */ +async function buildInvokeV3Transaction( + account: starknet.Account, + calls: starknet.Call[], + nonce: string | bigint, + overrideBounds?: starknet.ResourceBoundsBN, +): Promise { + // Compile the calldata + const calldata = starknet.transaction.getExecuteCalldata(calls, account.cairoVersion); + + // Use provided bounds, or estimate them. Estimation will fail for reverting + // calls, so pass `overrideBounds` when the test deliberately submits one. + const bounds = overrideBounds ?? (await account.estimateInvokeFee(calls)).resourceBounds; + + const resourceBounds = { + l1_gas: { + max_amount: toHex(bounds.l1_gas.max_amount), + max_price_per_unit: toHex(bounds.l1_gas.max_price_per_unit), + }, + l1_data_gas: { + max_amount: toHex(bounds.l1_data_gas.max_amount), + max_price_per_unit: toHex(bounds.l1_data_gas.max_price_per_unit), + }, + l2_gas: { + max_amount: toHex(bounds.l2_gas.max_amount), + max_price_per_unit: toHex(bounds.l2_gas.max_price_per_unit), + }, + }; + + // Build the unsigned transaction + const unsignedTx: Omit = { + type: "INVOKE", + version: "0x3", + sender_address: account.address, + calldata: calldata.map((c) => toHex(c)), + nonce: toHex(nonce), + resource_bounds: resourceBounds, + tip: "0x0", + paymaster_data: [], + account_deployment_data: [], + nonce_data_availability_mode: "L1", + fee_data_availability_mode: "L1", + }; + + // Sign the transaction using the account's signer + const chainId = await account.getChainId(); + + const signerDetails: starknet.V3InvocationsSignerDetails = { + walletAddress: account.address, + chainId, + cairoVersion: account.cairoVersion, + nonce: BigInt(nonce.toString()), + version: "0x3", + resourceBounds: bounds, + tip: 0n, + paymasterData: [], + accountDeploymentData: [], + nonceDataAvailabilityMode: starknet.EDataAvailabilityMode.L1, + feeDataAvailabilityMode: starknet.EDataAvailabilityMode.L1, + }; + + const signature = await account.signer.signTransaction(calls, signerDetails); + + // Convert signature to array of hex strings + // starknet.js Signature can be ArraySignatureType (string[]) or WeierstrassSignatureType + let signatureStrings: string[]; + if (Array.isArray(signature)) { + signatureStrings = signature.map((s) => (typeof s === "string" ? s : toHex(s))); + } else { + // WeierstrassSignatureType has r and s properties + const sig = signature as { r: bigint; s: bigint }; + signatureStrings = [toHex(sig.r), toHex(sig.s)]; + } + + return { + ...unsignedTx, + signature: signatureStrings, + }; +} + +function toHex(value: string | number | bigint): string { + if (typeof value === "string" && value.startsWith("0x")) { + return value; // Already a hex string + } + return "0x" + BigInt(value).toString(16); +} diff --git a/test/util.ts b/test/util.ts index e31baf3..6c2755b 100644 --- a/test/util.ts +++ b/test/util.ts @@ -61,12 +61,20 @@ export async function getAccountBalance( ): Promise { const tokenContractAddress = config.tokenContractAddress ?? ETH_TOKEN_CONTRACT_ADDRESS; const blockIdentifier = config.blockIdentifier ?? starknet.BlockTag.PRE_CONFIRMED; - const tokenClass = await provider.getClassAt(tokenContractAddress, blockIdentifier); - const tokenContract = new starknet.Contract({ - abi: tokenClass.abi, - address: tokenContractAddress, - providerOrAccount: provider, - }); - return tokenContract.withOptions({ blockIdentifier }).balanceOf(accountAddress); + // Call balanceOf via low-level callContract instead of Contract + abi, because + // Devnet's predeployed token classes expose their abi as the literal string "null", + // which breaks starknet.Contract's abi parser. + const result = await provider.callContract( + { + contractAddress: tokenContractAddress, + entrypoint: "balance_of", + calldata: [accountAddress], + }, + blockIdentifier, + ); + const felts = Array.isArray(result) ? result : (result as { result: string[] }).result; + const low = BigInt(felts[0]); + const high = BigInt(felts[1]); + return low + (high << 128n); }