diff --git a/.gitignore b/.gitignore index 51e78f1..f4b4587 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ e2e.log .bounties codex-resume .codex -.npmrc \ No newline at end of file +.npmrc +.agents/ diff --git a/packages/citizen-sdk/src/sdks/viem-claim-sdk.ts b/packages/citizen-sdk/src/sdks/viem-claim-sdk.ts index 413f411..d7c5a83 100644 --- a/packages/citizen-sdk/src/sdks/viem-claim-sdk.ts +++ b/packages/citizen-sdk/src/sdks/viem-claim-sdk.ts @@ -31,6 +31,10 @@ import { getRpcFallbackClient, shouldRetryRpcFallback, } from "../utils/rpcFallback" +import { + safeInvokeSubmittedCallback, + type TransactionSubmittedCallback, +} from "../utils/transactionCallbacks" export interface ClaimSDKOptions { account: Address @@ -73,6 +77,10 @@ export interface ClaimEntitlementResult { altAmount: bigint | null } +export type ClaimTxConfirmCallback = (message: string) => void | Promise + +export type ClaimTransactionSubmittedCallback = TransactionSubmittedCallback + type AltClaimCandidate = { chainId: SupportedChains amount: bigint @@ -348,13 +356,13 @@ export class ClaimSDK { /** * Submits a transaction and waits for its receipt. * @param params - Parameters for simulating the contract call. - * @param onHash - Optional callback to receive the transaction hash. + * @param onHash - Optional callback invoked after broadcast and before receipt confirmation. * @returns The transaction receipt. * @throws If submission fails or no active wallet address is found. */ async submitAndWait( params: SimulateContractParameters, - onHash?: (hash: `0x${string}`) => void, + onHash?: ClaimTransactionSubmittedCallback, ): Promise { if (!this.account) { throw new Error("No active wallet address found.") @@ -366,7 +374,11 @@ export class ClaimSDK { }) const hash = await this.walletClient.writeContract(request) - onHash?.(hash) + safeInvokeSubmittedCallback( + hash, + onHash, + "[ClaimSDK] onClaimSubmitted callback", + ) // Wait one block to prevent waitFor... from immediately throwing an error await new Promise((res) => setTimeout(res, 5000)) @@ -472,13 +484,15 @@ export class ClaimSDK { * 4. If whitelisted and can claim, checks if the user has sufficient balance. * 5. If the user cannot claim due to low balance, triggers a faucet request and waits. * 6. If whitelisted and can claim, proceeds to call the claim function on the UBIScheme contract. - * @param txConfirm - Optional callback to confirm transactions before execution. + * @param onConfirmFaucetTx - Optional callback to confirm faucet or retry actions during balance checks. + * @param onClaimSubmitted - Optional callback invoked after broadcast and before receipt confirmation. * @returns The transaction receipt if the claim is successful. * @throws If the user is not whitelisted, not entitled to claim, balance check fails, or claim transaction fails. */ async claim( - txConfirm?: (message: string) => void | Promise, - ): Promise { + onConfirmFaucetTx?: ClaimTxConfirmCallback, + onClaimSubmitted?: ClaimTransactionSubmittedCallback, + ): Promise { const userAddress = this.account // 1. Check whitelisting status @@ -496,19 +510,22 @@ export class ClaimSDK { } // 3. Ensure the user has sufficient balance to claim - const canClaim = await this.checkBalanceWithRetry(txConfirm) + const canClaim = await this.checkBalanceWithRetry(onConfirmFaucetTx) if (!canClaim) { throw new Error("Failed to meet balance threshold after faucet request.") } // 4. Execute the claim transaction try { - return await this.submitAndWait({ - address: this.ubiSchemeAddress, - abi: ubiSchemeV2ABI, - functionName: "claim", - chain: this.walletClient.chain, - }) + return await this.submitAndWait( + { + address: this.ubiSchemeAddress, + abi: ubiSchemeV2ABI, + functionName: "claim", + chain: this.walletClient.chain, + }, + onClaimSubmitted, + ) } catch (error: any) { if (error instanceof ContractFunctionExecutionError) { throw new Error(`Claim failed: ${error.shortMessage}`) diff --git a/packages/citizen-sdk/src/sdks/viem-custodial-claim-sdk.ts b/packages/citizen-sdk/src/sdks/viem-custodial-claim-sdk.ts index 51a16f4..44c1520 100644 --- a/packages/citizen-sdk/src/sdks/viem-custodial-claim-sdk.ts +++ b/packages/citizen-sdk/src/sdks/viem-custodial-claim-sdk.ts @@ -11,7 +11,12 @@ import { } from "viem" import { waitForTransactionReceipt } from "viem/actions" -import { ClaimSDK, type ClaimSDKOptions } from "./viem-claim-sdk" // Import the base ClaimSDK +import { + ClaimSDK, + type ClaimSDKOptions, + type ClaimTransactionSubmittedCallback, +} from "./viem-claim-sdk" // Import the base ClaimSDK +import { safeInvokeSubmittedCallback } from "../utils/transactionCallbacks" import type { WalletClaimStatus } from "../types" interface ClaimCustodialSDKOptions extends Omit { @@ -38,13 +43,13 @@ export class ClaimCustodialSDK extends ClaimSDK { * Override submitAndWait to handle LocalAccount signing for Celo RPC compatibility * Submits a transaction and waits for its receipt. * @param params - Parameters for simulating the contract call. - * @param onHash - Optional callback to receive the transaction hash. + * @param onHash - Optional callback invoked after broadcast and before receipt confirmation. * @returns The transaction receipt. * @throws If submission fails or no active wallet address is found. */ async submitAndWait( params: SimulateContractParameters, - onHash?: (hash: `0x${string}`) => void, + onHash?: ClaimTransactionSubmittedCallback, ): Promise { const account = this.walletClient.account if (!account?.address) { @@ -93,7 +98,11 @@ export class ClaimCustodialSDK extends ClaimSDK { hash = await this.walletClient.writeContract(request) } - onHash?.(hash) + safeInvokeSubmittedCallback( + hash, + onHash, + "[ClaimSDK] onClaimSubmitted callback", + ) // Wait one block to prevent immediate errors await new Promise((res) => setTimeout(res, 5000)) diff --git a/packages/citizen-sdk/src/utils/transactionCallbacks.ts b/packages/citizen-sdk/src/utils/transactionCallbacks.ts new file mode 100644 index 0000000..1bb6c9a --- /dev/null +++ b/packages/citizen-sdk/src/utils/transactionCallbacks.ts @@ -0,0 +1,25 @@ +export type TransactionSubmittedCallback = ( + hash: `0x${string}`, +) => void | Promise + +/** + * Invokes a submitted-transaction callback without awaiting it (so a slow or + * hanging callback cannot block confirmation) and logs callback failures without + * interrupting the transaction flow. + * @param hash - The submitted transaction hash. + * @param onSubmitted - Optional callback to notify after transaction broadcast. + * @param label - Log message prefix used when the callback throws. + */ +export function safeInvokeSubmittedCallback( + hash: `0x${string}`, + onSubmitted?: TransactionSubmittedCallback, + label = "Transaction submitted callback", +): void { + try { + Promise.resolve(onSubmitted?.(hash)).catch((error) => { + console.warn(`${label} failed`, error) + }) + } catch (error) { + console.warn(`${label} failed`, error) + } +} diff --git a/packages/citizen-sdk/test/claim-sdk.test.ts b/packages/citizen-sdk/test/claim-sdk.test.ts new file mode 100644 index 0000000..0b88e03 --- /dev/null +++ b/packages/citizen-sdk/test/claim-sdk.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type { PublicClient, TransactionReceipt, WalletClient } from "viem" +import { ClaimSDK } from "../src/sdks/viem-claim-sdk" +import { SupportedChains } from "../src/constants" + +vi.mock("viem/actions", () => ({ + waitForTransactionReceipt: vi.fn(async (_client, { hash }) => ({ + transactionHash: hash, + })), +})) + +const MOCK_ACCOUNT = "0x1111111111111111111111111111111111111111" +const MOCK_HASH = "0xdeadbeef" as `0x${string}` + +type MockPublicClient = Pick +type MockWalletClient = Pick< + WalletClient, + "account" | "chain" | "getAddresses" | "writeContract" +> + +describe("ClaimSDK claim callbacks", () => { + let publicClient: MockPublicClient + let walletClient: MockWalletClient + let sdk: ClaimSDK + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + beforeEach(() => { + publicClient = { + simulateContract: vi.fn().mockResolvedValue({ request: {} }), + } + + walletClient = { + account: { address: MOCK_ACCOUNT }, + chain: { id: SupportedChains.CELO }, + getAddresses: vi.fn().mockResolvedValue([MOCK_ACCOUNT]), + writeContract: vi.fn().mockResolvedValue(MOCK_HASH), + } + + sdk = new ClaimSDK({ + account: MOCK_ACCOUNT, + publicClient: publicClient as PublicClient, + walletClient: walletClient as WalletClient, + identitySDK: { + getWhitelistedRoot: vi.fn().mockResolvedValue({ + isWhitelisted: true, + root: MOCK_ACCOUNT, + }), + } as any, + env: "development", + }) + + vi.spyOn(sdk, "checkEntitlement").mockResolvedValue({ + amount: 1n, + altClaimAvailable: false, + altChainId: null, + altAmount: null, + }) + vi.spyOn(sdk, "checkBalanceWithRetry").mockResolvedValue(true) + }) + + it("calls onClaimSubmitted once after claim submission and before confirmation", async () => { + const events: string[] = [] + let confirmReceipt: (receipt: TransactionReceipt) => void = () => {} + const receiptPromise = new Promise((resolve) => { + confirmReceipt = resolve + }) + const onClaimSubmitted = vi.fn(() => { + events.push("submitted") + }) + vi.spyOn(sdk, "submitAndWait").mockImplementation( + async (_params, onSubmitted) => { + await onSubmitted?.(MOCK_HASH) + events.push("waiting") + return receiptPromise + }, + ) + + const claimPromise = sdk + .claim(undefined, onClaimSubmitted) + .then((receipt) => { + events.push("confirmed") + return receipt as TransactionReceipt + }) + + await vi.waitFor(() => { + expect(onClaimSubmitted).toHaveBeenCalledWith(MOCK_HASH) + }) + + expect(onClaimSubmitted).toHaveBeenCalledTimes(1) + expect(events).toEqual(["submitted", "waiting"]) + + confirmReceipt({ transactionHash: MOCK_HASH } as TransactionReceipt) + + const receipt = await claimPromise + expect(receipt.transactionHash).toBe(MOCK_HASH) + expect(events).toEqual(["submitted", "waiting", "confirmed"]) + }) + + it("invokes the callback inside submitAndWait before confirmation without blocking on it", async () => { + vi.useFakeTimers() + const { waitForTransactionReceipt } = await import("viem/actions") + const onSubmitted = vi.fn(() => new Promise(() => {})) + + const promise = sdk.submitAndWait({} as any, onSubmitted) + + await vi.waitFor(() => { + expect(onSubmitted).toHaveBeenCalledWith(MOCK_HASH) + }) + expect(waitForTransactionReceipt).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(5000) + await expect(promise).resolves.toMatchObject({ + transactionHash: MOCK_HASH, + }) + expect(onSubmitted).toHaveBeenCalledTimes(1) + }) + + it("swallows onClaimSubmitted errors so claim still resolves", async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + const onClaimSubmitted = vi + .fn() + .mockRejectedValue(new Error("ui callback failed")) + + const claimPromise = sdk.claim(undefined, onClaimSubmitted) + + await vi.waitFor(() => { + expect(onClaimSubmitted).toHaveBeenCalledTimes(1) + }) + + await vi.advanceTimersByTimeAsync(5000) + + await expect(claimPromise).resolves.toMatchObject({ + transactionHash: MOCK_HASH, + }) + expect(warnSpy).toHaveBeenCalledWith( + "[ClaimSDK] onClaimSubmitted callback failed", + expect.any(Error), + ) + }) + + it("supports onConfirmFaucetTx and onClaimSubmitted callbacks", async () => { + vi.useFakeTimers() + const onConfirmFaucetTx = vi.fn() + const onClaimSubmitted = vi.fn() + + const claimPromise = sdk.claim(onConfirmFaucetTx, onClaimSubmitted) + + await vi.waitFor(() => { + expect(onClaimSubmitted).toHaveBeenCalledWith(MOCK_HASH) + }) + + expect(sdk.checkBalanceWithRetry).toHaveBeenCalledWith(onConfirmFaucetTx) + + await vi.advanceTimersByTimeAsync(5000) + await claimPromise + }) + + it("preserves the positional txConfirm callback signature", async () => { + vi.useFakeTimers() + const txConfirm = vi.fn() + + const claimPromise = sdk.claim(txConfirm) + + await vi.waitFor(() => { + expect(sdk.checkBalanceWithRetry).toHaveBeenCalledWith(txConfirm) + }) + + await vi.advanceTimersByTimeAsync(5000) + await claimPromise + }) +})