@gatedpath/frontend-kit 0.2.1 → 0.2.3

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
 
@@ -58,6 +62,23 @@ State derivation, in order: no address is `disconnected`; nothing cached and an
58
62
  says; `isEligible` false is `ineligible`; `isEligible` true with an expiry inside the window is
59
63
  `expiring`; otherwise `eligible`.
60
64
 
65
+ ## The register hook
66
+
67
+ ```tsx
68
+ const r = useRegister(tokenAddress, address);
69
+ // r.state: 'disconnected' | 'checking' | 'not-a-register' | 'registered' | 'not-registered' | 'frozen' | 'paused' | 'error'
70
+ ```
71
+
72
+ `useRegister(token, wallet)` reads an ERC-3643 register (a T-REX token, the shape of every real asset
73
+ on this network) and, when a wallet is given, whether that register's identity registry has verified
74
+ it. It is the third gate a holder meets, after `permission.isAllowed` and a dApp's recipe, which
75
+ `useEligibility` reads. The result carries the token's facts (name, symbol, decimals, supply, T-REX
76
+ version, paused), the registry's shape (trusted issuers, required claim topics, and `gate`:
77
+ `registration only` when no topics are required, `claims` otherwise) and the wallet's standing
78
+ (verified, registered, frozen, balance). Reads are batched through Multicall3, cached per chain and
79
+ refetched on a new block. Only the issuer can register a wallet, so `not-registered` is information
80
+ for the page, never a button. The ops kit's `readRegister` is the same read from a script.
81
+
61
82
  ## The components
62
83
 
63
84
  | Component | State | Says |
@@ -116,12 +137,51 @@ The panel renders only on chain 31337. On 151 or 153 it renders nothing whatever
116
137
  and the scaffold imports it only in the dev server behind a build-time constant, so a production
117
138
  bundle carries none of it; the scaffolder's integration test builds one and checks.
118
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
+
119
177
  ## Tests
120
178
 
