Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5bb6618
fix(citizen-sdk): resolve whitelisted root for connected accounts
Ryjen1 Feb 1, 2026
e480b45
docs(citizen-sdk): add connected accounts claiming flow guide
Ryjen1 Feb 1, 2026
e5ac108
Adding a standalone script to verify connected account logic off-chain
Ryjen1 Feb 1, 2026
9bd79df
Adding documentation for the new connected accounts test script
Ryjen1 Feb 1, 2026
c1df3a9
Adding a simple shell script to make running the connected account te…
Ryjen1 Feb 1, 2026
e448c83
Updating test script defaults to use a real verified whitelisted address
Ryjen1 Feb 1, 2026
d7f059e
chore(citizen-sdk): remove hardcoded test addresses in favor of envir…
Ryjen1 Feb 1, 2026
902eddd
chore(citizen-sdk): use zero-address placeholders in test scripts for…
Ryjen1 Feb 1, 2026
ccae930
Address PR review feedback
Ryjen1 Feb 1, 2026
8d179ce
Implement comprehensive error handling and address all review feedback
Ryjen1 Feb 1, 2026
3f0642f
refactor(citizen-sdk): convert connected accounts test to TypeScript …
Ryjen1 Feb 2, 2026
edc8094
chore(citizen-sdk): update test runner and docs for TypeScript migration
Ryjen1 Feb 2, 2026
efd20d6
chore(citizen-sdk): bump version and add tsx devDependency
Ryjen1 Feb 2, 2026
e218df6
Move test files to test directory
Feb 4, 2026
0955bef
Chore: Update yarn.lock and cleanup test script
Feb 4, 2026
a1f19d2
move tests to main test folder
Feb 9, 2026
7e52bd0
add support for connected accounts to claim SDK
Feb 9, 2026
0fe046d
add env file for test accounts
Feb 9, 2026
a927b9a
chore: final cleanup for connected accounts
Feb 9, 2026
547951f
docs: simplify test README
Feb 9, 2026
18bde41
chore: fix pre-existing linting errors and add missing config
Feb 9, 2026
aca253c
Fix lint issues in engagement app hooks
Feb 12, 2026
1cfd097
Refine SDK logic and remove caching
Feb 12, 2026
0344180
Migrate tests to Vitest and cleanup old test suite
Feb 12, 2026
5096bf4
refactor: use identitySDK directly for root address resolution
Feb 16, 2026
f6eb039
build: remove unused tsx dependency and update test script
Feb 16, 2026
7442ed9
test: relocate connected accounts tests to package root
Feb 16, 2026
6132ef6
feat(demo): support connected accounts in IdentityCard via getWhiteli…
Feb 17, 2026
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
23 changes: 23 additions & 0 deletions apps/demo-identity-app/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"ignorePatterns": [
"dist",
".eslintrc.json"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"react-hooks"
],
"rules": {}
}
2 changes: 1 addition & 1 deletion apps/demo-identity-app/src/components/ClaimButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const ClaimButton: React.FC = () => {
const [txHash, setTxHash] = useState<string | null>(null)
const { sdk: claimSDK, loading, error: sdkError } = useClaimSDK("development")
const [sdk, setSdk] = useState<typeof claimSDK | null>(null)
const [claimAmount, setClaimAmount] = useState<Number | null>(null)
const [claimAmount, setClaimAmount] = useState<number | null>(null)
const [altClaimAvailable, setAltClaimAvailable] = useState(false)
const [altChainId, setAltChainId] = useState<SupportedChains | null>(null)

Expand Down
4 changes: 1 addition & 3 deletions apps/demo-identity-app/src/components/VerifyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ interface VerifyButtonProps {
onVerificationSuccess: () => void
}

export const VerifyButton: React.FC<VerifyButtonProps> = ({
onVerificationSuccess,
}) => {
export const VerifyButton: React.FC<VerifyButtonProps> = () => {
const { address } = useAccount()
const { sdk: identitySDK } = useIdentitySDK("development")

Expand Down
129 changes: 60 additions & 69 deletions apps/engagement-app/src/hooks/use-toast.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,94 @@
"use client";
"use client"

// Inspired by react-hot-toast library
import * as React from "react";
import * as React from "react"

import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"

const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000

type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};

const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}

let count = 0;
let count = 0

function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}

type ActionType = typeof actionTypes;

type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
type: "ADD_TOAST"
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
type: "UPDATE_TOAST"
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
type: "DISMISS_TOAST"
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
type: "REMOVE_TOAST"
toastId?: ToasterToast["id"]
}

interface State {
toasts: ToasterToast[];
toasts: ToasterToast[]
}

const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()

const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
return
}

const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
})
}, TOAST_REMOVE_DELAY)

toastTimeouts.set(toastId, timeout);
};
toastTimeouts.set(toastId, timeout)
}

export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
}

case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
}

case "DISMISS_TOAST": {
const { toastId } = action;
const { toastId } = action

// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
addToRemoveQueue(toast.id)
})
}

return {
Expand All @@ -110,44 +101,44 @@ export const reducer = (state: State, action: Action): State => {
}
: t,
),
};
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
}
};
}

const listeners: Array<(state: State) => void> = [];
const listeners: Array<(state: State) => void> = []

