Skip to content

add wallet-link support to citizen-sdk and react-hooks#38

Open
edehvictor wants to merge 9 commits intoGoodDollar:mainfrom
edehvictor:feat/wallet-link-support
Open

add wallet-link support to citizen-sdk and react-hooks#38
edehvictor wants to merge 9 commits intoGoodDollar:mainfrom
edehvictor:feat/wallet-link-support

Conversation

@edehvictor
Copy link
Copy Markdown
Contributor

@edehvictor edehvictor commented Mar 30, 2026

Summary

This PR reintroduces wallet-link support on top of the latest main.

It includes:

  • wallet-link support in @goodsdks/citizen-sdk
  • matching React hooks in @goodsdks/react-hooks
  • demo app updates for the wallet-link flow
  • the latest upstream deployment/workflow changes merged into this branch

Changes

  • add wallet-link SDK support in the citizen SDK
  • add wallet-link wagmi hooks in react-hooks
  • update docs for the new wallet-link flow
  • add/update demo app integration
  • sync this branch with the latest main

Testing

  • verified branch is updated with latest upstream main
  • verified changes are pushed to feat/wallet-link-support

Notes

This branch was previously merged by mistake and then reverted from main.
This PR is the reopened version requested by the maintainer, with the latest upstream changes pulled into the branch.

Summary by Sourcery

Add multi-wallet link functionality to the Identity SDK and React hooks, including security prompts, connection status checks, and demo integration.

New Features:

  • Introduce wallet-link connect/disconnect flows and connection status querying in the Identity SDK, with configurable security messaging and callbacks.
  • Expose new wallet-link Wagmi React hooks and a composite hook to manage connect/disconnect actions and status across chains.
  • Add a demo Wallet Link widget to the demo identity app to showcase multi-wallet linking UX.

Enhancements:

  • Expand identity contract ABIs and chain configuration (including XDC mainnet addresses) to support IdentityV4 wallet-link methods and updated deployments.
  • Improve SDK error handling by normalizing thrown error messages and clearing stale errors when Wagmi clients are unavailable.
  • Document the wallet-link flow, headless client expectations, and updated contract addresses in the citizen SDK and React hooks READMEs.

Tests:

  • Add unit tests covering wallet-link read/write flows, security message handling, and error propagation for the IdentitySDK.

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • The demo WalletLinkWidget component appears out of sync with the useWalletLink/useConnectedStatus API (it references connectedStatus.status, connectedStatus.allChainStatuses, etc., which are not returned by the hook), so it likely won’t compile or behave as intended; align the widget with the actual hook return shape (statuses, loading, error, refetch).
  • useConnectedStatus’s useEffect omits publicClients from the dependency array, so updating the set of clients will not trigger a refetch; consider adding publicClients (or a stable memoized representation of it) to the dependencies to keep results consistent with the provided clients.
  • In WalletLinkWidget you pass targetAddress as Address into useWalletLink before validating it with isAddress, which means the hook can run RPCs against an invalid address; consider tracking a separate validated address or only passing a value into watchAccount after it has been validated.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The demo `WalletLinkWidget` component appears out of sync with the `useWalletLink`/`useConnectedStatus` API (it references `connectedStatus.status`, `connectedStatus.allChainStatuses`, etc., which are not returned by the hook), so it likely won’t compile or behave as intended; align the widget with the actual hook return shape (`statuses`, `loading`, `error`, `refetch`).
- `useConnectedStatus`’s `useEffect` omits `publicClients` from the dependency array, so updating the set of clients will not trigger a refetch; consider adding `publicClients` (or a stable memoized representation of it) to the dependencies to keep results consistent with the provided clients.
- In `WalletLinkWidget` you pass `targetAddress as Address` into `useWalletLink` before validating it with `isAddress`, which means the hook can run RPCs against an invalid address; consider tracking a separate validated address or only passing a value into `watchAccount` after it has been validated.

## Individual Comments

### Comment 1
<location path="packages/react-hooks/src/citizen-sdk/wagmi-wallet-link-sdk.ts" line_range="26-35" />
<code_context>
+    sdk: IdentitySDK | null,
</code_context>
<issue_to_address>
**issue (bug_risk):** publicClients is used in the effect but missing from the dependency array, which can cause stale reads when the clients change.

If callers swap out `publicClients` (for example after reconnecting or changing transports), this hook will still use the old instance. Please include `publicClients` in the dependency array (or depend on a memoized `publicClients` from the caller) so the effect tracks the current value.
</issue_to_address>