121
- `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`
122
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`)
123
182
  from an in-memory state the tests mutate; nothing leaves the process and no address in it exists
124
- 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,
125
185
  manual refresh without a block, shared reads, the expiry window, errors and `enabled: false`. The
126
186
  component tests render every state, the gate's selection and override, the links (Access dApp,
127
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";
@@ -96,3 +114,234 @@ export declare const receptorMockStatusAbi: readonly [{
96
114
  readonly type: "uint8";
97
115
  }];
98
116
  }];
117
+ /**
118
+ * ERC-3643 (T-REX 4.1.x), the shape of every real register on this network (RESEARCH.md source log,
119
+ * 12 October 2026): the token, its identity registry, the trusted-issuers and claim-topics registries.
120
+ * Reads only; the interface was confirmed against a live mainnet register before it was written here.
121
+ */
122
+ export declare const registerTokenAbi: readonly [{
123
+ readonly type: "function";
124
+ readonly name: "identityRegistry";
125
+ readonly stateMutability: "view";
126
+ readonly inputs: readonly [];
127
+ readonly outputs: readonly [{
128
+ readonly name: "";
129
+ readonly type: "address";
130
+ }];
131
+ }, {
132
+ readonly type: "function";
133
+ readonly name: "compliance";
134
+ readonly stateMutability: "view";
135
+ readonly inputs: readonly [];
136
+ readonly outputs: readonly [{
137
+ readonly name: "";
138
+ readonly type: "address";
139
+ }];
140
+ }, {
141
+ readonly type: "function";
142
+ readonly name: "onchainID";
143
+ readonly stateMutability: "view";
144
+ readonly inputs: readonly [];
145
+ readonly outputs: readonly [{
146
+ readonly name: "";
147
+ readonly type: "address";
148
+ }];
149
+ }, {
150
+ readonly type: "function";
151
+ readonly name: "name";
152
+ readonly stateMutability: "view";
153
+ readonly inputs: readonly [];
154
+ readonly outputs: readonly [{
155
+ readonly name: "";
156
+ readonly type: "string";
157
+ }];
158
+ }, {
159
+ readonly type: "function";
160
+ readonly name: "symbol";
161
+ readonly stateMutability: "view";
162
+ readonly inputs: readonly [];
163
+ readonly outputs: readonly [{
164
+ readonly name: "";
165
+ readonly type: "string";
166
+ }];
167
+ }, {
168
+ readonly type: "function";
169
+ readonly name: "decimals";
170
+ readonly stateMutability: "view";
171
+ readonly inputs: readonly [];
172
+ readonly outputs: readonly [{
173
+ readonly name: "";
174
+ readonly type: "uint8";
175
+ }];
176
+ }, {
177
+ readonly type: "function";
178
+ readonly name: "totalSupply";
179
+ readonly stateMutability: "view";
180
+ readonly inputs: readonly [];
181
+ readonly outputs: readonly [{
182
+ readonly name: "";
183
+ readonly type: "uint256";
184
+ }];
185
+ }, {
186
+ readonly type: "function";
187
+ readonly name: "version";
188
+ readonly stateMutability: "view";
189
+ readonly inputs: readonly [];
190
+ readonly outputs: readonly [{
191
+ readonly name: "";
192
+ readonly type: "string";
193
+ }];
194
+ }, {
195
+ readonly type: "function";
196
+ readonly name: "paused";
197
+ readonly stateMutability: "view";
198
+ readonly inputs: readonly [];
199
+ readonly outputs: readonly [{
200
+ readonly name: "";
201
+ readonly type: "bool";
202
+ }];
203
+ }, {
204
+ readonly type: "function";
205
+ readonly name: "isFrozen";
206
+ readonly stateMutability: "view";
207
+ readonly inputs: readonly [{
208
+ readonly name: "wallet";
209
+ readonly type: "address";
210
+ }];
211
+ readonly outputs: readonly [{
212
+ readonly name: "";
213
+ readonly type: "bool";
214
+ }];
215
+ }, {
216
+ readonly type: "function";
217
+ readonly name: "getFrozenTokens";
218
+ readonly stateMutability: "view";
219
+ readonly inputs: readonly [{
220
+ readonly name: "wallet";
221
+ readonly type: "address";
222
+ }];
223
+ readonly outputs: readonly [{
224
+ readonly name: "";
225
+ readonly type: "uint256";
226
+ }];
227
+ }, {
228
+ readonly type: "function";
229
+ readonly name: "balanceOf";
230
+ readonly stateMutability: "view";
231
+ readonly inputs: readonly [{
232
+ readonly name: "wallet";
233
+ readonly type: "address";
234
+ }];
235
+ readonly outputs: readonly [{
236
+ readonly name: "";
237
+ readonly type: "uint256";
238
+ }];
239
+ }];
240
+ export declare const identityRegistryAbi: readonly [{
241
+ readonly type: "function";
242
+ readonly name: "identityStorage";
243
+ readonly stateMutability: "view";
244
+ readonly inputs: readonly [];
245
+ readonly outputs: readonly [{
246
+ readonly name: "";
247
+ readonly type: "address";
248
+ }];
249
+ }, {
250
+ readonly type: "function";
251
+ readonly name: "issuersRegistry";
252
+ readonly stateMutability: "view";
253
+ readonly inputs: readonly [];
254
+ readonly outputs: readonly [{
255
+ readonly name: "";
256
+ readonly type: "address";
257
+ }];
258
+ }, {
259
+ readonly type: "function";
260
+ readonly name: "topicsRegistry";
261
+ readonly stateMutability: "view";
262
+ readonly inputs: readonly [];
263
+ readonly outputs: readonly [{
264
+ readonly name: "";
265
+ readonly type: "address";
266
+ }];
267
+ }, {
268
+ readonly type: "function";
269
+ readonly name: "isVerified";
270
+ readonly stateMutability: "view";
271
+ readonly inputs: readonly [{
272
+ readonly name: "wallet";
273
+ readonly type: "address";
274
+ }];
275
+ readonly outputs: readonly [{
276
+ readonly name: "";
277
+ readonly type: "bool";
278
+ }];
279
+ }, {
280
+ readonly type: "function";
281
+ readonly name: "identity";
282
+ readonly stateMutability: "view";
283
+ readonly inputs: readonly [{
284
+ readonly name: "wallet";
285
+ readonly type: "address";
286
+ }];
287
+ readonly outputs: readonly [{
288
+ readonly name: "";
289
+ readonly type: "address";
290
+ }];
291
+ }, {
292
+ readonly type: "function";
293
+ readonly name: "investorCountry";
294
+ readonly stateMutability: "view";
295
+ readonly inputs: readonly [{
296
+ readonly name: "wallet";
297
+ readonly type: "address";
298
+ }];
299
+ readonly outputs: readonly [{
300
+ readonly name: "";
301
+ readonly type: "uint16";
302
+ }];
303
+ }, {
304
+ readonly type: "function";
305
+ readonly name: "contains";
306
+ readonly stateMutability: "view";
307
+ readonly inputs: readonly [{
308
+ readonly name: "wallet";
309
+ readonly type: "address";
310
+ }];
311
+ readonly outputs: readonly [{
312
+ readonly name: "";
313
+ readonly type: "bool";
314
+ }];
315
+ }];
316
+ export declare const trustedIssuersRegistryAbi: readonly [{
317
+ readonly type: "function";
318
+ readonly name: "getTrustedIssuers";
319
+ readonly stateMutability: "view";
320
+ readonly inputs: readonly [];
321
+ readonly outputs: readonly [{
322
+ readonly name: "";
323
+ readonly type: "address[]";
324
+ }];
325
+ }, {
326
+ readonly type: "function";
327
+ readonly name: "getTrustedIssuerClaimTopics";
328
+ readonly stateMutability: "view";
329
+ readonly inputs: readonly [{
330
+ readonly name: "issuer";
331
+ readonly type: "address";
332
+ }];
333
+ readonly outputs: readonly [{
334
+ readonly name: "";
335
+ readonly type: "uint256[]";
336
+ }];
337
+ }];
338
+ export declare const claimTopicsRegistryAbi: readonly [{
339
+ readonly type: "function";
340
+ readonly name: "getClaimTopics";
341
+ readonly stateMutability: "view";
342
+ readonly inputs: readonly [];
343
+ readonly outputs: readonly [{
344
+ readonly name: "";
345
+ readonly type: "uint256[]";
346
+ }];
347
+ }];
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
  {
@@ -77,3 +91,38 @@ export const receptorMockStatusAbi = [
77
91
  outputs: [{ name: '', type: 'uint8' }],
78
92
  },
79
93
  ];
94
+ /**
95
+ * ERC-3643 (T-REX 4.1.x), the shape of every real register on this network (RESEARCH.md source log,
96
+ * 12 October 2026): the token, its identity registry, the trusted-issuers and claim-topics registries.
97
+ * Reads only; the interface was confirmed against a live mainnet register before it was written here.
98
+ */
99
+ export const registerTokenAbi = [
100
+ { type: 'function', name: 'identityRegistry', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
101
+ { type: 'function', name: 'compliance', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
102
+ { type: 'function', name: 'onchainID', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
103
+ { type: 'function', name: 'name', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] },
104
+ { type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] },
105
+ { type: 'function', name: 'decimals', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'uint8' }] },
106
+ { type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'uint256' }] },
107
+ { type: 'function', name: 'version', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] },
108
+ { type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'bool' }] },
109
+ { type: 'function', name: 'isFrozen', stateMutability: 'view', inputs: [{ name: 'wallet', type: 'address' }], outputs: [{ name: '', type: 'bool' }] },
110
+ { type: 'function', name: 'getFrozenTokens', stateMutability: 'view', inputs: [{ name: 'wallet', type: 'address' }], outputs: [{ name: '', type: 'uint256' }] },
111
+ { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'wallet', type: 'address' }], outputs: [{ name: '', type: 'uint256' }] },
112
+ ];
113
+ export const identityRegistryAbi = [
114
+ { type: 'function', name: 'identityStorage', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
115
+ { type: 'function', name: 'issuersRegistry', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
116
+ { type: 'function', name: 'topicsRegistry', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address' }] },
117
+ { type: 'function', name: 'isVerified', stateMutability: 'view', inputs: [{ name: 'wallet', type: 'address' }], outputs: [{ name: '', type: 'bool' }] },
118
+ { type: 'function', name: 'identity', stateMutability: 'view', inputs: [{ name: 'wallet', type: 'address' }], outputs: [{ name: '', type: 'address' }] },
119
+ { type: 'function', name: 'investorCountry', stateMutability: 'view', inputs: [{ name: 'wallet', type: 'address' }], outputs: [{ name: '', type: 'uint16' }] },
120
+ { type: 'function', name: 'contains', stateMutability: 'view', inputs: [{ name: 'wallet', type: 'address' }], outputs: [{ name: '', type: 'bool' }] },
121
+ ];
122
+ export const trustedIssuersRegistryAbi = [
123
+ { type: 'function', name: 'getTrustedIssuers', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'address[]' }] },
124
+ { type: 'function', name: 'getTrustedIssuerClaimTopics', stateMutability: 'view', inputs: [{ name: 'issuer', type: 'address' }], outputs: [{ name: '', type: 'uint256[]' }] },
125
+ ];
126
+ export const claimTopicsRegistryAbi = [
127
+ { type: 'function', name: 'getClaimTopics', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'uint256[]' }] },
128
+ ];
@@ -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
+ }
@@ -5,7 +5,7 @@ export interface DevStatePanelProps {
5
5
  className?: string;
6
6
  }
7
7
  /**
8
- * The dev-only state switcher (PLAN.md 18.1). Lists the five wallets `npm run dev` seeded, marks
8
+ * The dev-only state switcher (the local loop). Lists the five wallets `npm run dev` seeded, marks
9
9
  * the connected one, and flips the connected wallet through the five credential states by calling
10
10
  * `setStatus` on the `ReceptorMock` as the deployer, through Anvil's impersonation RPC. Every
11
11
  * mounted `useEligibility` for that wallet refetches on the resulting signal, so the page moves
@@ -23,7 +23,7 @@ function rpc(client, method, params = []) {
23
23
  return client.request({ method, params });
24
24
  }
25
25
  /**
26
- * The dev-only state switcher (PLAN.md 18.1). Lists the five wallets `npm run dev` seeded, marks
26
+ * The dev-only state switcher (the local loop). Lists the five wallets `npm run dev` seeded, marks
27
27
  * the connected one, and flips the connected wallet through the five credential states by calling
28
28
  * `setStatus` on the `ReceptorMock` as the deployer, through Anvil's impersonation RPC. Every
29
29
  * mounted `useEligibility` for that wallet refetches on the resulting signal, so the page moves
package/dist/dev/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // `@gatedpath/frontend-kit/dev`: the local-loop pieces (PLAN.md 18.1). Import this path only from
1
+ // `@gatedpath/frontend-kit/dev`: the local-loop pieces (the local loop). Import this path only from
2
2
  // code a dev server runs; the scaffold's `web/src/components/DevState.tsx` does, behind a build-time
3
3
  // check that leaves nothing of it in a production bundle.
4
4
  export { DevStatePanel } from './DevStatePanel.js';
@@ -12,9 +12,9 @@ export interface LocalWallet {
12
12
  note: string;
13
13
  }
14
14
  /**
15
- * `deployments/local.json` as `scripts/dev.mjs` writes it in a scaffold (PLAN.md 18.1). `deployer`
15
+ * `deployments/local.json` as `scripts/dev.mjs` writes it in a scaffold (the local loop). `deployer`
16
16
  * is Anvil account 7, the account that deployed the mock and holds the contract's admin roles; the
17
- * panel impersonates it to call `setStatus`. Accounts 0 and 1 are never used (RESEARCH.md 30).
17
+ * panel impersonates it to call `setStatus`. Accounts 0 and 1 are never used (research question 30 on the builder site).
18
18
  */
19
19
  export interface LocalDeployment {
20
20
  chainId: number;
package/dist/index.d.ts CHANGED
@@ -4,7 +4,11 @@ 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 } 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';
10
+ export { useRegister } from './useRegister.js';
11
+ export type { RegisterState, RegisterResult, UseRegisterOptions } from './useRegister.js';
8
12
  export { ACCESS_URL, FAUCET_URL, VINE_INTERACTION_URL, VINE_FEES_URL } from './urls.js';
9
13
  export { explainError } from './explainError.js';
10
14
  export type { ExplainedError, ExplainErrorOptions } from './explainError.js';
package/dist/index.js CHANGED
@@ -2,6 +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 } from './abi.js';
5
+ export { checkRecipient, useRecipientCheck } from './checkRecipient.js';
6
+ export { verifierAbi, permissionAbi, bootstrapRegistryAbi, receptorMockAbi, receptorMockStatusAbi, registerTokenAbi, identityRegistryAbi, trustedIssuersRegistryAbi, claimTopicsRegistryAbi } from './abi.js';
7
+ export { useRegister } from './useRegister.js';
6
8
  export { ACCESS_URL, FAUCET_URL, VINE_INTERACTION_URL, VINE_FEES_URL } from './urls.js';
7
9
  export { explainError } from './explainError.js';
@@ -0,0 +1,55 @@
1
+ import type { Address } from './useEligibility.js';
2
+ /**
3
+ * Where a wallet stands with one ERC-3643 register, in words a page can switch on.
4
+ * `not-a-register`: the address does not answer `identityRegistry()` with a contract.
5
+ * `registered`: the register's identity registry has verified this wallet.
6
+ * `not-registered`: it has not; only the issuer can change that.
7
+ * `frozen`: verified, but the issuer has frozen this wallet's tokens.
8
+ * `paused`: the issuer has paused every transfer of this register.
9
+ */
10
+ export type RegisterState = 'disconnected' | 'checking' | 'not-a-register' | 'registered' | 'not-registered' | 'frozen' | 'paused' | 'error';
11
+ export interface UseRegisterOptions {
12
+ chainId?: number;
13
+ /** Refetch floor in milliseconds; the reads also refetch on every new block. Default 30 000. */
14
+ refreshInterval?: number;
15
+ watchBlocks?: boolean;
16
+ enabled?: boolean;
17
+ }
18
+ export interface RegisterResult {
19
+ state: RegisterState;
20
+ /** The token's own facts, when it is a register. */
21
+ token: {
22
+ name: string | null;
23
+ symbol: string | null;
24
+ decimals: number | null;
25
+ totalSupply: bigint | null;
26
+ version: string | null;
27
+ paused: boolean | null;
28
+ identityRegistry: Address | null;
29
+ };
30
+ /** The registry's shape: which issuers it trusts and which claim topics it requires. */
31
+ registry: {
32
+ trustedIssuers: Address[];
33
+ claimTopics: bigint[];
34
+ gate: 'registration only' | 'claims' | null;
35
+ };
36
+ /** The wallet's standing, when a wallet was given. */
37
+ wallet: {
38
+ isVerified: boolean | null;
39
+ registered: boolean | null;
40
+ frozen: boolean | null;
41
+ balance: bigint | null;
42
+ } | null;
43
+ blockNumber: bigint | undefined;
44
+ isFetching: boolean;
45
+ error: Error | null;
46
+ refresh: () => Promise<void>;
47
+ }
48
+ /**
49
+ * Reads an ERC-3643 register (a T-REX token) and, if a wallet is given, whether that register's
50
+ * identity registry has verified it. The third of the three gates a holder meets on this network,
51
+ * after `permission.isAllowed` and a dApp's recipe (`useEligibility`). Read-only; batched through
52
+ * Multicall3 by wagmi; cached per chain and refetched on a new block. Only the issuer can register a
53
+ * wallet, so a `not-registered` answer is information for the page, never a button.
54
+ */
55
+ export declare function useRegister(token: Address | undefined, wallet: Address | undefined, options?: UseRegisterOptions): RegisterResult;
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+ import { useEffect, useMemo, useRef } from 'react';
3
+ import { useBlockNumber, useChainId, useReadContracts } from 'wagmi';
4
+ import { registerTokenAbi, identityRegistryAbi, claimTopicsRegistryAbi, trustedIssuersRegistryAbi } from './abi.js';
5
+ const ZERO = '0x0000000000000000000000000000000000000000';
6
+ /**
7
+ * Reads an ERC-3643 register (a T-REX token) and, if a wallet is given, whether that register's
8
+ * identity registry has verified it. The third of the three gates a holder meets on this network,
9
+ * after `permission.isAllowed` and a dApp's recipe (`useEligibility`). Read-only; batched through
10
+ * Multicall3 by wagmi; cached per chain and refetched on a new block. Only the issuer can register a
11
+ * wallet, so a `not-registered` answer is information for the page, never a button.
12
+ */
13
+ export function useRegister(token, wallet, options = {}) {
14
+ const currentChainId = useChainId();
15
+ const chainId = options.chainId ?? currentChainId;
16
+ const refreshInterval = options.refreshInterval ?? 30_000;
17
+ const watchBlocks = options.watchBlocks ?? true;
18
+ const enabled = (options.enabled ?? true) && Boolean(token);
19
+ const block = useBlockNumber({ chainId, watch: watchBlocks && enabled, query: { enabled: watchBlocks && enabled } });
20
+ const query = { enabled, staleTime: refreshInterval, refetchInterval: refreshInterval, refetchOnWindowFocus: false };
21
+ // Pass one: the token. `identityRegistry()` decides whether there is a register at all.
22
+ const tokenReads = useReadContracts({
23
+ allowFailure: true,
24
+ contracts: [
25
+ { address: (token ?? ZERO), abi: registerTokenAbi, functionName: 'identityRegistry', chainId },
26
+ { address: (token ?? ZERO), abi: registerTokenAbi, functionName: 'name', chainId },
27
+ { address: (token ?? ZERO), abi: registerTokenAbi, functionName: 'symbol', chainId },
28
+ { address: (token ?? ZERO), abi: registerTokenAbi, functionName: 'decimals', chainId },
29
+ { address: (token ?? ZERO), abi: registerTokenAbi, functionName: 'totalSupply', chainId },
30
+ { address: (token ?? ZERO), abi: registerTokenAbi, functionName: 'version', chainId },
31
+ { address: (token ?? ZERO), abi: registerTokenAbi, functionName: 'paused', chainId },
32
+ ],
33
+ query,
34
+ });
35
+ const t = tokenReads.data;
36
+ const registryAddress = (t?.[0]?.status === 'success' ? t[0].result : null) ?? null;
37
+ const isRegister = registryAddress !== null && registryAddress !== ZERO;
38
+ // Pass two: the registry's shape and the wallet's standing, once the registry is known.
39
+ // The wallet reads are always in the batch, against the zero address when no wallet is given, so the
40
+ // batch has one shape and the results can be read by position. Those answers are ignored without a wallet.
41
+ const who = (wallet ?? ZERO);
42
+ const registryFor = (registryAddress ?? ZERO);
43
+ const tokenFor = (token ?? ZERO);
44
+ const detailReads = useReadContracts({
45
+ allowFailure: true,
46
+ contracts: [
47
+ { address: registryFor, abi: identityRegistryAbi, functionName: 'issuersRegistry', chainId },
48
+ { address: registryFor, abi: identityRegistryAbi, functionName: 'topicsRegistry', chainId },
49
+ { address: registryFor, abi: identityRegistryAbi, functionName: 'isVerified', args: [who], chainId },
50
+ { address: registryFor, abi: identityRegistryAbi, functionName: 'identity', args: [who], chainId },
51
+ { address: tokenFor, abi: registerTokenAbi, functionName: 'isFrozen', args: [who], chainId },
52
+ { address: tokenFor, abi: registerTokenAbi, functionName: 'balanceOf', args: [who], chainId },
53
+ ],
54
+ query: { ...query, enabled: enabled && isRegister },
55
+ });
56
+ const d = detailReads.data;
57
+ const issuersRegistry = d?.[0]?.status === 'success' ? d[0].result : null;
58
+ const topicsRegistry = d?.[1]?.status === 'success' ? d[1].result : null;
59
+ // Pass three: the issuers and the topics.
60
+ const shapeReads = useReadContracts({
61
+ allowFailure: true,
62
+ contracts: [
63
+ { address: (issuersRegistry ?? ZERO), abi: trustedIssuersRegistryAbi, functionName: 'getTrustedIssuers', chainId },
64
+ { address: (topicsRegistry ?? ZERO), abi: claimTopicsRegistryAbi, functionName: 'getClaimTopics', chainId },
65
+ ],
66
+ query: { ...query, enabled: enabled && Boolean(issuersRegistry && topicsRegistry) },
67
+ });
68
+ const s = shapeReads.data;
69
+ const refresh = async () => {
70
+ await Promise.all([tokenReads.refetch(), detailReads.refetch(), shapeReads.refetch()]);
71
+ };
72
+ // Refetch on a new block, and only on a new block: wagmi polls the block number for us.
73
+ const lastBlock = useRef(undefined);
74
+ const refetchToken = tokenReads.refetch;
75
+ const refetchDetail = detailReads.refetch;
76
+ const refetchShape = shapeReads.refetch;
77
+ useEffect(() => {
78
+ if (!watchBlocks || !enabled || block.data === undefined)
79
+ return;
80
+ if (lastBlock.current === undefined) {
81
+ lastBlock.current = block.data;
82
+ return;
83
+ }
84
+ if (block.data !== lastBlock.current) {
85
+ lastBlock.current = block.data;
86
+ void refetchToken();
87
+ if (isRegister)
88
+ void refetchDetail();
89
+ if (issuersRegistry && topicsRegistry)
90
+ void refetchShape();
91
+ }
92
+ }, [block.data, watchBlocks, enabled, isRegister, issuersRegistry, topicsRegistry, refetchToken, refetchDetail, refetchShape]);
93
+ return useMemo(() => {
94
+ const ok = (r) => (r && r.status === 'success' ? r.result : null);
95
+ const error = (tokenReads.error ?? detailReads.error ?? shapeReads.error);
96
+ const trustedIssuers = (ok(s?.[0]) ?? []);
97
+ const claimTopics = (ok(s?.[1]) ?? []);
98
+ const paused = ok(t?.[6]);
99
+ const isVerified = wallet ? ok(d?.[2]) : null;
100
+ const identity = wallet ? ok(d?.[3]) : null;
101
+ const frozen = wallet ? ok(d?.[4]) : null;
102
+ const balance = wallet ? ok(d?.[5]) : null;
103
+ let state;
104
+ if (!token)
105
+ state = 'disconnected';
106
+ else if (error)
107
+ state = 'error';
108
+ else if (tokenReads.isPending)
109
+ state = 'checking';
110
+ else if (!isRegister)
111
+ state = 'not-a-register';
112
+ else if (wallet && detailReads.isPending)
113
+ state = 'checking';
114
+ else if (paused)
115
+ state = 'paused';
116
+ else if (wallet && frozen)
117
+ state = 'frozen';
118
+ else if (wallet)
119
+ state = isVerified ? 'registered' : 'not-registered';
120
+ else
121
+ state = 'registered';
122
+ return {
123
+ state,
124
+ token: {
125
+ name: ok(t?.[1]),
126
+ symbol: ok(t?.[2]),
127
+ decimals: ok(t?.[3]),
128
+ totalSupply: ok(t?.[4]),
129
+ version: ok(t?.[5]),
130
+ paused,
131
+ identityRegistry: isRegister ? registryAddress : null,
132
+ },
133
+ registry: {
134
+ trustedIssuers,
135
+ claimTopics,
136
+ gate: isRegister && s ? (claimTopics.length === 0 ? 'registration only' : 'claims') : null,
137
+ },
138
+ wallet: wallet && isRegister ? { isVerified, registered: identity !== null && identity !== ZERO, frozen, balance } : null,
139
+ blockNumber: block.data,
140
+ isFetching: tokenReads.isFetching || detailReads.isFetching || shapeReads.isFetching,
141
+ error,
142
+ refresh,
143
+ };
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [token, wallet, t, d, s, isRegister, registryAddress, tokenReads.isPending, detailReads.isPending, tokenReads.isFetching, detailReads.isFetching, shapeReads.isFetching, tokenReads.error, detailReads.error, shapeReads.error, block.data]);
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatedpath/frontend-kit",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": {