Skip to content

Conversation

@L03TJ3
Copy link
Collaborator

@L03TJ3 L03TJ3 commented Aug 17, 2025

Description

To remove maintance of sdk methods in separated places, the handling of a potential connected custodial wallet is merged into the core sdk.
Also includes:

  • follow Account/Address naming convention of viem
  • fix submitAndWait with correct params (.to / callbackdata)

About # (link your issue here)
#14 (comment)

How Has This Been Tested?

Tested flow in demo.

@korbit-ai
Copy link

korbit-ai bot commented Aug 17, 2025

You've used up your 5 PR reviews for this month under the Korbit Starter Plan. You'll get 5 more reviews on August 19th, 2025 or you can upgrade to Pro for unlimited PR reviews and enhanced features in your Korbit Console.

Copy link
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 there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments

### Comment 1
<location> `packages/citizen-sdk/src/sdks/viem-claim-sdk.ts:74` </location>
<code_context>
     this.walletClient = walletClient
     this.identitySDK = identitySDK
-    this.account = account ?? walletClient.account.address
+    this.account = account ?? walletClient.account
+    this.address = address ?? this.account.address

     this.rdu = rdu
</code_context>

<issue_to_address>
Defaulting logic for account and address may not handle all edge cases.

If walletClient.account is undefined, address may also be undefined. Please add error handling or validation to ensure both values are properly set.
</issue_to_address>