### Comment 2
<location path="apps/demo-identity-app/src/components/WalletLinkWidget.tsx" line_range="97-106" />
<code_context>
+        <p style={{ color: "green" }}>Tx Hash: {connectAccount.txHash || disconnectAccount.txHash}</p>
+      )}
+
+      <div style={{ background: "#f5f5f5", padding: "10px", marginTop: "1rem", fontSize: "14px" }}>
+        <h4>Status for {targetAddress || "..."}</h4>
+        {connectedStatus.loading ? (
+          <p>Loading status...</p>
+        ) : (
+          <>
+            <p><strong>Connected (Current Chain):</strong> {connectedStatus.status?.isConnected ? "✅ Yes" : "❌ No"}</p>
+            <p><strong>Root Identity:</strong> {connectedStatus.status?.root || "None"}</p>
+            
+            <details style={{ marginTop: "10px" }}>
+              <summary>Multi-Chain Statuses</summary>
+              <ul>
+                {connectedStatus.allChainStatuses.map(chain => (
+                  <li key={chain.chainId}>
+                    {chain.chainName}: {chain.isConnected ? `✅ (Root: ${chain.root})` : "❌"}
</code_context>
<issue_to_address>
**issue (bug_risk):** The widget references `status` and `allChainStatuses` properties that do not exist on `UseConnectedStatusReturn`.

The `useConnectedStatus` hook returns `{ statuses, loading, error, refetch }`, but the widget reads `connectedStatus.status` and `connectedStatus.allChainStatuses`, which will be `undefined` and can break the UI at runtime. You likely want to derive a “current chain” status from `statuses` (e.g. `statuses.find(...)`) for the primary display, and use `statuses` directly for the multi-chain list. Updating the widget to match `UseConnectedStatusReturn` will prevent these runtime errors.
</issue_to_address>

### Comment 3
<location path="apps/demo-identity-app/src/components/WalletLinkWidget.tsx" line_range="5-7" />
<code_context>
+
+export const WalletLinkWidget = () => {
+  const [targetAddress, setTargetAddress] = useState<string>("");
+  const { connectAccount, disconnectAccount, connectedStatus } = useWalletLink("development", targetAddress as Address);
+
+  const handleConnect = async () => {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Casting `targetAddress` to `Address` before validation can cause unnecessary RPC errors while the user types.

`targetAddress as Address` is passed directly into `useWalletLink``useConnectedStatus``sdk.checkConnectedStatus`, so RPC calls are made even when the user has typed an invalid/partial address. Instead, only pass a validated address into the hook, e.g.:
```ts
const parsedAddress = isAddress(targetAddress) ? (targetAddress as Address) : undefined
const { connectAccount, disconnectAccount, connectedStatus } = useWalletLink("development", parsedAddress)
```
This prevents RPC calls until the input is a valid address.

```suggestion
export const WalletLinkWidget = () => {
  const [targetAddress, setTargetAddress] = useState<string>("");

  const parsedAddress = isAddress(targetAddress) ? (targetAddress as Address) : undefined;
  const { connectAccount, disconnectAccount, connectedStatus } = useWalletLink("development", parsedAddress);
```
</issue_to_address>

### Comment 4
<location path="packages/react-hooks/src/citizen-sdk/wagmi-wallet-link-sdk.ts" line_range="32" />
<code_context>
+    const [loading, setLoading] = useState(false)
+    const [error, setError] = useState<string | null>(null)
+    const [txHash, setTxHash] = useState<`0x${string}` | null>(null)
+    const [pendingSecurityConfirm, setPendingSecurityConfirm] = useState<{
+        message: string
+        resolve: (confirmed: boolean) => void
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the `useWalletLinkAction` control flow by keeping only the security message in React state, moving the resolver to a ref, and extracting the option-wrapping into a helper function.

The main complexity cost here comes from the generic `useWalletLinkAction` + promise-in-state pattern. You can keep all existing behavior while making the control flow much easier to follow by:

1. **Moving the resolver out of React state**  
2. **Storing only the security message in state**  
3. **Extracting the option-wrapping into a small helper**  

That keeps `useWalletLinkAction`, `useConnectAccount`, and `useDisconnectAccount` APIs intact, but makes the implementation less “clever” and easier to reason about.

### 1. Store only message in state, resolver in a ref

Instead of:

```ts
const [pendingSecurityConfirm, setPendingSecurityConfirm] = useState<{
  message: string
  resolve: (confirmed: boolean) => void
} | null>(null)

const confirmSecurity = useCallback(
  (confirmed: boolean) => {
    pendingSecurityConfirm?.resolve(confirmed)
    setPendingSecurityConfirm(null)
  },
  [pendingSecurityConfirm],
)
```

you can separate UI state (message) from control flow (resolver) using a ref:

```ts
const [pendingSecurityMessage, setPendingSecurityMessage] = useState<string | null>(null)
const securityResolveRef = useRef<((confirmed: boolean) => void) | null>(null)

const confirmSecurity = useCallback((confirmed: boolean) => {
  securityResolveRef.current?.(confirmed)
  securityResolveRef.current = null
  setPendingSecurityMessage(null)
}, [])
```

Then in the `run` function, instead of putting `resolve` into state:

```ts
onSecurityMessage:
  options?.onSecurityMessage ??
  (options?.skipSecurityMessage
    ? undefined
    : (message) =>
        new Promise((resolve) => {
          setPendingSecurityConfirm({ message, resolve })
        })),
```

update to:

```ts
onSecurityMessage:
  options?.onSecurityMessage ??
  (options?.skipSecurityMessage
    ? undefined
    : (message) =>
        new Promise<boolean>((resolve) => {
          securityResolveRef.current = resolve
          setPendingSecurityMessage(message)
        })),
```

And adjust the return type accordingly:

```ts
interface UseWalletLinkActionReturn {
  // ...
  pendingSecurityConfirm: { message: string } | null
  confirmSecurity: (confirmed: boolean) => void
}
```

With this, `useConnectAccount` / `useDisconnectAccount` no longer need to strip `resolve`, and their implementation becomes simpler:

```ts
export const useConnectAccount = (sdk: IdentitySDK | null): UseConnectAccountReturn => {
  const base = useWalletLinkAction(sdk, (s, account, options) =>
    s.connectAccount(account, options),
  )
  return {
    ...base,
    connect: base.run,
    pendingSecurityConfirm: base.pendingSecurityConfirm,
  }
}
```

### 2. Extract a small helper for options wrapping

To reduce density in `run`, you can extract option-wrapping logic into a helper without changing behavior:

```ts
function createWalletLinkOptions(
  options: WalletLinkOptions | undefined,
  deps: {
    setTxHash: (hash: `0x${string}`) => void
    setPendingSecurityMessage: (message: string) => void
    securityResolveRef: React.MutableRefObject<((confirmed: boolean) => void) | null>
  },
): WalletLinkOptions | undefined {
  if (!options && !deps) return options

  const { setTxHash, setPendingSecurityMessage, securityResolveRef } = deps

  return {
    ...options,
    onHash: (hash) => {
      setTxHash(hash)
      options?.onHash?.(hash)
    },
    onSecurityMessage:
      options?.onSecurityMessage ??
      (options?.skipSecurityMessage
        ? undefined
        : (message) =>
            new Promise<boolean>((resolve) => {
              securityResolveRef.current = resolve
              setPendingSecurityMessage(message)
            })),
  }
}
```

Then `run` becomes easier to scan:

```ts
const run = useCallback(
  async (account: Address, options?: WalletLinkOptions) => {
    if (!sdk) {
      setError("IdentitySDK not initialized")
      return
    }

    setLoading(true)
    setError(null)
    setTxHash(null)

    try {
      await action(
        sdk,
        account,
        createWalletLinkOptions(options, {
          setTxHash,
          setPendingSecurityMessage,
          securityResolveRef,
        }),
      )
    } catch (err: any) {
      setError(err instanceof Error ? err.message : String(err))
    } finally {
      setLoading(false)
    }
  },
  [sdk, action],
)
```

This keeps the generic hook and all current behavior, but:

- Removes the non-obvious “promise resolver in state” pattern.
- Makes it clear that `confirmSecurity` resolves an internal promise.
- Reduces nesting and cognitive load in `run`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@edehvictor edehvictor changed the title Feat/wallet link support add wallet-link support to citizen-sdk and react-hooks Mar 30, 2026
@L03TJ3 L03TJ3 linked an issue Mar 30, 2026 that may be closed by this pull request
11 tasks
@L03TJ3 L03TJ3 requested review from L03TJ3 and removed request for sirpy April 2, 2026 17:10
if (!publicClient || !walletClient) {
setSdk(null)
setError("Wallet or Public client not initialized")
setError(null)
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.

I think this has happened before.
Why are you removing things in files that you have not made changes too?
and why are you removing error messages? what is the purpose of changing it to null here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed. I restored the previous behavior here and put back the original Wallet or Public client not initialized error so this file keeps its prior semantics.

Comment on lines +207 to +209
* This SDK is headless: it never creates its own public clients internally.
* For the SDK's current chain `this.publicClient` is reused (preserving the
* app's transport config — custom headers, retry policy, authenticated RPCs, etc.).
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.

I might have made a mistake in my last review. (we keep it headless don't enforce or overwrite app-level). but now reading the comment I understand the intention better.

We do support fallbacks to local read-only public clients in other flows as well.

So I think we need to make a slight adjustment.
While it's good to have the passed-down client as optional, it should always be the leading one.
We could do a fallback on a local rpc-client. but for this we don't need new methods.
you can look at the flow (readChainEntitlement).

And replicate the flow using existing methods to use getRpcFallbackClient
because it make sense we want to support easily reading all chains status.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated accordingly. publicClients still take priority when passed in, the active chain still uses this.publicClient, and for the other supported chains checkConnectedStatus now falls back through getRpcFallbackClient, following the same pattern as readChainEntitlement.

import { useWalletLink } from "@goodsdks/react-hooks";
import { Address, isAddress } from "viem";

export const WalletLinkWidget = () => {
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.

since we we use wagmi here.
Either here as demo, or at least in the wagmi README
add a section that explains how to handle switchChain if someone wants to connect a wallet on a chain they are currrently not connected to

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added. I documented the switchChain flow in the React hooks README, including the need to wait for Wagmi to finish switching and for useWalletLink to reinitialize on the new chain before calling connect or disconnect.

@edehvictor
Copy link
Copy Markdown
Contributor Author

Hey @L03TJ3, I'm doing the necessary fixes from your feedback. Thanks for the feedback. Once I've done, I'd reply to every comment you made with the changes.

@edehvictor
Copy link
Copy Markdown
Contributor Author

Gm @L03TJ3, I addressed the maintainer and Sourcery review feedback and pushed the updates. I also synced the branch with the latest main, resolved the README conflict, and replied on each review thread.

@edehvictor edehvictor requested a review from L03TJ3 April 3, 2026 13:11

#### `useConnectAccount(sdk)`

Manages the connect-account flow including the security confirmation prompt.
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.

This should reference back to the earlier section in the readme, so that it is clear this hook consumes the identity-sdk

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. The wallet-link section now explicitly references the Identity & Claim section/useIdentitySDK so it is clear these hooks consume the identity SDK.

Comment on lines +99 to +133
export interface UseConnectAccountReturn
extends Omit<UseWalletLinkActionReturn, "run" | "pendingSecurityConfirm"> {
connect: UseWalletLinkActionReturn["run"]
pendingSecurityConfirm: { message: string } | null
}

export const useConnectAccount = (
sdk: IdentitySDK | null,
): UseConnectAccountReturn => {
const base = useWalletLinkAction(sdk, (s, account, options) =>
s.connectAccount(account, options),
)
return {
...base,
connect: base.run,
}
}

export interface UseDisconnectAccountReturn
extends Omit<UseWalletLinkActionReturn, "run" | "pendingSecurityConfirm"> {
disconnect: UseWalletLinkActionReturn["run"]
pendingSecurityConfirm: { message: string } | null
}

export const useDisconnectAccount = (
sdk: IdentitySDK | null,
): UseDisconnectAccountReturn => {
const base = useWalletLinkAction(sdk, (s, account, options) =>
s.disconnectAccount(account, options),
)
return {
...base,
disconnect: base.run,
}
}
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.

Connect/disconnect should be able to be handled with a single hook

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. useWalletLinkActions now handles both connect and disconnect with shared state (loading/error/txHash/security prompt), and useWalletLink returns that single hook for both actions so consumers can manage both flows from one hook.

)
})

it("should return a headless error entry when checking all chains without providing publicClients", async () => {
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.

this test does not pass

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The headless test now stubs getRpcFallbackClient to use the mock public client and asserts the fallback flow without expecting the old 'No public client provided' error.

@edehvictor
Copy link
Copy Markdown
Contributor Author

Hi @L03TJ3 , I’ve fixed all the review items and pushed the updates. The maintainer threads are resolved and replies posted. The wallet-link changes are updated and tests adjusted accordingly

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Add Connect-A-Wallet Flow Support to Citizen SDK

2 participants