Skip to content

feat: new @goodsdks/invite-sdk — InvitesV2 viem SDK + React/Wagmi hooks#44

Open
Copilot wants to merge 3 commits intomainfrom
copilot/add-invite-sdk-package
Open

feat: new @goodsdks/invite-sdk — InvitesV2 viem SDK + React/Wagmi hooks#44
Copilot wants to merge 3 commits intomainfrom
copilot/add-invite-sdk-package

Conversation

Copy link
Copy Markdown

Copilot AI commented Apr 28, 2026

Adds a new workspace package @goodsdks/invite-sdk wrapping the InvitesV2 contract (v2.4 UUPS) into a typed viem SDK, following the same class-based conventions as citizen-sdk. Companion hooks are added to react-hooks.

packages/invite-sdk

  • src/abi.tsinvitesV2ABI via parseAbi: view/write functions, InviteeJoined + InviterBounty events, all five custom errors
  • src/constants.tsINVITES_V2_ADDRESSES for Fuse + Celo across production/staging/development (+ XDC development), sourced from deployment.json
  • src/types.tsInviteUser, InviteLevel, InviteStats, BountyResult, BountyEligibilityDetails, typed InviteSDKError with errorCode
  • src/sdks/viem-invite-sdk.tsInviteSDK class:
    • All read methods (getUser, getLevel, getStats, getInvitees, getPendingInvitees, getPendingBounties, canCollectBounty, resolveCode, …)
    • Write methods with pre-checks → simulate → write → wait: join, collectBounty, collectAllBounties
    • checkEligibilityDetails — calls Identity isWhitelisted on both invitee and inviter to surface the exact blocker (including reverificationDue flag)
    • formatBounty(cents, chainId) — normalises G¢ → G$ using CHAIN_DECIMALS from citizen-sdk
const sdk = await InviteSDK.init({ publicClient, walletClient, env: "production" })

// read
const { eligible, details } = await sdk.checkEligibilityDetails(invitee)
// details.reverificationDue → true when whitelist check lapsed

// write (pre-checks + simulate included)
const txHash = await sdk.join(myCode, inviterCode)
const result  = await sdk.collectBounty(invitee)   // → BountyResult
const results = await sdk.collectAllBounties()      // → BountyResult[]

// display
console.log(formatBounty(result.bountyPaid, SupportedChains.FUSE)) // "12.50"

packages/react-hooks/src/invite-sdk

Hook Description
useInviteSDK(env) Initialises InviteSDK for the connected wallet
useInviteStatus(invitee?) User record, eligibility, pending counts, reverificationDue
useJoin(env) join(myCode, inviterCode) action + loading/error/txHash
useCollectBounty(env) collectBounty(invitee) action + BountyResult state
useCollectAllBounties(env) Batch payout action + BountyResult[] state

Hooks re-export InviteSDKError so callers can branch on err.errorCode (NOT_ACTIVE, NOT_ELIGIBLE_BOUNTY, etc.).

