diff --git a/packages/bridging-sdk/package.json b/packages/bridging-sdk/package.json index a12149b..caf64f5 100644 --- a/packages/bridging-sdk/package.json +++ b/packages/bridging-sdk/package.json @@ -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\"" @@ -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", diff --git a/packages/citizen-sdk/README-ClaimSDK.md b/packages/citizen-sdk/README-ClaimSDK.md index 2ce3c43..5f9d6a5 100644 --- a/packages/citizen-sdk/README-ClaimSDK.md +++ b/packages/citizen-sdk/README-ClaimSDK.md @@ -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()` diff --git a/packages/citizen-sdk/src/sdks/viem-claim-sdk.ts b/packages/citizen-sdk/src/sdks/viem-claim-sdk.ts index 413f411..37a28f1 100644 --- a/packages/citizen-sdk/src/sdks/viem-claim-sdk.ts +++ b/packages/citizen-sdk/src/sdks/viem-claim-sdk.ts @@ -354,7 +354,7 @@ export class ClaimSDK { */ async submitAndWait( params: SimulateContractParameters, - onHash?: (hash: `0x${string}`) => void, + onHash?: (hash: `0x${string}`) => void | Promise, ): Promise { if (!this.account) { throw new Error("No active wallet address found.") @@ -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)) @@ -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, + onConfirmFaucetTx?: (message: string) => void | Promise, + onClaimSubmitted?: (hash: `0x${string}`) => void | Promise, ): Promise { const userAddress = this.account @@ -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}`) 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..5539500 100644 --- a/packages/citizen-sdk/src/sdks/viem-custodial-claim-sdk.ts +++ b/packages/citizen-sdk/src/sdks/viem-custodial-claim-sdk.ts @@ -44,7 +44,7 @@ export class ClaimCustodialSDK extends ClaimSDK { */ async submitAndWait( params: SimulateContractParameters, - onHash?: (hash: `0x${string}`) => void, + onHash?: (hash: `0x${string}`) => void | Promise, ): Promise { const account = this.walletClient.account if (!account?.address) { @@ -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)) 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..6330688 --- /dev/null +++ b/packages/citizen-sdk/test/claim-sdk.test.ts @@ -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 +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((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(() => {})) + + 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 + }) +})