Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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
36 changes: 18 additions & 18 deletions apps/engagement-app/src/hooks/use-toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ type ToasterToast = ToastProps & {
description?: React.ReactNode;
action?: ToastActionElement;
};

const actionTypes = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
Expand All @@ -29,25 +29,25 @@ function genId() {
return count.toString();
}

type ActionType = typeof actionTypes;
type ActionType = typeof _actionTypes;

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

interface State {
toasts: ToasterToast[];
Expand Down Expand Up @@ -105,9 +105,9 @@ export const reducer = (state: State, action: Action): State => {
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
...t,
open: false,
}
: t,
),
};
Expand Down
68 changes: 63 additions & 5 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 @@ -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
6 changes: 4 additions & 2 deletions packages/citizen-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"name": "@goodsdks/citizen-sdk",
"version": "1.2.3",
"version": "1.2.4",
"type": "module",
"scripts": {
"build": "tsup --clean",
"dev": "tsc --watch",
"bump": "yarn version patch && yarn build && git add package.json && git commit -m \"version bump\""
"bump": "yarn version patch && yarn build && git add package.json && git commit -m \"version bump\"",
"test:connected": "npx tsx ../../test/citizen-sdk/connected-accounts.ts"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
Expand All @@ -26,6 +27,7 @@
],
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"tsx": "^4.19.2",
"typescript": "latest",
"viem": "latest",
"wagmi": "latest"
Expand Down
46 changes: 45 additions & 1 deletion packages/citizen-sdk/src/sdks/viem-claim-sdk.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
zeroAddress,
type Account,
type Address,
type Chain,
Expand Down Expand Up @@ -83,6 +84,7 @@ export class ClaimSDK {
private readonly account: Address
private readonly env: contractEnv
public readonly rdu: string
private whitelistedRootCache: Address | null = null

constructor({
account,
Expand Down Expand Up @@ -164,6 +166,44 @@ export class ClaimSDK {
return this.chainId
}

/**
* Resolves the main whitelisted address for this account.
* Useful for connected accounts to claim on behalf of their root address.
*/
private async getWhitelistedRootAddress(): Promise<Address> {
// Return cached value if available
if (this.whitelistedRootCache) {
return this.whitelistedRootCache
}

try {
// Resolve the whitelisted root for this account
const { root, isWhitelisted } = await this.identitySDK.getWhitelistedRoot(
this.account,
)

// Normalize "no root" / "not whitelisted" cases
if (!isWhitelisted || !root || root === zeroAddress) {
throw new Error(
"No whitelisted root address found for connected account; the user may not be whitelisted.",
)
}

// Cache the result
this.whitelistedRootCache = root

return root
} catch (error) {
// Normalize SDK and transport errors into a predictable failure mode
const message =
error instanceof Error && error.message ? error.message : String(error)

throw new Error(
`Unable to resolve whitelisted root address for connected account: ${message}`,
)
}
}

private async readChainEntitlement(
chainId: SupportedChains,
client?: PublicClient,
Expand All @@ -184,12 +224,16 @@ export class ClaimSDK {
altClient = resolvedClient
}

// Use whitelisted root address for entitlement check
// This enables connected accounts to claim on behalf of their main account
const rootAddress = await this.getWhitelistedRootAddress()

return this.readContract<bigint>(
{
address: contracts.ubiContract as Address,
abi: ubiSchemeV2ABI,
functionName: "checkEntitlement",
args: [this.account],
args: [rootAddress],
},
altClient,
chainId,
Expand Down
17 changes: 17 additions & 0 deletions test/citizen-sdk/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Connected Accounts Test Configuration
# Copy this file to .env and replace with your actual test addresses

# Required: Whitelisted account address (main account)
MAIN_ACCOUNT=0x0000000000000000000000000000000000000000

# Required: Account connected to the main account
CONNECTED_ACCOUNT=0x0000000000000000000000000000000000000000

# Required: Non-whitelisted account for error testing
NON_WHITELISTED_ACCOUNT=0x0000000000000000000000000000000000000000

# Optional: Environment (development, staging, production)
ENV=development

# Optional: Custom RPC URL
# RPC_URL=https://forno.celo.org
30 changes: 30 additions & 0 deletions test/citizen-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Connected Accounts Test Script

Tests that connected accounts can claim UBI via their whitelisted root.

## Run Commands

```bash
cd test/citizen-sdk
cp .env.example .env # Edit with your test addresses
./run-test.sh
```

Alternatively from packages/citizen-sdk:
```bash
npm run test:connected
```

## Required Environment Variables

- `MAIN_ACCOUNT` - Whitelisted account address **(required)**
- `CONNECTED_ACCOUNT` - Account connected to main **(required)**
- `NON_WHITELISTED_ACCOUNT` - Non-whitelisted account for error testing **(required)**
- `ENV` - Environment: development, staging, or production (optional, default: development)
- `RPC_URL` - Custom RPC endpoint (optional, default: https://forno.celo.org)

## Pass/Fail Criteria

**Pass:** All 4 tests pass - main resolves to self, connected resolves to main, non-whitelisted throws error, status retrieval succeeds.

**Fail:** Any test fails - indicates issues with whitelisted root resolution or entitlement checks.
Loading