react-hooks/package.json gains @goodsdks/invite-sdk: workspace:*; no circular dependency — invite-sdk depends on citizen-sdk, never the reverse.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • binaries.soliditylang.org
    • Triggering command: /usr/local/bin/node /usr/local/bin/node /home/REDACTED/work/GoodSDKs/GoodSDKs/packages/engagement-contracts/node_modules/hardhat/internal/cli/bootstrap.js compile (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Summary by Sourcery

Add a new InviteSDK workspace package for the InvitesV2 contract and expose React/Wagmi hooks for invite status and bounty flows.

New Features:

  • Introduce @goodsdks/invite-sdk package providing a typed viem-based InviteSDK wrapper around the InvitesV2 contract, including invite/user/level queries and bounty collection helpers.
  • Add React/Wagmi hooks to initialise InviteSDK, read invite status, and perform join and bounty collection actions from dapps.
  • Expose a formatBounty utility to display on-chain G$ bounty amounts as human-readable values.

Enhancements:

  • Wire the new invite-sdk into the react-hooks workspace and re-export its hooks from the main react-hooks entrypoint.
  • Provide environment-aware InvitesV2 contract address resolution and typed error codes for invite-related contract failures.

Build:

  • Configure TypeScript and tsup build settings for the new invite-sdk package.

Copilot AI changed the title [WIP] Add new @goodsdks/invite-sdk package with integration plan feat: new @goodsdks/invite-sdk — InvitesV2 viem SDK + React/Wagmi hooks Apr 28, 2026
Copilot AI requested a review from L03TJ3 April 28, 2026 11:31
@L03TJ3 L03TJ3 marked this pull request as ready for review April 28, 2026 15:51
@L03TJ3 L03TJ3 requested review from a team and blueogin April 28, 2026 15:51
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 3 issues, and left some high level feedback:

  • In formatBounty, const divisor = BigInt(10 ** decimals) will produce an imprecise divisor for larger decimals (e.g. 18) because 10 ** decimals is computed as a JS number first; consider using const divisor = 10n ** BigInt(decimals) to avoid precision issues.
  • In useInviteStatus, when !sdk || !target you only clear user but leave eligible, pendingBounties, pendingInvitees, and reverificationDue unchanged, which can leak stale state between account changes; reset these fields in that branch to keep the hook output consistent with the current target.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `formatBounty`, `const divisor = BigInt(10 ** decimals)` will produce an imprecise divisor for larger decimals (e.g. 18) because `10 ** decimals` is computed as a JS `number` first; consider using `const divisor = 10n ** BigInt(decimals)` to avoid precision issues.
- In `useInviteStatus`, when `!sdk || !target` you only clear `user` but leave `eligible`, `pendingBounties`, `pendingInvitees`, and `reverificationDue` unchanged, which can leak stale state between account changes; reset these fields in that branch to keep the hook output consistent with the current target.

## Individual Comments

### Comment 1
<location path="packages/react-hooks/src/invite-sdk/wagmi-invite-hooks.ts" line_range="31-36" />
<code_context>
+  const [error, setError] = useState<string | null>(null)
+
+  useEffect(() => {
+    if (!publicClient || !walletClient) {
+      setSdk(null)
+      setError("Wallet or Public client not initialized")
+      return
+    }
</code_context>
<issue_to_address>
**suggestion:** Treat missing wallet/public client as a neutral state instead of an error string.

During initial wagmi setup or when the user isn’t connected, `publicClient`/`walletClient` can legitimately be undefined. Treating this as an `error` blurs the line between normal "not connected" and real failures (e.g. misconfiguration). Consider leaving `error` as `null` in this case and letting consumers infer state from `sdk === null` plus the account connection status instead.

```suggestion
  useEffect(() => {
    if (!publicClient || !walletClient) {
      setSdk(null)
      return
    }
```
</issue_to_address>

### Comment 2
<location path="packages/invite-sdk/src/sdks/viem-invite-sdk.ts" line_range="189" />
<code_context>
+
+  // ─── Private helpers ───────────────────────────────────────────────────────
+
+  private async read<T = unknown>(
+    functionName: string,
+    args: unknown[] = [],
</code_context>
<issue_to_address>
**issue (complexity):** Consider introducing typed helper methods, shared pre-check utilities, and a small whitelist-status helper, plus a purely-BigInt divisor, to simplify the SDK’s control flow and improve type safety without changing behavior.

You can reduce the complexity without changing behavior by tightening a few patterns.

---

### 1. Avoid stringly‑typed `read` and ad‑hoc casting

The generic `read(functionName: string, args: unknown[])` forces string names and manual casting at every call site, which increases mental overhead and weakens type safety.

You can keep the helper but make it typed per function, or define small wrappers. For example:

```ts
// Narrow the helper
private readActive() {
  return this.publicClient.readContract({
    address: this.contractAddress,
    abi: invitesV2ABI,
    functionName: "active",
  }) as Promise<boolean>
}

private readUser(address: Address) {
  return this.publicClient.readContract({
    address: this.contractAddress,
    abi: invitesV2ABI,
    functionName: "users",
    args: [address],
  }) as Promise<
    readonly [Address, `0x${string}`, boolean, bigint, bigint, bigint, bigint, bigint, bigint]
  >
}
```

Then the call sites become simpler and typed:

```ts
async getActive(): Promise<boolean> {
  return this.readActive()
}

async getUser(address: Address): Promise<InviteUser> {
  const result = await this.readUser(address)
  // mapping unchanged...
}
```

You can incrementally replace the most frequently used `read(...)` calls with typed helpers like `readActive`, `readMinimums`, `readUser`, `readLevel`.

---

### 2. Factor out repeated “require active” / eligibility pre‑checks

`join`, `collectBounty`, and `collectAllBounties` all repeat `active` checks (and `canCollectBountyFor` in one place). A small shared helper keeps behavior but removes duplication:

```ts
private async ensureActive() {
  const isActive = await this.read<boolean>("active")
  if (!isActive) {
    throw new InviteSDKError("contract is not active", "NOT_ACTIVE")
  }
}
```

Then:

```ts
async join(myCode: `0x${string}`, inviterCode: `0x${string}`): Promise<`0x${string}`> {
  await this.ensureActive()
  const [existingOwner, callerUser] = await Promise.all([
    this.read<Address>("codeToUser", [myCode]),
    this.getUser(this.account),
  ])
  // rest unchanged...
}

async collectAllBounties(): Promise<BountyResult[]> {
  await this.ensureActive()
  try {
    const receipt = await this.submitAndWait("collectBounties")
    // mapping unchanged...
  } catch (err) {
    throw mapContractError(err)
  }
}
```

Similarly, you can extract the “ensure eligible or throw detailed InviteSDKError” logic from `collectBounty` into a reusable helper if you plan to have more bounty-related methods.

---

### 3. Simplify `checkEligibilityDetails` identity logic

The identity/whitelist orchestration is doing multiple reads and local mutations. Extracting the identity‑related part into its own helper makes the flow easier to follow without changing semantics:

```ts
private async getWhitelistStatus(
  identityAddress: Address,
  invitee: Address,
  inviter: Address,
): Promise<{
  inviteeWhitelisted: boolean
  inviterWhitelisted: boolean | null
  reverificationDue: boolean
}> {
  if (identityAddress === zeroAddress) {
    return { inviteeWhitelisted: false, inviterWhitelisted: null, reverificationDue: false }
  }

  let inviteeWhitelisted = false
  let inviterWhitelisted: boolean | null = null

  try {
    inviteeWhitelisted = await this.publicClient.readContract({
      address: identityAddress,
      abi: identityABI,
      functionName: "isWhitelisted",
      args: [invitee],
    }) as boolean
  } catch {
    // treat as not whitelisted
  }

  if (inviter !== zeroAddress) {
    try {
      inviterWhitelisted = await this.publicClient.readContract({
        address: identityAddress,
        abi: identityABI,
        functionName: "isWhitelisted",
        args: [inviter],
      }) as boolean
    } catch {
      inviterWhitelisted = null
    }
  }

  const reverificationDue =
    !inviteeWhitelisted ||
    (inviterWhitelisted !== null && !inviterWhitelisted)

  return { inviteeWhitelisted, inviterWhitelisted, reverificationDue }
}
```

Then `checkEligibilityDetails` becomes:

```ts
async checkEligibilityDetails(invitee: Address) {
  const identityAddress = await this.read<Address>("getIdentity")

  const [isActive, eligible, minimumClaims, minimumDays, user] = await Promise.all([
    this.read<boolean>("active"),
    this.read<boolean>("canCollectBountyFor", [invitee]),
    this.read<number>("minimumClaims"),
    this.read<number>("minimumDays"),
    this.getUser(invitee),
  ])

  const { inviteeWhitelisted, inviterWhitelisted, reverificationDue } =
    await this.getWhitelistStatus(identityAddress, invitee, user.invitedBy)

  return {
    eligible,
    details: {
      isActive,
      inviteeWhitelisted,
      inviterWhitelisted,
      minimumClaims,
      minimumDays,
      reverificationDue,
    },
  }
}
```

This keeps all checks and edge cases but reduces branching and mutable locals in the main method.

---

### 4. Compute `formatBounty` divisor purely with `BigInt`

To avoid mixing `number` and `BigInt` (and avoid implicit precision pitfalls), you can keep the behavior but compute the divisor in `BigInt` only:

```ts
export function formatBounty(cents: bigint, chainId: SupportedChains): string {
  const decimals = CHAIN_DECIMALS[chainId] ?? 2

  let divisor = 1n
  for (let i = 0; i < decimals; i++) {
    divisor *= 10n
  }

  const whole = cents / divisor
  const remainder = cents % divisor

  const fractionStr = remainder.toString().padStart(decimals, "0")
  const trimmed = fractionStr.replace(/0+$/, "").padEnd(2, "0")

  return `${whole}.${trimmed}`
}
```

This keeps outputs identical while making the implementation more obviously correct for high‑decimals chains.
</issue_to_address>

### Comment 3
<location path="packages/react-hooks/src/invite-sdk/wagmi-invite-hooks.ts" line_range="151" />
<code_context>
+ * Pre-checks (contract must be active, code must be free, user must not have joined)
+ * are run before simulation to surface errors before signing.
+ */
+export const useJoin = (env: contractEnv = "production"): UseJoinResult => {
+  const { sdk } = useInviteSDK(env)
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the shared async/error-handling logic into a reusable helper and simplifying the status hook state to remove duplication and opaque refetching.

You can keep all behaviour but trim a lot of boilerplate by extracting the repeated async pattern and error formatting, and slightly tightening `useInviteStatus`.

### 1) Centralise error formatting + async action pattern

The three action hooks are almost identical structurally. You can pull that into a small helper while preserving the current public API:

```ts
const formatInviteError = (err: unknown): string => {
  if (err instanceof InviteSDKError) return `[${err.errorCode}] ${err.message}`
  if (err instanceof Error) return err.message
  return String(err)
}

interface InviteActionState<T> {
  loading: boolean
  error: string | null
  result: T | null
}

const useInviteAction = <Args extends unknown[], Result>(
  sdk: InviteSDK | null,
  fn: (sdk: InviteSDK, ...args: Args) => Promise<Result>,
) => {
  const [state, setState] = useState<InviteActionState<Result>>({
    loading: false,
    error: null,
    result: null,
  })

  const run = useCallback(
    async (...args: Args): Promise<Result> => {
      if (!sdk) throw new Error("InviteSDK not initialized")

      setState({ loading: true, error: null, result: null })

      try {
        const result = await fn(sdk, ...args)
        setState({ loading: false, error: null, result })
        return result
      } catch (err) {
        const msg = formatInviteError(err)
        setState({ loading: false, error: msg, result: null })
        throw err
      }
    },
    [sdk, fn],
  )

  return { ...state, run }
}
```

Then each hook becomes much simpler without changing its outward behaviour:

```ts
export const useJoin = (env: contractEnv = "production"): UseJoinResult => {
  const { sdk } = useInviteSDK(env)

  const { run, loading, error, result } = useInviteAction<
    [`0x${string}`, `0x${string}`],
    `0x${string}`
  >(sdk, (s, myCode, inviterCode) => s.join(myCode, inviterCode))

  return {
    join: run,
    loading,
    error,
    txHash: result,
  }
}
```

```ts
export const useCollectBounty = (
  env: contractEnv = "production",
): UseCollectBountyResult => {
  const { sdk } = useInviteSDK(env)

  const { run, loading, error, result } = useInviteAction<[Address], BountyResult>(
    sdk,
    (s, invitee) => s.collectBounty(invitee),
  )

  return {
    collectBounty: run,
    loading,
    error,
    result,
  }
}
```

```ts
export const useCollectAllBounties = (
  env: contractEnv = "production",
): UseCollectAllBountiesResult => {
  const { sdk } = useInviteSDK(env)

  const { run, loading, error, result } = useInviteAction<[], BountyResult[]>(
    sdk,
    (s) => s.collectAllBounties(),
  )

  return {
    collectAllBounties: run,
    loading,
    error,
    results: result ?? [],
  }
}
```

This removes the triplicated `try/catch/finally` blocks and guarantees consistent `InviteSDKError` formatting across all action hooks.

### 2) Simplify `useInviteStatus` state + refetch pattern

You can group the status pieces into a single object and replace the `tick` counter with a `fetchStatus` callback that is reused for both `useEffect` and `refetch`:

```ts
interface InviteStatusInternal {
  user: InviteUser | null
  eligible: boolean
  pendingBounties: bigint
  pendingInvitees: Address[]
  reverificationDue: boolean
}

export const useInviteStatus = (invitee?: Address): InviteStatus => {
  const { address: connectedAddress } = useAccount()
  const { sdk, loading: sdkLoading, error: sdkError } = useInviteSDK()

  const target = invitee ?? connectedAddress

  const [status, setStatus] = useState<InviteStatusInternal>({
    user: null,
    eligible: false,
    pendingBounties: 0n,
    pendingInvitees: [],
    reverificationDue: false,
  })
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const fetchStatus = useCallback(async () => {
    if (!sdk || !target) {
      setStatus((prev) => ({ ...prev, user: null }))
      return
    }

    setLoading(true)
    setError(null)

    try {
      const [u, eligibilityResult, pending, pendingInvs] = await Promise.all([
        sdk.getUser(target),
        sdk.checkEligibilityDetails(target),
        sdk.getPendingBounties(target),
        sdk.getPendingInvitees(target),
      ])

      setStatus({
        user: u,
        eligible: eligibilityResult.eligible,
        reverificationDue: eligibilityResult.details.reverificationDue,
        pendingBounties: pending,
        pendingInvitees: pendingInvs,
      })
    } catch (err) {
      setError(formatInviteError(err)) // reuse same helper as above
    } finally {
      setLoading(false)
    }
  }, [sdk, target])

  useEffect(() => {
    void fetchStatus()
  }, [fetchStatus])

  return {
    ...status,
    loading: sdkLoading || loading,
    error: sdkError ?? error,
    refetch: fetchStatus,
  }
}
```

This keeps the exact behaviour but reduces the number of individual `useState` calls and removes the opaque `tick` refetch mechanism, making the flow easier to follow.
</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 +31 to +36
useEffect(() => {
if (!publicClient || !walletClient) {
setSdk(null)
setError("Wallet or Public client not initialized")
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Treat missing wallet/public client as a neutral state instead of an error string.

During initial wagmi setup or when the user isn’t connected, publicClient/walletClient can legitimately be undefined. Treating this as an error blurs the line between normal "not connected" and real failures (e.g. misconfiguration). Consider leaving error as null in this case and letting consumers infer state from sdk === null plus the account connection status instead.

Suggested change
useEffect(() => {
if (!publicClient || !walletClient) {
setSdk(null)
setError("Wallet or Public client not initialized")
return
}
useEffect(() => {
if (!publicClient || !walletClient) {
setSdk(null)
return
}


// ─── Private helpers ───────────────────────────────────────────────────────

private async read<T = unknown>(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider introducing typed helper methods, shared pre-check utilities, and a small whitelist-status helper, plus a purely-BigInt divisor, to simplify the SDK’s control flow and improve type safety without changing behavior.

You can reduce the complexity without changing behavior by tightening a few patterns.


1. Avoid stringly‑typed read and ad‑hoc casting

The generic read(functionName: string, args: unknown[]) forces string names and manual casting at every call site, which increases mental overhead and weakens type safety.

You can keep the helper but make it typed per function, or define small wrappers. For example:

// Narrow the helper
private readActive() {
  return this.publicClient.readContract({
    address: this.contractAddress,
    abi: invitesV2ABI,
    functionName: "active",
  }) as Promise<boolean>
}

private readUser(address: Address) {
  return this.publicClient.readContract({
    address: this.contractAddress,
    abi: invitesV2ABI,
    functionName: "users",
    args: [address],
  }) as Promise<
    readonly [Address, `0x${string}`, boolean, bigint, bigint, bigint, bigint, bigint, bigint]
  >
}

Then the call sites become simpler and typed:

async getActive(): Promise<boolean> {
  return this.readActive()
}

async getUser(address: Address): Promise<InviteUser> {
  const result = await this.readUser(address)
  // mapping unchanged...
}

You can incrementally replace the most frequently used read(...) calls with typed helpers like readActive, readMinimums, readUser, readLevel.


2. Factor out repeated “require active” / eligibility pre‑checks

join, collectBounty, and collectAllBounties all repeat active checks (and canCollectBountyFor in one place). A small shared helper keeps behavior but removes duplication:

private async ensureActive() {
  const isActive = await this.read<boolean>("active")
  if (!isActive) {
    throw new InviteSDKError("contract is not active", "NOT_ACTIVE")
  }
}

Then:

async join(myCode: `0x${string}`, inviterCode: `0x${string}`): Promise<`0x${string}`> {
  await this.ensureActive()
  const [existingOwner, callerUser] = await Promise.all([
    this.read<Address>("codeToUser", [myCode]),
    this.getUser(this.account),
  ])
  // rest unchanged...
}

async collectAllBounties(): Promise<BountyResult[]> {
  await this.ensureActive()
  try {
    const receipt = await this.submitAndWait("collectBounties")
    // mapping unchanged...
  } catch (err) {
    throw mapContractError(err)
  }
}

Similarly, you can extract the “ensure eligible or throw detailed InviteSDKError” logic from collectBounty into a reusable helper if you plan to have more bounty-related methods.


3. Simplify checkEligibilityDetails identity logic

The identity/whitelist orchestration is doing multiple reads and local mutations. Extracting the identity‑related part into its own helper makes the flow easier to follow without changing semantics:

private async getWhitelistStatus(
  identityAddress: Address,
  invitee: Address,
  inviter: Address,
): Promise<{
  inviteeWhitelisted: boolean
  inviterWhitelisted: boolean | null
  reverificationDue: boolean
}> {
  if (identityAddress === zeroAddress) {
    return { inviteeWhitelisted: false, inviterWhitelisted: null, reverificationDue: false }
  }

  let inviteeWhitelisted = false
  let inviterWhitelisted: boolean | null = null

  try {
    inviteeWhitelisted = await this.publicClient.readContract({
      address: identityAddress,
      abi: identityABI,
      functionName: "isWhitelisted",
      args: [invitee],
    }) as boolean
  } catch {
    // treat as not whitelisted
  }

  if (inviter !== zeroAddress) {
    try {
      inviterWhitelisted = await this.publicClient.readContract({
        address: identityAddress,
        abi: identityABI,
        functionName: "isWhitelisted",
        args: [inviter],
      }) as boolean
    } catch {
      inviterWhitelisted = null
    }
  }

  const reverificationDue =
    !inviteeWhitelisted ||
    (inviterWhitelisted !== null && !inviterWhitelisted)

  return { inviteeWhitelisted, inviterWhitelisted, reverificationDue }
}

Then checkEligibilityDetails becomes:

async checkEligibilityDetails(invitee: Address) {
  const identityAddress = await this.read<Address>("getIdentity")

  const [isActive, eligible, minimumClaims, minimumDays, user] = await Promise.all([
    this.read<boolean>("active"),
    this.read<boolean>("canCollectBountyFor", [invitee]),
    this.read<number>("minimumClaims"),
    this.read<number>("minimumDays"),
    this.getUser(invitee),
  ])

  const { inviteeWhitelisted, inviterWhitelisted, reverificationDue } =
    await this.getWhitelistStatus(identityAddress, invitee, user.invitedBy)

  return {
    eligible,
    details: {
      isActive,
      inviteeWhitelisted,
      inviterWhitelisted,
      minimumClaims,
      minimumDays,
      reverificationDue,
    },
  }
}

This keeps all checks and edge cases but reduces branching and mutable locals in the main method.


4. Compute formatBounty divisor purely with BigInt

To avoid mixing number and BigInt (and avoid implicit precision pitfalls), you can keep the behavior but compute the divisor in BigInt only:

export function formatBounty(cents: bigint, chainId: SupportedChains): string {
  const decimals = CHAIN_DECIMALS[chainId] ?? 2

  let divisor = 1n
  for (let i = 0; i < decimals; i++) {
    divisor *= 10n
  }

  const whole = cents / divisor
  const remainder = cents % divisor

  const fractionStr = remainder.toString().padStart(decimals, "0")
  const trimmed = fractionStr.replace(/0+$/, "").padEnd(2, "0")

  return `${whole}.${trimmed}`
}

This keeps outputs identical while making the implementation more obviously correct for high‑decimals chains.

* Pre-checks (contract must be active, code must be free, user must not have joined)
* are run before simulation to surface errors before signing.
*/
export const useJoin = (env: contractEnv = "production"): UseJoinResult => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider extracting the shared async/error-handling logic into a reusable helper and simplifying the status hook state to remove duplication and opaque refetching.

You can keep all behaviour but trim a lot of boilerplate by extracting the repeated async pattern and error formatting, and slightly tightening useInviteStatus.

1) Centralise error formatting + async action pattern

The three action hooks are almost identical structurally. You can pull that into a small helper while preserving the current public API:

const formatInviteError = (err: unknown): string => {
  if (err instanceof InviteSDKError) return `[${err.errorCode}] ${err.message}`
  if (err instanceof Error) return err.message
  return String(err)
}

interface InviteActionState<T> {
  loading: boolean
  error: string | null
  result: T | null
}

const useInviteAction = <Args extends unknown[], Result>(
  sdk: InviteSDK | null,
  fn: (sdk: InviteSDK, ...args: Args) => Promise<Result>,
) => {
  const [state, setState] = useState<InviteActionState<Result>>({
    loading: false,
    error: null,
    result: null,
  })

  const run = useCallback(
    async (...args: Args): Promise<Result> => {
      if (!sdk) throw new Error("InviteSDK not initialized")

      setState({ loading: true, error: null, result: null })

      try {
        const result = await fn(sdk, ...args)
        setState({ loading: false, error: null, result })
        return result
      } catch (err) {
        const msg = formatInviteError(err)
        setState({ loading: false, error: msg, result: null })
        throw err
      }
    },
    [sdk, fn],
  )

  return { ...state, run }
}

Then each hook becomes much simpler without changing its outward behaviour:

export const useJoin = (env: contractEnv = "production"): UseJoinResult => {
  const { sdk } = useInviteSDK(env)

  const { run, loading, error, result } = useInviteAction<
    [`0x${string}`, `0x${string}`],
    `0x${string}`
  >(sdk, (s, myCode, inviterCode) => s.join(myCode, inviterCode))

  return {
    join: run,
    loading,
    error,
    txHash: result,
  }
}
export const useCollectBounty = (
  env: contractEnv = "production",
): UseCollectBountyResult => {
  const { sdk } = useInviteSDK(env)

  const { run, loading, error, result } = useInviteAction<[Address], BountyResult>(
    sdk,
    (s, invitee) => s.collectBounty(invitee),
  )

  return {
    collectBounty: run,
    loading,
    error,
    result,
  }
}
export const useCollectAllBounties = (
  env: contractEnv = "production",
): UseCollectAllBountiesResult => {
  const { sdk } = useInviteSDK(env)

  const { run, loading, error, result } = useInviteAction<[], BountyResult[]>(
    sdk,
    (s) => s.collectAllBounties(),
  )

  return {
    collectAllBounties: run,
    loading,
    error,
    results: result ?? [],
  }
}

This removes the triplicated try/catch/finally blocks and guarantees consistent InviteSDKError formatting across all action hooks.

2) Simplify useInviteStatus state + refetch pattern

You can group the status pieces into a single object and replace the tick counter with a fetchStatus callback that is reused for both useEffect and refetch:

interface InviteStatusInternal {
  user: InviteUser | null
  eligible: boolean
  pendingBounties: bigint
  pendingInvitees: Address[]
  reverificationDue: boolean
}

export const useInviteStatus = (invitee?: Address): InviteStatus => {
  const { address: connectedAddress } = useAccount()
  const { sdk, loading: sdkLoading, error: sdkError } = useInviteSDK()

  const target = invitee ?? connectedAddress

  const [status, setStatus] = useState<InviteStatusInternal>({
    user: null,
    eligible: false,
    pendingBounties: 0n,
    pendingInvitees: [],
    reverificationDue: false,
  })
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const fetchStatus = useCallback(async () => {
    if (!sdk || !target) {
      setStatus((prev) => ({ ...prev, user: null }))
      return
    }

    setLoading(true)
    setError(null)

    try {
      const [u, eligibilityResult, pending, pendingInvs] = await Promise.all([
        sdk.getUser(target),
        sdk.checkEligibilityDetails(target),
        sdk.getPendingBounties(target),
        sdk.getPendingInvitees(target),
      ])

      setStatus({
        user: u,
        eligible: eligibilityResult.eligible,
        reverificationDue: eligibilityResult.details.reverificationDue,
        pendingBounties: pending,
        pendingInvitees: pendingInvs,
      })
    } catch (err) {
      setError(formatInviteError(err)) // reuse same helper as above
    } finally {
      setLoading(false)
    }
  }, [sdk, target])

  useEffect(() => {
    void fetchStatus()
  }, [fetchStatus])

  return {
    ...status,
    loading: sdkLoading || loading,
    error: sdkError ?? error,
    refetch: fetchStatus,
  }
}

This keeps the exact behaviour but reduces the number of individual useState calls and removes the opaque tick refetch mechanism, making the flow easier to follow.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c3c01d87b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

*/
export const useInviteStatus = (invitee?: Address): InviteStatus => {
const { address: connectedAddress } = useAccount()
const { sdk, loading: sdkLoading, error: sdkError } = useInviteSDK()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Thread env into useInviteStatus SDK initialization

useInviteStatus always builds its SDK with useInviteSDK() defaults, so it silently reads production contracts even when the app is configured for staging/development. In non-production deployments this can return wrong status data (or throw on env/chain combinations missing production addresses), while useJoin/useCollect* in the same module may be pointed at a different env, creating inconsistent behavior in one screen.

Useful? React with 👍 / 👎.

Comment on lines +389 to +391
reverificationDue =
!inviteeWhitelisted ||
(inviterWhitelisted !== null && !inviterWhitelisted)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compute reverificationDue only from historical identity state

This marks reverificationDue as true whenever inviteeWhitelisted is false, but the contract/docs distinction is “previously authenticated and now lapsed” vs “never whitelisted”. As written, first-time unverified users (and also transient identity read failures that default to false) are misclassified as reverification cases, so downstream UIs using this flag will show the wrong remediation path.

Useful? React with 👍 / 👎.

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.

reverificationDue should only be true when the user was previously verified and is now no longer whitelisted.
You can check lastAuthenticated(user) for prior verification evidence

Comment on lines +95 to +97
if (!sdk || !target) {
setUser(null)
return
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear all status fields when invite target becomes unavailable

When sdk or target becomes falsy (disconnect/account switch), the effect only clears user and returns, leaving eligible, pendingBounties, pendingInvitees, and reverificationDue from the previous account in state. That leaks stale invite status into the next render and can show incorrect bounty/eligibility info until another successful fetch happens.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

@blueogin blueogin left a comment

Choose a reason for hiding this comment

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

no tests added for new invite SDK

Comment on lines +389 to +391
reverificationDue =
!inviteeWhitelisted ||
(inviterWhitelisted !== null && !inviterWhitelisted)
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.

reverificationDue should only be true when the user was previously verified and is now no longer whitelisted.
You can check lastAuthenticated(user) for prior verification evidence

@L03TJ3 L03TJ3 moved this from Ready-For-Assignment to In Review in GoodBounties Apr 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

feat: new @goodsdks/invite-sdk package — scope and integration plan

3 participants