diff --git a/src/__tests__/cairo.client.starknet.test.ts b/src/__tests__/cairo.client.starknet.test.ts new file mode 100644 index 0000000..2a6c181 --- /dev/null +++ b/src/__tests__/cairo.client.starknet.test.ts @@ -0,0 +1,319 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { + createStarknetCampaignClient, + type StarknetDeps, +} from "../services/cairo/campaignFactory.starknet" + +// Minimal env that satisfies all required vars +const REQUIRED_ENV: Record = { + CAIRO_RPC_URL: "http://localhost:5050", + CAIRO_ACCOUNT_ADDRESS: "0xaccount", + CAIRO_PRIVATE_KEY: "0xprivkey", + CAIRO_FACTORY_CONTRACT_ADDRESS: "0xfactory", +} + +function setEnv(overrides: Record) { + const prev: Record = {} + for (const [k, v] of Object.entries(overrides)) { + prev[k] = process.env[k] + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + return prev +} + +function restoreEnv(prev: Record) { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } +} + +function makeDeps(overrides: Partial = {}): StarknetDeps { + return { + provider: { + getClassHashAt: async () => "0xclass", + waitForTransaction: async () => ({ events: [] }), + }, + account: { + execute: async () => ({ transaction_hash: "0xdeadbeef" }), + }, + ...overrides, + } +} + +// --- Missing required env var tests --- + +test("createStarknetCampaignClient throws when CAIRO_RPC_URL is missing", () => { + const prev = setEnv({ ...REQUIRED_ENV, CAIRO_RPC_URL: undefined }) + try { + assert.throws(() => createStarknetCampaignClient(), { + message: "Missing required env var: CAIRO_RPC_URL", + }) + } finally { + restoreEnv(prev) + } +}) + +test("createStarknetCampaignClient throws when CAIRO_ACCOUNT_ADDRESS is missing", () => { + const prev = setEnv({ ...REQUIRED_ENV, CAIRO_ACCOUNT_ADDRESS: undefined }) + try { + assert.throws(() => createStarknetCampaignClient(), { + message: "Missing required env var: CAIRO_ACCOUNT_ADDRESS", + }) + } finally { + restoreEnv(prev) + } +}) + +test("createStarknetCampaignClient throws when CAIRO_PRIVATE_KEY is missing", () => { + const prev = setEnv({ ...REQUIRED_ENV, CAIRO_PRIVATE_KEY: undefined }) + try { + assert.throws(() => createStarknetCampaignClient(), { + message: "Missing required env var: CAIRO_PRIVATE_KEY", + }) + } finally { + restoreEnv(prev) + } +}) + +test("createStarknetCampaignClient throws when CAIRO_FACTORY_CONTRACT_ADDRESS is missing", () => { + const prev = setEnv({ ...REQUIRED_ENV, CAIRO_FACTORY_CONTRACT_ADDRESS: undefined }) + try { + assert.throws(() => createStarknetCampaignClient(), { + message: "Missing required env var: CAIRO_FACTORY_CONTRACT_ADDRESS", + }) + } finally { + restoreEnv(prev) + } +}) + +// --- Transaction hash extraction --- + +test("createCampaign throws when execute returns no transaction hash", async () => { + const prev = setEnv({ ...REQUIRED_ENV, CAMPAIGN_CREATED_EVENT_KEY: undefined }) + try { + const deps = makeDeps({ + account: { execute: async () => ({}) }, + }) + const client = createStarknetCampaignClient(deps) + await assert.rejects( + () => client.createCampaign({ campaignRef: "ABCDE", targetAmount: "1", donationToken: "0xtoken" }), + { message: "Contract call did not return a transaction hash" } + ) + } finally { + restoreEnv(prev) + } +}) + +test("createCampaign accepts transactionHash as fallback field name", async () => { + const prev = setEnv({ ...REQUIRED_ENV, CAMPAIGN_CREATED_EVENT_KEY: undefined }) + try { + const deps = makeDeps({ + account: { execute: async () => ({ transactionHash: "0xfallback" }) }, + }) + const client = createStarknetCampaignClient(deps) + const result = await client.createCampaign({ + campaignRef: "ABCDE", + targetAmount: "1", + donationToken: "0xtoken", + }) + assert.equal(result.transactionHash, "0xfallback") + } finally { + restoreEnv(prev) + } +}) + +// --- Campaign ID parsing --- + +test("createCampaign uses transaction hash as campaignId when CAMPAIGN_CREATED_EVENT_KEY is not set", async () => { + const prev = setEnv({ ...REQUIRED_ENV, CAMPAIGN_CREATED_EVENT_KEY: undefined }) + try { + const deps = makeDeps() + const client = createStarknetCampaignClient(deps) + const result = await client.createCampaign({ + campaignRef: "ABCDE", + targetAmount: "1", + donationToken: "0xtoken", + }) + assert.equal(result.campaignId, result.transactionHash) + } finally { + restoreEnv(prev) + } +}) + +test("createCampaign extracts campaignId from matching event data when key is configured", async () => { + const prev = setEnv({ + ...REQUIRED_ENV, + CAMPAIGN_CREATED_EVENT_KEY: "0xEventKey", + }) + try { + const deps = makeDeps({ + provider: { + getClassHashAt: async () => "0xclass", + waitForTransaction: async () => ({ + events: [ + { keys: ["0xeventkey"], data: ["0xcampaign123"] }, + ], + }), + }, + }) + const client = createStarknetCampaignClient(deps) + const result = await client.createCampaign({ + campaignRef: "ABCDE", + targetAmount: "1", + donationToken: "0xtoken", + }) + assert.equal(result.campaignId, "0xcampaign123") + } finally { + restoreEnv(prev) + } +}) + +test("createCampaign falls back to transactionHash when event key does not match any event", async () => { + const prev = setEnv({ + ...REQUIRED_ENV, + CAMPAIGN_CREATED_EVENT_KEY: "0xMissingKey", + }) + try { + const deps = makeDeps({ + provider: { + getClassHashAt: async () => "0xclass", + waitForTransaction: async () => ({ + events: [{ keys: ["0xdifferentkey"], data: ["0xcampaign123"] }], + }), + }, + }) + const client = createStarknetCampaignClient(deps) + const result = await client.createCampaign({ + campaignRef: "ABCDE", + targetAmount: "1", + donationToken: "0xtoken", + }) + assert.equal(result.campaignId, result.transactionHash) + } finally { + restoreEnv(prev) + } +}) + +test("parseEventCampaignId matching is case-insensitive", async () => { + const prev = setEnv({ + ...REQUIRED_ENV, + CAMPAIGN_CREATED_EVENT_KEY: "0XUPPERCASEKEY", + }) + try { + const deps = makeDeps({ + provider: { + getClassHashAt: async () => "0xclass", + waitForTransaction: async () => ({ + events: [{ keys: ["0xuppercasekey"], data: ["0xcampaignid"] }], + }), + }, + }) + const client = createStarknetCampaignClient(deps) + const result = await client.createCampaign({ + campaignRef: "ABCDE", + targetAmount: "1", + donationToken: "0xtoken", + }) + assert.equal(result.campaignId, "0xcampaignid") + } finally { + restoreEnv(prev) + } +}) + +test("createCampaign falls back to transactionHash when matched event has no data", async () => { + const prev = setEnv({ + ...REQUIRED_ENV, + CAMPAIGN_CREATED_EVENT_KEY: "0xeventkey", + }) + try { + const deps = makeDeps({ + provider: { + getClassHashAt: async () => "0xclass", + waitForTransaction: async () => ({ + events: [{ keys: ["0xeventkey"], data: [] }], + }), + }, + }) + const client = createStarknetCampaignClient(deps) + const result = await client.createCampaign({ + campaignRef: "ABCDE", + targetAmount: "1", + donationToken: "0xtoken", + }) + assert.equal(result.campaignId, result.transactionHash) + } finally { + restoreEnv(prev) + } +}) + +// --- Retry behavior --- + +test("assertContractAccessible retries on provider failure and resolves on eventual success", async () => { + const prev = setEnv(REQUIRED_ENV) + try { + let calls = 0 + const deps = makeDeps({ + provider: { + getClassHashAt: async () => { + calls++ + if (calls < 3) throw new Error("RPC error") + return "0xclass" + }, + waitForTransaction: async () => ({ events: [] }), + }, + }) + const client = createStarknetCampaignClient(deps) + await client.assertContractAccessible("0xaddr") + assert.equal(calls, 3) + } finally { + restoreEnv(prev) + } +}) + +test("createCampaign calls execute exactly once and does not retry on failure", async () => { + // account.execute is state-mutating; retrying it could duplicate the on-chain transaction + const prev = setEnv({ ...REQUIRED_ENV, CAMPAIGN_CREATED_EVENT_KEY: undefined }) + try { + let calls = 0 + const deps = makeDeps({ + account: { + execute: async () => { + calls++ + throw new Error("RPC failure") + }, + }, + }) + const client = createStarknetCampaignClient(deps) + await assert.rejects( + () => client.createCampaign({ campaignRef: "ABCDE", targetAmount: "1", donationToken: "0xtoken" }), + { message: "RPC failure" } + ) + assert.equal(calls, 1, "execute must not be retried") + } finally { + restoreEnv(prev) + } +}) + +test("createCampaign propagates execute error immediately without retrying", async () => { + const prev = setEnv({ ...REQUIRED_ENV, CAMPAIGN_CREATED_EVENT_KEY: undefined }) + try { + const deps = makeDeps({ + account: { + execute: async () => { + throw new Error("node rejected transaction") + }, + }, + }) + const client = createStarknetCampaignClient(deps) + await assert.rejects( + () => client.createCampaign({ campaignRef: "ABCDE", targetAmount: "1", donationToken: "0xtoken" }), + { message: "node rejected transaction" } + ) + } finally { + restoreEnv(prev) + } +}) diff --git a/src/services/cairo/campaignFactory.starknet.ts b/src/services/cairo/campaignFactory.starknet.ts index bbc38db..4402c84 100644 --- a/src/services/cairo/campaignFactory.starknet.ts +++ b/src/services/cairo/campaignFactory.starknet.ts @@ -1,23 +1,49 @@ import { env } from "process" import { Account, RpcProvider, shortString } from "starknet" +import type { Call } from "starknet" import logger from "../../utils/logger" import { withRetry } from "./retry" import { toU256Parts } from "../../components/v1/campaign/campaign.validation" import type { CairoCampaignClient } from "./campaignFactory.client" +type StarknetEvent = { + keys?: string[] + data?: string[] +} + +type StarknetReceipt = { + events?: StarknetEvent[] +} + +type ExecuteResult = { + transaction_hash?: string + transactionHash?: string +} + +// Narrow provider/account interface used internally — allows injection in tests without live RPC +export type StarknetDeps = { + provider: { + getClassHashAt(_address: string): Promise + waitForTransaction(_txHash: string): Promise + } + account: { + execute(_invocation: Call): Promise + } +} + const getRequiredEnv = (key: string) => { const value = env[key] if (!value) throw new Error(`Missing required env var: ${key}`) return value } -const parseEventCampaignId = (receipt: any): string | null => { +const parseEventCampaignId = (receipt: StarknetReceipt): string | null => { const eventKey = env.CAMPAIGN_CREATED_EVENT_KEY if (!eventKey) return null const normalizedKey = eventKey.toLowerCase() - const events: any[] = receipt?.events ?? [] + const events: StarknetEvent[] = receipt?.events ?? [] const match = events.find((e) => (e.keys ?? []).map((k: string) => k.toLowerCase()).includes(normalizedKey)) if (!match) return null @@ -25,14 +51,21 @@ const parseEventCampaignId = (receipt: any): string | null => { return typeof data0 === "string" && data0.length ? data0 : null } -export const createStarknetCampaignClient = (): CairoCampaignClient => { +export const createStarknetCampaignClient = (_deps?: StarknetDeps): CairoCampaignClient => { const rpcUrl = getRequiredEnv("CAIRO_RPC_URL") const accountAddress = getRequiredEnv("CAIRO_ACCOUNT_ADDRESS") const privateKey = getRequiredEnv("CAIRO_PRIVATE_KEY") const factoryAddress = getRequiredEnv("CAIRO_FACTORY_CONTRACT_ADDRESS") - const provider = new RpcProvider({ nodeUrl: rpcUrl }) - const account = new Account({ provider, address: accountAddress, signer: privateKey }) + const provider: StarknetDeps["provider"] = + _deps?.provider ?? (new RpcProvider({ nodeUrl: rpcUrl }) as unknown as StarknetDeps["provider"]) + const account: StarknetDeps["account"] = + _deps?.account ?? + (new Account({ + provider: provider as unknown as RpcProvider, + address: accountAddress, + signer: privateKey, + }) as unknown as StarknetDeps["account"]) return { assertContractAccessible: async (address: string) => { @@ -51,18 +84,17 @@ export const createStarknetCampaignClient = (): CairoCampaignClient => { const campaignRefFelt = shortString.encodeShortString(campaignRef) const { low, high } = toU256Parts(targetAmount) - const invocation = { + const invocation: Call = { contractAddress: factoryAddress, entrypoint: "create_campaign", calldata: [campaignRefFelt, low, high, donationToken], } - const result = await withRetry(async () => { - logger.info(`Submitting create_campaign tx for ref=${campaignRef}`) - return await account.execute(invocation as any) - }) + logger.info(`Submitting create_campaign tx for ref=${campaignRef}`) + // Not wrapped in withRetry: retrying a state-changing execute can duplicate the transaction + const result: ExecuteResult = await account.execute(invocation) - const transactionHash = (result as any)?.transaction_hash ?? (result as any)?.transactionHash + const transactionHash = result?.transaction_hash ?? result?.transactionHash if (!transactionHash) { throw new Error("Contract call did not return a transaction hash") } @@ -74,4 +106,3 @@ export const createStarknetCampaignClient = (): CairoCampaignClient => { }, } } -