Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ e2e.log
.bounties
codex-resume
.codex
.npmrc
.npmrc
.agents/
43 changes: 30 additions & 13 deletions packages/citizen-sdk/src/sdks/viem-claim-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {
getRpcFallbackClient,
shouldRetryRpcFallback,
} from "../utils/rpcFallback"
import {
safeInvokeSubmittedCallback,
type TransactionSubmittedCallback,
} from "../utils/transactionCallbacks"

export interface ClaimSDKOptions {
account: Address
Expand Down Expand Up @@ -73,6 +77,10 @@ export interface ClaimEntitlementResult {
altAmount: bigint | null
}

export type ClaimTxConfirmCallback = (message: string) => void | Promise<void>

export type ClaimTransactionSubmittedCallback = TransactionSubmittedCallback

type AltClaimCandidate = {
chainId: SupportedChains
amount: bigint
Expand Down Expand Up @@ -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<TransactionReceipt> {
if (!this.account) {
throw new Error("No active wallet address found.")
Expand All @@ -366,7 +374,11 @@ export class ClaimSDK {
})

const hash = await this.walletClient.writeContract(request)
onHash?.(hash)
Comment thread
sirpy marked this conversation as resolved.
Resolved
safeInvokeSubmittedCallback(
hash,
onHash,
"[ClaimSDK] onClaimSubmitted callback",
)

// Wait one block to prevent waitFor... from immediately throwing an error
await new Promise((res) => setTimeout(res, 5000))
Expand Down Expand Up @@ -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<void>,
): Promise<TransactionReceipt | any> {
onConfirmFaucetTx?: ClaimTxConfirmCallback,
onClaimSubmitted?: ClaimTransactionSubmittedCallback,
): Promise<TransactionReceipt> {
const userAddress = this.account

// 1. Check whitelisting status
Expand All @@ -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}`)
Expand Down
17 changes: 13 additions & 4 deletions packages/citizen-sdk/src/sdks/viem-custodial-claim-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClaimSDKOptions, 'account'> {
Expand All @@ -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<TransactionReceipt> {
const account = this.walletClient.account
if (!account?.address) {
Expand Down Expand Up @@ -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))
Expand Down
25 changes: 25 additions & 0 deletions packages/citizen-sdk/src/utils/transactionCallbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type TransactionSubmittedCallback = (
hash: `0x${string}`,
) => void | Promise<void>

/**
* 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)
}
}
176 changes: 176 additions & 0 deletions packages/citizen-sdk/test/claim-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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<PublicClient, "simulateContract">
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<TransactionReceipt>((resolve) => {
confirmReceipt = resolve
})
const onClaimSubmitted = vi.fn(() => {
events.push("submitted")
})
vi.spyOn(sdk, "submitAndWait").mockImplementation(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test mocks out submitAndWait and invokes the callback itself, so the real callback-before-confirmation ordering inside ClaimSDK.submitAndWait is never exercised. A regression there wouldn't fail the suite.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

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<void>(() => {}))

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
})
})
Loading