@gatedpath/frontend-kit 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,10 @@ For agents
17
17
  table (`@gatedpath/agent-rules/failures`): NotEligible (with the credential state when you pass
18
18
  one), a wallet rejection, a wrong chain, not enough RBNT. Show `plainWords` and `fix.link`, never
19
19
  `error.message` alone; `kind: "unknown"` carries the error's own words in `raw`.
20
+ - Before a send, `checkRecipient(client, { recipient, chainId })` (or `useRecipientCheck(recipient)`)
21
+ answers what card 13's `RecipientGuard` will answer on chain: `ok`, a `verdict`, and `reasons`
22
+ with a sentence each. Show the sentences; never build the transaction while `ok` is false. It
23
+ means an address someone verified holds, not the right person.
20
24
  - Build: `npm run build`. Tests: `npm test` (Vitest, jsdom, a mocked transport; no network).
21
25
  ```
22
26
 
@@ -133,12 +137,51 @@ The panel renders only on chain 31337. On 151 or 153 it renders nothing whatever
133
137
  and the scaffold imports it only in the dev server behind a build-time constant, so a production
134
138
  bundle carries none of it; the scaffolder's integration test builds one and checks.
135
139
 
140
+ ## checkRecipient
141
+
142
+ ```tsx
143
+ import { useRecipientCheck } from '@gatedpath/frontend-kit';
144
+
145
+ const { check, isChecking, error } = useRecipientCheck(recipient, { allowContract });
146
+ // check: { ok, verdict, reasons: [{ kind, error, plainWords }], permission, chainId } | null
147
+ return (
148
+ <>
149
+ {isChecking && <p>Asking the network…</p>}
150
+ {check?.reasons.map((r) => <p key={r.kind}>{r.plainWords}</p>)}
151
+ <button disabled={!check?.ok} onClick={send}>Send through the guard</button>
152
+ </>
153
+ );
154
+ ```
155
+
156
+ A transfer that refuses to lose your money (card 13) goes through `RecipientGuard`, a contract
157
+ that asks the network whether the recipient has passed the identity gate on this chain and
158
+ refuses if not. `checkRecipient(client, { recipient, chainId, allowContract?, amount?, permission?,
159
+ registry? })` asks the same questions from the browser first, in the guard's order, so the page can
160
+ say no before it asks for a signature: the zero address (`ZeroRecipient`); code at the address,
161
+ which makes it a contract the guard refuses unless `allowContract` is true (`RecipientIsContract`;
162
+ a 23-byte EIP-7702 delegation is still a wallet); the bootstrap registry naming no permission
163
+ contract, or one with no code (`NoPermissionContract`); `permission.isAllowed` false
164
+ (`RecipientNotPermissioned`); and, when you pass `amount`, zero (`NothingToSend`). Each reason
165
+ carries the guard's error name and a sentence for the page. The permission contract is resolved
166
+ through the registry on every call, as the guard does, because its proxy admin has moved before;
167
+ pass `permission` to skip that when the page already holds it. `client` is a viem `PublicClient`
168
+ (`usePublicClient()` in wagmi); the call throws if the client is on another chain than `chainId`,
169
+ and an RPC failure is thrown, never turned into a verdict.
170
+
171
+ `useRecipientCheck(recipient, { chainId?, allowContract?, permission?, registry?, refreshInterval?,
172
+ enabled? })` runs it when a recipient is set, caches the answer under the chain id and the recipient
173
+ for 30 s, and exposes `refresh()`. The page's answer is a courtesy and the contract's is the rule:
174
+ `ok` means an address someone verified holds on this chain, or a contract the user said they mean.
175
+ A typo that lands on another verified address goes through, and the page should say so.
176
+
136
177
  ## Tests
137
178
 
138
- `npm test`: 80 Vitest tests in jsdom. `test/mock-chain.tsx` is a wagmi config over a `custom`
179
+ `npm test`: 122 Vitest tests in jsdom. `test/mock-chain.tsx` is a wagmi config over a `custom`
139
180
  transport that answers `eth_chainId`, `eth_blockNumber`, `eth_call` and Multicall3 `aggregate3`
181
+ (and, since `checkRecipient`, `eth_getCode` and the bootstrap registry's `getContractAddress`)
140
182
  from an in-memory state the tests mutate; nothing leaves the process and no address in it exists
141
- anywhere. The hook tests cover every state, that reads happen once and then only on a new block,
183
+ anywhere. `test/checkRecipient.test.tsx` covers every refusal the guard makes, the ok path, the
184
+ EIP-7702 case, a given permission contract, the chain mismatch and the hook's cache and refresh. The hook tests cover every state, that reads happen once and then only on a new block,
142
185
  manual refresh without a block, shared reads, the expiry window, errors and `enabled: false`. The
143
186
  component tests render every state, the gate's selection and override, the links (Access dApp,
144
187
  faucet, Vine) and the retry and refresh buttons. `test/dev-panel.test.tsx` runs the dev panel
package/dist/abi.d.ts CHANGED
@@ -15,6 +15,24 @@ export declare const verifierAbi: readonly [{
15
15
  readonly type: "bool";
16
16
  }];
17
17
  }];
18
+ /**
19
+ * The bootstrap registry, the one fixed address on both chains: `getContractAddress("permission")`
20
+ * names the permission contract. `checkRecipient` asks it on every call rather than remembering
21
+ * the answer, because the permission proxy's admin has moved before.
22
+ */
23
+ export declare const bootstrapRegistryAbi: readonly [{
24
+ readonly type: "function";
25
+ readonly name: "getContractAddress";
26
+ readonly stateMutability: "view";
27
+ readonly inputs: readonly [{
28
+ readonly name: "name";
29
+ readonly type: "string";
30
+ }];
31
+ readonly outputs: readonly [{
32
+ readonly name: "";
33
+ readonly type: "address";
34
+ }];
35
+ }];
18
36
  /** The network's permission contract: `isAllowed(address)` (RESEARCH.md question 7). */
19
37
  export declare const permissionAbi: readonly [{
20
38
  readonly type: "function";
package/dist/abi.js CHANGED
@@ -12,6 +12,20 @@ export const verifierAbi = [
12
12
  outputs: [{ name: '', type: 'bool' }],
13
13
  },
14
14
  ];
15
+ /**
16
+ * The bootstrap registry, the one fixed address on both chains: `getContractAddress("permission")`
17
+ * names the permission contract. `checkRecipient` asks it on every call rather than remembering
18
+ * the answer, because the permission proxy's admin has moved before.
19
+ */
20
+ export const bootstrapRegistryAbi = [
21
+ {
22
+ type: 'function',
23
+ name: 'getContractAddress',
24
+ stateMutability: 'view',
25
+ inputs: [{ name: 'name', type: 'string' }],
26
+ outputs: [{ name: '', type: 'address' }],
27
+ },
28
+ ];
15
29
  /** The network's permission contract: `isAllowed(address)` (RESEARCH.md question 7). */
16
30
  export const permissionAbi = [
17
31
  {
@@ -0,0 +1,73 @@
1
+ import type { PublicClient } from 'viem';
2
+ import type { Address } from './useEligibility.js';
3
+ /** `RecipientGuard.Verdict`, plus the one case where the guard cannot reach a verdict at all. */
4
+ export type RecipientVerdict = 'permissioned' | 'not-permissioned' | 'contract' | 'zero' | 'no-permission-contract';
5
+ /** One per named revert in `RecipientGuard`, except `NativeTransferFailed`, which only the send itself can find. */
6
+ export type RecipientReasonKind = 'zero-recipient' | 'recipient-is-contract' | 'recipient-not-permissioned' | 'no-permission-contract' | 'nothing-to-send';
7
+ export interface RecipientReason {
8
+ kind: RecipientReasonKind;
9
+ /** The guard's error name for this refusal, as a block explorer or `explainError` would show it. */
10
+ error: 'ZeroRecipient' | 'RecipientIsContract' | 'RecipientNotPermissioned' | 'NoPermissionContract' | 'NothingToSend';
11
+ /** A sentence for the page. */
12
+ plainWords: string;
13
+ }
14
+ export interface CheckRecipientOptions {
15
+ recipient: Address;
16
+ /** The chain the client reads. Refused when the client says it is on a different one. */
17
+ chainId: number;
18
+ /** True when the user has said a contract is what they mean to pay (`send(to, allowContract)`). Default false. */
19
+ allowContract?: boolean;
20
+ /** The amount about to be sent, when known; zero is refused the way `send` refuses an empty call. */
21
+ amount?: bigint;
22
+ /** Skip the registry and read this permission contract. Default: resolve it live, as the guard does. */
23
+ permission?: Address;
24
+ /** The bootstrap registry. Default: the shared address from @gatedpath/chains. */
25
+ registry?: Address;
26
+ }
27
+ export interface RecipientCheck {
28
+ /** True when the guard would let the send through to this recipient. */
29
+ ok: boolean;
30
+ verdict: RecipientVerdict;
31
+ /** Empty when `ok`. Otherwise every refusal the guard would raise, in the order it raises them. */
32
+ reasons: RecipientReason[];
33
+ recipient: Address;
34
+ chainId: number;
35
+ /** The permission contract the answer came from, when one was read. */
36
+ permission: Address | null;
37
+ }
38
+ /**
39
+ * Asks the network what `RecipientGuard.check(to)` would answer, and turns it into the refusals
40
+ * `send` would raise. Reads, in the guard's order: the recipient's code; then, for a wallet, the
41
+ * registry's code, the permission contract it names, that contract's code, and `isAllowed`. The
42
+ * permission contract is resolved on every call and never remembered, because its proxy admin has
43
+ * moved before (14 September 2026); pass `permission` to skip that when the page already knows it.
44
+ *
45
+ * What `ok` means: an address someone verified holds on this chain, or a contract the user has
46
+ * said they mean. It does not mean the right person: a typo that lands on another verified
47
+ * address goes through. Say that on the page.
48
+ */
49
+ export declare function checkRecipient(client: PublicClient, options: CheckRecipientOptions): Promise<RecipientCheck>;
50
+ export interface UseRecipientCheckOptions {
51
+ /** Chain to read on. Defaults to wagmi's current chain. */
52
+ chainId?: number;
53
+ allowContract?: boolean;
54
+ permission?: Address;
55
+ registry?: Address;
56
+ /** How long an answer is kept before the next render reads again. Default 30 000 ms. */
57
+ refreshInterval?: number;
58
+ enabled?: boolean;
59
+ }
60
+ export interface RecipientCheckResult {
61
+ /** The last answer, or null before the first one. */
62
+ check: RecipientCheck | null;
63
+ isChecking: boolean;
64
+ error: Error | null;
65
+ /** Read again now. */
66
+ refresh: () => Promise<void>;
67
+ }
68
+ /**
69
+ * `checkRecipient` as a hook: runs when `recipient` is set, caches the answer under the chain id
70
+ * and the recipient for `refreshInterval`, and reads again on `refresh()`. Mount it on the
71
+ * recipient field; show `check.reasons[*].plainWords` under it.
72
+ */
73
+ export declare function useRecipientCheck(recipient: Address | undefined, options?: UseRecipientCheckOptions): RecipientCheckResult;
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+ // checkRecipient: the question card 13's RecipientGuard asks on chain, asked from the browser
3
+ // first, so a page can refuse before it asks for a signature. Same reads, same order, same
4
+ // answers as `RecipientGuard.check` and `_requirePasses`; the contract's answer is still the rule
5
+ // and this one is the courtesy. No cache here: `useRecipientCheck` caches per chain so an answer
6
+ // from 153 is never shown for 151.
7
+ import { useCallback, useMemo } from 'react';
8
+ import { useQuery } from '@tanstack/react-query';
9
+ import { useChainId, usePublicClient } from 'wagmi';
10
+ import { addresses, chainById } from '@gatedpath/chains';
11
+ import { bootstrapRegistryAbi, permissionAbi } from './abi.js';
12
+ const ZERO = '0x0000000000000000000000000000000000000000';
13
+ const WORDS = {
14
+ 'zero-recipient': {
15
+ error: 'ZeroRecipient',
16
+ plainWords: 'That is the zero address. Nothing can be sent there, and the guard refuses it.',
17
+ },
18
+ 'recipient-is-contract': {
19
+ error: 'RecipientIsContract',
20
+ plainWords: 'This is a contract, not a wallet, so it has never been through the identity gate. The guard refuses it unless you say a contract is what you mean to pay.',
21
+ },
22
+ 'recipient-not-permissioned': {
23
+ error: 'RecipientNotPermissioned',
24
+ plainWords: 'No one verified holds this address on this chain. Money sent there would be gone, so the guard refuses.',
25
+ },
26
+ 'no-permission-contract': {
27
+ error: 'NoPermissionContract',
28
+ plainWords: 'This chain has no permission contract behind its registry, so there is nothing to check a recipient against. The guard refuses every send here.',
29
+ },
30
+ 'nothing-to-send': {
31
+ error: 'NothingToSend',
32
+ plainWords: 'The amount is zero. There is nothing to send.',
33
+ },
34
+ };
35
+ function reason(kind) {
36
+ return { kind, ...WORDS[kind] };
37
+ }
38
+ /** `RecipientGuard._isContract`: code at the address, unless it is an EIP-7702 delegation designator, which is still a wallet. */
39
+ function isContractCode(code) {
40
+ if (!code || code === '0x')
41
+ return false;
42
+ const bytes = (code.length - 2) / 2;
43
+ return !(bytes === 23 && code.toLowerCase().startsWith('0xef0100'));
44
+ }
45
+ /**
46
+ * Asks the network what `RecipientGuard.check(to)` would answer, and turns it into the refusals
47
+ * `send` would raise. Reads, in the guard's order: the recipient's code; then, for a wallet, the
48
+ * registry's code, the permission contract it names, that contract's code, and `isAllowed`. The
49
+ * permission contract is resolved on every call and never remembered, because its proxy admin has
50
+ * moved before (14 September 2026); pass `permission` to skip that when the page already knows it.
51
+ *
52
+ * What `ok` means: an address someone verified holds on this chain, or a contract the user has
53
+ * said they mean. It does not mean the right person: a typo that lands on another verified
54
+ * address goes through. Say that on the page.
55
+ */
56
+ export async function checkRecipient(client, options) {
57
+ const { recipient, chainId } = options;
58
+ if (client.chain && client.chain.id !== chainId) {
59
+ throw new Error(`checkRecipient: the client is on chain ${client.chain.id}, not ${chainId}; an answer from one chain is never shown for another`);
60
+ }
61
+ const reasons = [];
62
+ if (options.amount !== undefined && options.amount === 0n)
63
+ reasons.push(reason('nothing-to-send'));
64
+ const base = { recipient, chainId, permission: null };
65
+ if (recipient.toLowerCase() === ZERO) {
66
+ reasons.push(reason('zero-recipient'));
67
+ return { ...base, ok: false, verdict: 'zero', reasons };
68
+ }
69
+ const code = await client.getCode({ address: recipient });
70
+ if (isContractCode(code)) {
71
+ if (!options.allowContract)
72
+ reasons.push(reason('recipient-is-contract'));
73
+ return { ...base, ok: reasons.length === 0, verdict: 'contract', reasons };
74
+ }
75
+ let permission = options.permission ?? null;
76
+ if (!permission) {
77
+ const registry = options.registry ?? chainById(chainId)?.contracts['bootstrapRegistry']?.address ?? addresses.mainnet.bootstrapRegistry.address;
78
+ const registryCode = await client.getCode({ address: registry });
79
+ const named = registryCode && registryCode !== '0x'
80
+ ? await client.readContract({ address: registry, abi: bootstrapRegistryAbi, functionName: 'getContractAddress', args: ['permission'] })
81
+ : ZERO;
82
+ const permissionCode = named.toLowerCase() === ZERO ? '0x' : await client.getCode({ address: named });
83
+ if (!permissionCode || permissionCode === '0x') {
84
+ reasons.push(reason('no-permission-contract'));
85
+ return { ...base, ok: false, verdict: 'no-permission-contract', reasons };
86
+ }
87
+ permission = named;
88
+ }
89
+ const allowed = await client.readContract({ address: permission, abi: permissionAbi, functionName: 'isAllowed', args: [recipient] });
90
+ if (!allowed)
91
+ reasons.push(reason('recipient-not-permissioned'));
92
+ return { ...base, permission, ok: reasons.length === 0, verdict: allowed ? 'permissioned' : 'not-permissioned', reasons };
93
+ }
94
+ /**
95
+ * `checkRecipient` as a hook: runs when `recipient` is set, caches the answer under the chain id
96
+ * and the recipient for `refreshInterval`, and reads again on `refresh()`. Mount it on the
97
+ * recipient field; show `check.reasons[*].plainWords` under it.
98
+ */
99
+ export function useRecipientCheck(recipient, options = {}) {
100
+ const currentChainId = useChainId();
101
+ const chainId = options.chainId ?? currentChainId;
102
+ const client = usePublicClient({ chainId });
103
+ const allowContract = options.allowContract ?? false;
104
+ const enabled = (options.enabled ?? true) && Boolean(client && recipient);
105
+ const { permission, registry } = options;
106
+ const query = useQuery({
107
+ queryKey: ['redbelly-frontend-kit', 'recipient', chainId, recipient?.toLowerCase(), allowContract, permission, registry],
108
+ queryFn: () => checkRecipient(client, { recipient: recipient, chainId, allowContract, permission, registry }),
109
+ enabled,
110
+ staleTime: options.refreshInterval ?? 30_000,
111
+ refetchOnWindowFocus: false,
112
+ });
113
+ const refetch = query.refetch;
114
+ const refresh = useCallback(async () => {
115
+ await refetch();
116
+ }, [refetch]);
117
+ return useMemo(() => ({ check: query.data ?? null, isChecking: enabled && query.isPending, error: query.error ?? null, refresh }), [query.data, query.isPending, query.error, enabled, refresh]);
118
+ }
package/dist/index.d.ts CHANGED
@@ -4,7 +4,9 @@ export { receptorMockExpiry } from './expiry.js';
4
4
  export { notifyEligibilityChanged, onEligibilityChanged } from './events.js';
5
5
  export { EligibilityGate, NotConnected, NotVerified, Ineligible, Eligible, ExpiringSoon, Checking, EligibilityError, LastChecked, GasWarning, } from './components.js';
6
6
  export type { EligibilityGateProps, StateProps, NotVerifiedProps, IneligibleProps, ExpiringSoonProps, EligibilityErrorProps, LastCheckedProps, GasWarningProps, } from './components.js';
7
- export { verifierAbi, permissionAbi, receptorMockAbi, receptorMockStatusAbi, registerTokenAbi, identityRegistryAbi, trustedIssuersRegistryAbi, claimTopicsRegistryAbi } from './abi.js';
7
+ export { checkRecipient, useRecipientCheck } from './checkRecipient.js';
8
+ export type { RecipientCheck, RecipientReason, RecipientReasonKind, RecipientVerdict, CheckRecipientOptions, UseRecipientCheckOptions, RecipientCheckResult } from './checkRecipient.js';
9
+ export { verifierAbi, permissionAbi, bootstrapRegistryAbi, receptorMockAbi, receptorMockStatusAbi, registerTokenAbi, identityRegistryAbi, trustedIssuersRegistryAbi, claimTopicsRegistryAbi } from './abi.js';
8
10
  export { useRegister } from './useRegister.js';
9
11
  export type { RegisterState, RegisterResult, UseRegisterOptions } from './useRegister.js';
10
12
  export { ACCESS_URL, FAUCET_URL, VINE_INTERACTION_URL, VINE_FEES_URL } from './urls.js';
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ export { useEligibility } from './useEligibility.js';
2
2
  export { receptorMockExpiry } from './expiry.js';
3
3
  export { notifyEligibilityChanged, onEligibilityChanged } from './events.js';
4
4
  export { EligibilityGate, NotConnected, NotVerified, Ineligible, Eligible, ExpiringSoon, Checking, EligibilityError, LastChecked, GasWarning, } from './components.js';
5
- export { verifierAbi, permissionAbi, receptorMockAbi, receptorMockStatusAbi, registerTokenAbi, identityRegistryAbi, trustedIssuersRegistryAbi, claimTopicsRegistryAbi } from './abi.js';
5
+ export { checkRecipient, useRecipientCheck } from './checkRecipient.js';
6
+ export { verifierAbi, permissionAbi, bootstrapRegistryAbi, receptorMockAbi, receptorMockStatusAbi, registerTokenAbi, identityRegistryAbi, trustedIssuersRegistryAbi, claimTopicsRegistryAbi } from './abi.js';
6
7
  export { useRegister } from './useRegister.js';
7
8
  export { ACCESS_URL, FAUCET_URL, VINE_INTERACTION_URL, VINE_FEES_URL } from './urls.js';
8
9
  export { explainError } from './explainError.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatedpath/frontend-kit",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "React components and one hook for the five eligibility states every Redbelly dApp has: not connected, connected but not verified on the network, verified but ineligible for this action, eligible, credential expiring soon; plus explainError, the plain words for a viem or wagmi error from the shared failure table. wagmi + viem, headless with a small default style.",
5
5
  "license": "MIT",
6
6
  "repository": {