let memoryState: State = { toasts: [] };
let memoryState: State = { toasts: [] }

function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState);
});
listener(memoryState)
})
}

type Toast = Omit<ToasterToast, "id">;
type Toast = Omit<ToasterToast, "id">

function toast({ ...props }: Toast) {
const id = genId();
const id = genId()

const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })

dispatch({
type: "ADD_TOAST",
Expand All @@ -156,36 +147,36 @@ function toast({ ...props }: Toast) {
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
if (!open) dismiss()
},
},
});
})

return {
id: id,
dismiss,
update,
};
}
}

function useToast() {
const [state, setState] = React.useState<State>(memoryState);
const [state, setState] = React.useState<State>(memoryState)

React.useEffect(() => {
listeners.push(setState);
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState);
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1);
listeners.splice(index, 1)
}
};
}, [state]);
}
}, [state])

return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
}

export { useToast, toast };
export { useToast, toast }
70 changes: 64 additions & 6 deletions packages/citizen-sdk/README-ClaimSDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ The Claim SDK ships with `@goodsdks/citizen-sdk` and builds on the Identity SDK
## Claim Flow at a Glance

1. **Verify identity** – confirm the wallet is whitelisted through the Identity SDK.
2. **Check entitlement** – determine the claimable amount on the active chain and look for fallbacks (Fuse ⇄ Celo ⇄ XDC).
3. **Trigger faucet (optional)** – tops up the claim contract if required.
4. **Submit claim** – send the `claim()` transaction and wait for confirmation.
2. **Resolve whitelisted root** – for connected accounts, resolve the main whitelisted address.
3. **Check entitlement** – determine the claimable amount on the active chain using the whitelisted root address.
4. **Look for fallbacks** – if no entitlement on the current chain, check alternatives (Fuse ⇄ Celo ⇄ XDC).
5. **Trigger faucet (optional)** – tops up the claim contract if required.
6. **Submit claim** – send the `claim()` transaction and wait for confirmation.

```
User connects wallet
Expand All @@ -16,9 +18,11 @@ IdentitySDK.getWhitelistedRoot
Whitelisted? ── no ──▶ Face verification
yes
yes (returns root address)
ClaimSDK uses root address
ClaimSDK.checkEntitlement
ClaimSDK.checkEntitlement(root)
Can claim? ── no ──▶ nextClaimTime timer
Expand Down Expand Up @@ -68,7 +72,7 @@ if (amount > 0n) {
`No allocation on the connected chain. ${altChain.label} exposes ${altAmount.toString()} wei.`,
)

// Optional: inspect the fallback chain without reconnecting the wallet
// inspect the fallback chain without reconnecting the wallet
const fallbackClient = createPublicClient({
transport: http(altChain.rpcUrls[0]!),
})
Expand All @@ -90,6 +94,59 @@ if (amount > 0n) {
> switch networks and then re-run `ClaimSDK.init` so the SDK binds to that
> environment before calling `claimSDK.claim()` again.

## Connected Accounts

Users can connect secondary wallets to their main whitelisted account. When a connected account attempts to claim:

1. **`getWhitelistedRoot(connectedAddress)`** returns the main whitelisted address
2. **`checkEntitlement`** is automatically called with the whitelisted root address (not the connected wallet)
3. **Claiming proceeds** as if the main account is claiming

This allows users to claim from any connected wallet without re-verification.

### How it Works

The `IdentityV2.getWhitelistedRoot(address)` contract method returns:

- `0x0` = address is neither whitelisted nor connected
- `input address` = address is the main whitelisted account
- `different address` = input is a connected account, returns the main whitelisted address

The ClaimSDK automatically resolves the whitelisted root and uses it for all entitlement checks, making connected accounts work transparently.

### Example

```ts
// User connects with a secondary wallet (Account B)
// Account B is connected to main whitelisted Account A

const identitySDK = await IdentitySDK.init({
publicClient,
walletClient,
env: "production",
})
const claimSDK = await ClaimSDK.init({
publicClient,
walletClient,
identitySDK,
env: "production",
})

// Behind the scenes:
// 1. getWhitelistedRoot(Account B) → returns Account A
// 2. checkEntitlement(Account A) → returns entitlement for Account A
// 3. User can claim on behalf of Account A

const { amount } = await claimSDK.checkEntitlement()
if (amount > 0n) {
const receipt = await claimSDK.claim()
console.log(
"Claimed successfully from connected account!",
receipt.transactionHash,
)
}
```

## Using with React

For Wagmi-based React projects, use the hooks exposed from `@goodsdks/react-hooks`. They wrap these Viem clients with loading/error state and should be the default integration layer for UI code. See `packages/react-hooks/README.md` for full guidance and examples.
Expand Down Expand Up @@ -118,6 +175,7 @@ Refer to the generated TypeScript declarations in `packages/citizen-sdk/dist/` f
## Best Practices

- Check `identitySDK.getWhitelistedRoot` before instantiating the Claim SDK UI.
- **Handle connected accounts**: The SDK automatically resolves the whitelisted root, but you may want to display which account is being claimed for in your UI.
- Surface `altClaimAvailable` hints so users can switch to chains with available allocations.
- Provide loading and error states around every async interaction.
- Log transaction hashes and explorer links after a successful claim for supportability.
Expand Down
Loading