Skip to content
Closed
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
13 changes: 11 additions & 2 deletions packages/bridging-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"type": "module",
"scripts": {
"build": "tsup --clean",
"prepack": "yarn build",
"test": "vitest run",
"dev": "tsc --watch",
"bump": "yarn version patch && yarn build && git add package.json && git commit -m \"version bump\""
Expand All @@ -22,9 +23,17 @@
}
},
"files": [
"dist",
"src"
"dist"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/GoodDollar/GoodSDKs.git",
"directory": "packages/bridging-sdk"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"tsup": "^8.3.5",
Expand Down
4 changes: 2 additions & 2 deletions packages/citizen-sdk/README-ClaimSDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ For Wagmi-based React projects, use the hooks exposed from `@goodsdks/react-hook
- Resolves the wallet's current state: `not_whitelisted`, `already_claimed`, or `can_claim`.
- `claimSDK.nextClaimTime()`
- Computes when the wallet can claim again.
- `claimSDK.claim()`
- Sends the UBI claim transaction, handling faucet triggers when balances are low.
- `claimSDK.claim(onConfirmFaucetTx?, onClaimSubmitted?)`
- Sends the UBI claim transaction, handling faucet triggers when balances are low. `onClaimSubmitted` is called with the transaction hash immediately after broadcast and before receipt confirmation.
- `claimSDK.getDailyStats()`
- Fetches daily aggregate claimers and amounts.
- `claimSDK.triggerFaucet()` / `claimSDK.getFaucetParameters()`
Expand Down
33 changes: 22 additions & 11 deletions packages/citizen-sdk/src/sdks/viem-claim-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ export class ClaimSDK {
*/
async submitAndWait(
params: SimulateContractParameters,
onHash?: (hash: `0x${string}`) => void,
onHash?: (hash: `0x${string}`) => void | Promise<void>,
): Promise<TransactionReceipt> {
if (!this.account) {
throw new Error("No active wallet address found.")
Expand All @@ -366,7 +366,13 @@ export class ClaimSDK {
})

const hash = await this.walletClient.writeContract(request)
onHash?.(hash)
try {
Promise.resolve(onHash?.(hash)).catch((error) => {
console.warn("[ClaimSDK] onClaimSubmitted callback failed", error)
})
} catch (error) {
console.warn("[ClaimSDK] onClaimSubmitted callback failed", error)
}

// Wait one block to prevent waitFor... from immediately throwing an error
await new Promise((res) => setTimeout(res, 5000))
Expand Down Expand Up @@ -472,12 +478,14 @@ 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 transactions before execution.
* @param onClaimSubmitted - Optional callback invoked with the transaction hash immediately 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>,
onConfirmFaucetTx?: (message: string) => void | Promise<void>,
onClaimSubmitted?: (hash: `0x${string}`) => void | Promise<void>,
): Promise<TransactionReceipt | any> {
const userAddress = this.account

Expand All @@ -496,19 +504,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
10 changes: 8 additions & 2 deletions packages/citizen-sdk/src/sdks/viem-custodial-claim-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class ClaimCustodialSDK extends ClaimSDK {
*/
async submitAndWait(
params: SimulateContractParameters,
onHash?: (hash: `0x${string}`) => void,
onHash?: (hash: `0x${string}`) => void | Promise<void>,
): Promise<TransactionReceipt> {
const account = this.walletClient.account
if (!account?.address) {
Expand Down Expand Up @@ -93,7 +93,13 @@ export class ClaimCustodialSDK extends ClaimSDK {
hash = await this.walletClient.writeContract(request)
}

onHash?.(hash)
try {
Promise.resolve(onHash?.(hash)).catch((error) => {
console.warn("[ClaimSDK] onClaimSubmitted callback failed", error)
})
} catch (error) {
console.warn("[ClaimSDK] onClaimSubmitted callback failed", error)
}

// Wait one block to prevent immediate errors
await new Promise((res) => setTimeout(res, 5000))
Expand Down
171 changes: 171 additions & 0 deletions packages/citizen-sdk/test/claim-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { PublicClient, TransactionReceipt, WalletClient } from "viem"
import { waitForTransactionReceipt } from "viem/actions"
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(() => {
vi.mocked(waitForTransactionReceipt).mockImplementation(
async (_client, { hash }) =>
({
transactionHash: hash,
}) as TransactionReceipt,
)

publicClient = {
simulateContract: vi.fn().mockResolvedValue({ request: {} }),
}

walletClient = {
account: { address: MOCK_ACCOUNT } as any,
chain: { id: SupportedChains.CELO } as any,
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 inside real submitAndWait after broadcast and before receipt confirmation", async () => {
vi.useFakeTimers()
const events: string[] = []
let confirmReceipt: (receipt: TransactionReceipt) => void = () => {}
const receiptPromise = new Promise<TransactionReceipt>((resolve) => {
confirmReceipt = resolve
})

vi.mocked(waitForTransactionReceipt).mockImplementation(async () => {
events.push("waiting")
return receiptPromise
})

const onClaimSubmitted = vi.fn((hash: `0x${string}`) => {
events.push(`submitted:${hash}`)
})

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:${MOCK_HASH}`])

await vi.advanceTimersByTimeAsync(5000)
expect(events).toEqual([`submitted:${MOCK_HASH}`, "waiting"])

confirmReceipt({ transactionHash: MOCK_HASH } as TransactionReceipt)

const receipt = await claimPromise
expect(receipt.transactionHash).toBe(MOCK_HASH)
expect(events).toEqual([`submitted:${MOCK_HASH}`, "waiting", "confirmed"])
})

it("does not block claim confirmation when onClaimSubmitted returns a slow or unresolved promise", async () => {
vi.useFakeTimers()
const onClaimSubmitted = vi.fn(() => new Promise<void>(() => {}))

const claimPromise = sdk.claim(undefined, onClaimSubmitted)

await vi.waitFor(() => {
expect(onClaimSubmitted).toHaveBeenCalledWith(MOCK_HASH)
})

await vi.advanceTimersByTimeAsync(5000)

await expect(claimPromise).resolves.toMatchObject({
transactionHash: MOCK_HASH,
})
})

it("swallows synchronous and asynchronous 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 both 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
})
})