### Comment 2
<location> `packages/citizen-sdk/src/sdks/viem-claim-sdk.ts:96` </location>
<code_context>
   ): Promise<ClaimSDK> {
-    const [account] = await props.walletClient.getAddresses()
-    return new ClaimSDK({ account, ...props })
+    const [address] = await props.walletClient.getAddresses()
+    return new ClaimSDK({
+      account: props.walletClient?.account,
+      address,
</code_context>

<issue_to_address>
The init method now passes both account and address, but account may be undefined.

Validate that walletClient.account is defined before creating the SDK to prevent downstream issues.
</issue_to_address>

### Comment 3
<location> `packages/citizen-sdk/src/sdks/viem-claim-sdk.ts:159` </location>
<code_context>
+    try {
+      let hash: Hash
+
+      if ("signMessage" in this.account) {
+        const callData = encodeFunctionData({
+          abi: params.abi,
+          functionName: params.functionName,
+          args: params.args,
+        })
+
+        // Local signing path (works on RPCs that block eth_sendTransaction)
+        hash = await this.walletClient.request({
+          // Pass the LocalAccount, not just the address
+          account: account as LocalAccount,
+          to: request.address!,
+          data: callData,
</code_context>

<issue_to_address>
Type assertion to LocalAccount may be unsafe if account is not actually a LocalAccount.

Add a runtime check to ensure 'account' is a LocalAccount before casting to prevent potential errors.
</issue_to_address>

### Comment 4
<location> `packages/citizen-sdk/src/sdks/viem-identity-sdk.ts:57` </location>
<code_context>
  */
 export class IdentitySDK {
-  public account: Address
+  public account: Account
+  public address: Address
   publicClient: PublicClient
</code_context>

<issue_to_address>
Consider refactoring to use a unified Signer abstraction for message signing and address handling.

```suggestion
// 1) Define a simple Signer interface and unify Account vs WalletClient signing:
type Signer = {
  address: Address
  signMessage(opts: { message: string }): Promise<`0x${string}`>
}

// 2) In your IdentitySDK, replace both `account: Account` and `address: Address` fields
//    with a single private `signer: Signer`.
export class IdentitySDK {
  private signer: Signer
  publicClient: PublicClient
  walletClient: WalletClient & WalletActions
  contract: IdentityContract
  env: contractEnv = "production"

  constructor({
    account,
    publicClient,
    walletClient,
    env,
  }: Omit<IdentitySDKOptions, "address"> & { account?: Account }) {
    if (!walletClient.account) {
      throw new Error("ClaimSDK: WalletClient must have an account attached.")
    }
    this.publicClient = publicClient
    this.walletClient = walletClient
    this.env = env!

    // pick either a LocalAccount or wrap the WalletClient.account into Signer
    const acct = account ?? walletClient.account!
    if ("signMessage" in acct) {
      this.signer = acct
    } else {
      this.signer = {
        address: acct.address,
        signMessage: ({ message }) =>
          walletClient.signMessage({ account: acct.address, message }),
      }
    }

    const { contractEnvAddresses } = resolveChainAndContract(walletClient, env)
    this.contract = initializeIdentityContract(
      this.publicClient,
      contractEnvAddresses.identityContract,
    )
  }

  // 3) Simplify generateFVLink by always using this.signer:
  async generateFVLink(
    popupMode = false,
    callbackUrl?: string,
    chainId?: number,
  ): Promise<string> {
    try {
      const address = this.signer.address
      if (!address) throw new Error("No wallet address found.")
      const nonce = Math.floor(Date.now() / 1000).toString()
      const fvSigMessage = FV_IDENTIFIER_MSG2.replace("<account>", address)
      const fvSig = await this.signer.signMessage({ message: fvSigMessage })

      const { identityUrl } = Envs[this.env]!
      if (!identityUrl) throw new Error("identityUrl is not defined.")
      if (!popupMode && !callbackUrl)
        throw new Error("Callback URL is required for redirect mode.")

      const url = new URL(identityUrl)
      const params: Record<string, string | number> = {
        account: address,
        nonce,
        fvsig: fvSig,
        chain: chainId ?? (await this.publicClient.getChainId()),
      }
      if (callbackUrl) {
        params[popupMode ? "cbu" : "rdu"] = callbackUrl
      }
      Object.entries(params).forEach(([k, v]) =>
        url.searchParams.append(k, String(v)),
      )
      return url.toString()
    } catch (err: any) {
      console.error("generateFVLink Error:", err)
      throw err
    }
  }

  // 4) Update submitAndWait to use `this.signer.address` as well:
  async submitAndWait(
    params: SimulateContractParameters,
    onHash?: (hash: `0x${string}`) => void,
  ): Promise<any> {
    if (!this.signer.address) {
      throw new Error("No active wallet address found.")
    }
    const { request } = await this.publicClient.simulateContract({
      account: this.signer.address,
      ...params,
    })
    const hash = await this.walletClient.writeContract(request)
    onHash?.(hash)
    return waitForTransactionReceipt(this.publicClient, { hash })
  }
}
```

• Introduces a single `Signer` abstraction instead of keeping separate `account` and `address`.  
• Moves all branching logic into the constructor.  
• Simplifies `generateFVLink` and `submitAndWait` to always use `this.signer`.  
• Eliminates nested `if/else` and try/catch inside message signing while preserving both LocalAccount and WalletClient behavior.
</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.

Comment on lines +96 to +97
const [address] = await props.walletClient.getAddresses()
return new ClaimSDK({
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): The init method now passes both account and address, but account may be undefined.

Validate that walletClient.account is defined before creating the SDK to prevent downstream issues.

Comment on lines +159 to +168
if ("signMessage" in this.account) {
const callData = encodeFunctionData({
abi: params.abi,
functionName: params.functionName,
args: params.args,
})

// Local signing path (works on RPCs that block eth_sendTransaction)
hash = await this.walletClient.request({
// Pass the LocalAccount, not just the address
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Type assertion to LocalAccount may be unsafe if account is not actually a LocalAccount.

Add a runtime check to ensure 'account' is a LocalAccount before casting to prevent potential errors.

public account: Account
public address: Address
publicClient: PublicClient
walletClient: WalletClient & WalletActions
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider refactoring to use a unified Signer abstraction for message signing and address handling.

Suggested change
walletClient: WalletClient & WalletActions
// 1) Define a simple Signer interface and unify Account vs WalletClient signing:
type Signer = {
address: Address
signMessage(opts: { message: string }): Promise<`0x${string}`>
}
// 2) In your IdentitySDK, replace both `account: Account` and `address: Address` fields
// with a single private `signer: Signer`.
export class IdentitySDK {
private signer: Signer
publicClient: PublicClient
walletClient: WalletClient & WalletActions
contract: IdentityContract
env: contractEnv = "production"
constructor({
account,
publicClient,
walletClient,
env,
}: Omit<IdentitySDKOptions, "address"> & { account?: Account }) {
if (!walletClient.account) {
throw new Error("ClaimSDK: WalletClient must have an account attached.")
}
this.publicClient = publicClient
this.walletClient = walletClient
this.env = env!
// pick either a LocalAccount or wrap the WalletClient.account into Signer
const acct = account ?? walletClient.account!
if ("signMessage" in acct) {
this.signer = acct
} else {
this.signer = {
address: acct.address,
signMessage: ({ message }) =>
walletClient.signMessage({ account: acct.address, message }),
}
}
const { contractEnvAddresses } = resolveChainAndContract(walletClient, env)
this.contract = initializeIdentityContract(
this.publicClient,
contractEnvAddresses.identityContract,
)
}
// 3) Simplify generateFVLink by always using this.signer:
async generateFVLink(
popupMode = false,
callbackUrl?: string,
chainId?: number,
): Promise<string> {
try {
const address = this.signer.address
if (!address) throw new Error("No wallet address found.")
const nonce = Math.floor(Date.now() / 1000).toString()
const fvSigMessage = FV_IDENTIFIER_MSG2.replace("<account>", address)
const fvSig = await this.signer.signMessage({ message: fvSigMessage })
const { identityUrl } = Envs[this.env]!
if (!identityUrl) throw new Error("identityUrl is not defined.")
if (!popupMode && !callbackUrl)
throw new Error("Callback URL is required for redirect mode.")
const url = new URL(identityUrl)
const params: Record<string, string | number> = {
account: address,
nonce,
fvsig: fvSig,
chain: chainId ?? (await this.publicClient.getChainId()),
}
if (callbackUrl) {
params[popupMode ? "cbu" : "rdu"] = callbackUrl
}
Object.entries(params).forEach(([k, v]) =>
url.searchParams.append(k, String(v)),
)
return url.toString()
} catch (err: any) {
console.error("generateFVLink Error:", err)
throw err
}
}
// 4) Update submitAndWait to use `this.signer.address` as well:
async submitAndWait(
params: SimulateContractParameters,
onHash?: (hash: `0x${string}`) => void,
): Promise<any> {
if (!this.signer.address) {
throw new Error("No active wallet address found.")
}
const { request } = await this.publicClient.simulateContract({
account: this.signer.address,
...params,
})
const hash = await this.walletClient.writeContract(request)
onHash?.(hash)
return waitForTransactionReceipt(this.publicClient, { hash })
}
}

• Introduces a single Signer abstraction instead of keeping separate account and address.
• Moves all branching logic into the constructor.
• Simplifies generateFVLink and submitAndWait to always use this.signer.
• Eliminates nested if/else and try/catch inside message signing while preserving both LocalAccount and WalletClient behavior.

@sirpy
Copy link
Contributor

sirpy commented Aug 17, 2025

@L03TJ3 can we get more info about what this solves? an example of a wallet without signing?

why can't a walletclient with Account be used instead?
see https://viem.sh/docs/clients/wallet#optional-extend-with-public-actions

@L03TJ3
Copy link
Collaborator Author

L03TJ3 commented Aug 19, 2025

@sirpy thats not the problem thats being solved.
The problem is personal_sign method is blocked on public rpcs like forno:

curl -X POST https://forno.celo.org \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0",
    "method":"personal_sign",
    "params":["0x48656c6c6f2c2043656c6f21","0x000000000000000000000000000000000000dead"],
    "id":1
  }'
{"jsonrpc":"2.0","error":{"code":-32601,"message":"rpc method is not whitelisted"},"id":1}

@sirpy
Copy link
Contributor

sirpy commented Aug 24, 2025

@sirpy thats not the problem thats being solved. The problem is personal_sign method is blocked on public rpcs like forno:

curl -X POST https://forno.celo.org \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0",
    "method":"personal_sign",
    "params":["0x48656c6c6f2c2043656c6f21","0x000000000000000000000000000000000000dead"],
    "id":1
  }'
{"jsonrpc":"2.0","error":{"code":-32601,"message":"rpc method is not whitelisted"},"id":1}

the code wraps signmessage around walletclient or Account, i dont see any reason why it wouldnt work.
personal_sign should not be sent to an rpc, i'm guessing this happens only if no signer is defined, adding Account like in the above docs would probably solve it

@sirpy
Copy link
Contributor

sirpy commented Sep 2, 2025

@L03TJ3 @harjaapdhillon16

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.

3 participants