@gatedpath/frontend-kit 0.1.0

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.
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { useChainId, useClient, useConnect, useConnection, useDisconnect } from 'wagmi';
5
+ import { encodeFunctionData } from 'viem';
6
+ import { readContract, waitForTransactionReceipt } from 'viem/actions';
7
+ import { receptorMockStatusAbi } from '../abi.js';
8
+ import { notifyEligibilityChanged } from '../events.js';
9
+ import { anvilAccountConnector } from './anvilConnector.js';
10
+ import { ELIGIBILITY_STATES, LOCAL_CHAIN_ID } from './localDeployment.js';
11
+ const STATE_HINT = {
12
+ NeverIssued: 'no credential',
13
+ Valid: 'passes the gate',
14
+ Expired: 'credential past its window',
15
+ Revoked: 'issuer revoked it',
16
+ WrongJurisdiction: 'credential does not satisfy the query',
17
+ };
18
+ function short(address) {
19
+ return `${address.slice(0, 6)}…${address.slice(-4)}`;
20
+ }
21
+ /** Sends one JSON-RPC request through wagmi's client for the local chain, methods Anvil adds included. */
22
+ function rpc(client, method, params = []) {
23
+ return client.request({ method, params });
24
+ }
25
+ /**
26
+ * The dev-only state switcher (PLAN.md 18.1). Lists the five wallets `npm run dev` seeded, marks
27
+ * the connected one, and flips the connected wallet through the five credential states by calling
28
+ * `setStatus` on the `ReceptorMock` as the deployer, through Anvil's impersonation RPC. Every
29
+ * mounted `useEligibility` for that wallet refetches on the resulting signal, so the page moves
30
+ * into the new state without a reload.
31
+ *
32
+ * It renders only when the wallet (or, unconnected, the app) is on chain 31337 and the deployment
33
+ * it was given says 31337 too. On 151 or 153 it renders nothing, whatever it was given. In a
34
+ * scaffold it is imported only by the dev server and is absent from production bundles; the
35
+ * scaffolder's test asserts both.
36
+ */
37
+ export function DevStatePanel({ deployment, className }) {
38
+ const configChainId = useChainId();
39
+ const connection = useConnection();
40
+ const chainId = connection.chainId ?? configChainId;
41
+ const onLocal = chainId === LOCAL_CHAIN_ID && deployment.chainId === LOCAL_CHAIN_ID;
42
+ const client = useClient({ chainId: LOCAL_CHAIN_ID });
43
+ const connect = useConnect();
44
+ const disconnect = useDisconnect();
45
+ const [states, setStates] = useState(() => Object.fromEntries(deployment.wallets.map((w) => [w.address.toLowerCase(), w.state])));
46
+ const [busy, setBusy] = useState(null);
47
+ const [message, setMessage] = useState(null);
48
+ const requestId = BigInt(deployment.requestId);
49
+ const connected = connection.address?.toLowerCase();
50
+ const readStates = useCallback(async () => {
51
+ if (!client || !onLocal)
52
+ return;
53
+ const next = {};
54
+ const targets = [...deployment.wallets.map((w) => w.address), ...(connection.address ? [connection.address] : [])];
55
+ for (const address of targets) {
56
+ const status = await readContract(client, {
57
+ address: deployment.verifier,
58
+ abi: receptorMockStatusAbi,
59
+ functionName: 'eligibilityStatus',
60
+ args: [address, requestId],
61
+ });
62
+ next[address.toLowerCase()] = ELIGIBILITY_STATES[Number(status)] ?? 'NeverIssued';
63
+ }
64
+ setStates((prev) => ({ ...prev, ...next }));
65
+ }, [client, onLocal, deployment.wallets, deployment.verifier, requestId, connection.address]);
66
+ useEffect(() => {
67
+ void readStates().catch((e) => setMessage(e instanceof Error ? e.message : String(e)));
68
+ }, [readStates]);
69
+ const setState = useCallback(async (state) => {
70
+ if (!client || !connection.address)
71
+ return;
72
+ const wallet = connection.address;
73
+ const deployer = deployment.deployer.address;
74
+ setBusy(state);
75
+ setMessage(null);
76
+ try {
77
+ // The mock has no owner: any sender may call setStatus. The deployer is used because it is the
78
+ // account npm run dev funded and deployed from, and impersonation keeps that true whatever
79
+ // wallet is connected. Nothing here signs; Anvil does.
80
+ await rpc(client, 'anvil_impersonateAccount', [deployer]);
81
+ let hash;
82
+ try {
83
+ const data = encodeFunctionData({
84
+ abi: receptorMockStatusAbi,
85
+ functionName: 'setStatus',
86
+ args: [wallet, requestId, ELIGIBILITY_STATES.indexOf(state)],
87
+ });
88
+ hash = (await rpc(client, 'eth_sendTransaction', [{ from: deployer, to: deployment.verifier, data }]));
89
+ }
90
+ finally {
91
+ await rpc(client, 'anvil_stopImpersonatingAccount', [deployer]);
92
+ }
93
+ const receipt = await waitForTransactionReceipt(client, { hash });
94
+ setStates((prev) => ({ ...prev, [wallet.toLowerCase()]: state }));
95
+ setMessage(`${short(wallet)} is now ${state} (block ${receipt.blockNumber.toString()})`);
96
+ notifyEligibilityChanged(wallet);
97
+ }
98
+ catch (e) {
99
+ setMessage(e instanceof Error ? e.message : String(e));
100
+ }
101
+ finally {
102
+ setBusy(null);
103
+ }
104
+ }, [client, connection.address, deployment.deployer.address, deployment.verifier, requestId]);
105
+ // One connector per seeded wallet, created here and handed to wagmi on click, so the app's own
106
+ // wagmi config needs no dev wiring: nothing about Anvil accounts exists outside this panel.
107
+ const connectorFor = useMemo(() => {
108
+ const byIndex = new Map(deployment.wallets.map((w) => [w.index, anvilAccountConnector({ address: w.address, index: w.index, label: `Wallet ${w.index} (${w.state})` })]));
109
+ return (index) => byIndex.get(index);
110
+ }, [deployment.wallets]);
111
+ if (!onLocal)
112
+ return null;
113
+ const connectedRow = connected && deployment.wallets.find((w) => w.address.toLowerCase() === connected);
114
+ const connectedState = connected ? states[connected] : undefined;
115
+ return (_jsxs("section", { className: ['rb-devstate', className].filter(Boolean).join(' '), "aria-label": "Local dev state", children: [_jsxs("p", { className: "rb-devstate-title", children: ["Dev state", _jsxs("span", { className: "rb-devstate-muted", children: [' ', "Anvil chain ", LOCAL_CHAIN_ID, deployment.forkOf ? `, fork of ${deployment.forkOf}` : ', no fork', ", request id ", deployment.requestId, ". Dev server only; never on 151 or 153."] })] }), _jsxs("table", { className: "rb-devstate-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Wallet" }), _jsx("th", { children: "State" }), _jsx("th", { children: "Address" }), _jsx("th", {})] }) }), _jsx("tbody", { children: deployment.wallets.map((w) => {
116
+ const isConnected = connected === w.address.toLowerCase();
117
+ const connector = connectorFor(w.index);
118
+ const state = states[w.address.toLowerCase()] ?? w.state;
119
+ return (_jsxs("tr", { className: isConnected ? 'rb-devstate-connected' : undefined, "aria-current": isConnected ? 'true' : undefined, children: [_jsxs("td", { children: [w.index, isConnected ? _jsx("span", { className: "rb-devstate-mark", children: " connected" }) : null] }), _jsxs("td", { children: [state, " ", _jsxs("span", { className: "rb-devstate-muted", children: ["(", STATE_HINT[state], ")"] })] }), _jsx("td", { children: _jsx("code", { title: w.address, children: short(w.address) }) }), _jsx("td", { children: isConnected ? (_jsx("button", { type: "button", className: "rb-devstate-button", onClick: () => disconnect.mutate({}), children: "Disconnect" })) : connector ? (_jsx("button", { type: "button", className: "rb-devstate-button", disabled: connect.isPending, onClick: () => connect.mutate({ connector }), children: "Connect" })) : null })] }, w.address));
120
+ }) })] }), _jsxs("div", { className: "rb-devstate-actions", role: "group", "aria-label": "Set the connected wallet's state", children: [_jsx("span", { className: "rb-devstate-muted", children: connection.address
121
+ ? `Set ${connectedRow ? `wallet ${connectedRow.index}` : short(connection.address)} to:`
122
+ : 'Connect a wallet above to change its state.' }), ELIGIBILITY_STATES.map((s) => (_jsx("button", { type: "button", className: "rb-devstate-button", disabled: !connection.address || busy !== null || connectedState === s, "aria-pressed": connectedState === s, onClick: () => void setState(s), children: busy === s ? `${s}…` : s }, s)))] }), message ? _jsx("p", { className: "rb-devstate-muted rb-devstate-message", children: message }) : null] }));
123
+ }
@@ -0,0 +1,25 @@
1
+ import { type EIP1193RequestFn } from 'viem';
2
+ import type { Address } from '../useEligibility.js';
3
+ import type { LocalDeployment } from './localDeployment.js';
4
+ export interface AnvilAccountConnectorOptions {
5
+ /** The Anvil account to act as. Must be one the node has unlocked (its own ten, or an impersonated one). */
6
+ address: Address;
7
+ /** The account's index, for the connector's name. */
8
+ index: number;
9
+ /** A label for the button; defaults to `Wallet <index>`. */
10
+ label?: string;
11
+ }
12
+ /**
13
+ * A wagmi connector for one of Anvil's unlocked accounts. Every request goes straight to the
14
+ * chain's RPC: `eth_sendTransaction` is signed by the node, so the browser never holds a key
15
+ * and nothing is imported into a wallet extension. It only makes sense against a local Anvil
16
+ * (chain 31337); `npm run dev` in a scaffold adds one per seeded wallet in the dev server and
17
+ * none in a production build.
18
+ */
19
+ export declare function anvilAccountConnector({ address, index, label }: AnvilAccountConnectorOptions): import("wagmi").CreateConnectorFn<{
20
+ request: EIP1193RequestFn;
21
+ }, Record<string, unknown>, Record<string, unknown>>;
22
+ /** One connector per seeded wallet, in the file's order (NeverIssued first, Valid second). */
23
+ export declare function anvilAccountConnectors(deployment: LocalDeployment): import("wagmi").CreateConnectorFn<{
24
+ request: EIP1193RequestFn;
25
+ }, Record<string, unknown>, Record<string, unknown>>[];
@@ -0,0 +1,78 @@
1
+ import { createConnector } from 'wagmi';
2
+ import { getAddress, numberToHex } from 'viem';
3
+ /**
4
+ * A wagmi connector for one of Anvil's unlocked accounts. Every request goes straight to the
5
+ * chain's RPC: `eth_sendTransaction` is signed by the node, so the browser never holds a key
6
+ * and nothing is imported into a wallet extension. It only makes sense against a local Anvil
7
+ * (chain 31337); `npm run dev` in a scaffold adds one per seeded wallet in the dev server and
8
+ * none in a production build.
9
+ */
10
+ export function anvilAccountConnector({ address, index, label }) {
11
+ const account = getAddress(address);
12
+ let connected = false;
13
+ return createConnector((config) => ({
14
+ id: `anvil-account-${index}`,
15
+ name: label ?? `Wallet ${index}`,
16
+ type: 'anvilAccount',
17
+ async connect() {
18
+ connected = true;
19
+ // wagmi types the accounts by a `withCapabilities` generic; this connector has no capabilities to report.
20
+ return { accounts: [account], chainId: await this.getChainId() };
21
+ },
22
+ async disconnect() {
23
+ connected = false;
24
+ },
25
+ async getAccounts() {
26
+ return connected ? [account] : [];
27
+ },
28
+ async getChainId() {
29
+ const chain = config.chains[0];
30
+ if (!chain)
31
+ throw new Error('no chain configured');
32
+ return chain.id;
33
+ },
34
+ async isAuthorized() {
35
+ return connected;
36
+ },
37
+ async switchChain({ chainId }) {
38
+ const chain = config.chains.find((c) => c.id === chainId);
39
+ if (!chain)
40
+ throw new Error(`chain ${chainId} is not configured`);
41
+ return chain;
42
+ },
43
+ onAccountsChanged() { },
44
+ onChainChanged() { },
45
+ onDisconnect() {
46
+ connected = false;
47
+ config.emitter.emit('disconnect');
48
+ },
49
+ async getProvider() {
50
+ const chain = config.chains[0];
51
+ if (!chain)
52
+ throw new Error('no chain configured');
53
+ const url = chain.rpcUrls.default.http[0];
54
+ if (!url)
55
+ throw new Error('the chain has no HTTP RPC URL');
56
+ const request = (async ({ method, params }) => {
57
+ if (method === 'eth_accounts' || method === 'eth_requestAccounts')
58
+ return [account];
59
+ if (method === 'eth_chainId')
60
+ return numberToHex(chain.id);
61
+ const response = await fetch(url, {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? [] }),
65
+ });
66
+ const body = (await response.json());
67
+ if (body.error)
68
+ throw new Error(body.error.message ?? 'RPC error');
69
+ return body.result;
70
+ });
71
+ return { request };
72
+ },
73
+ }));
74
+ }
75
+ /** One connector per seeded wallet, in the file's order (NeverIssued first, Valid second). */
76
+ export function anvilAccountConnectors(deployment) {
77
+ return deployment.wallets.map((w) => anvilAccountConnector({ address: w.address, index: w.index, label: `Wallet ${w.index} (${w.state})` }));
78
+ }
@@ -0,0 +1,6 @@
1
+ export { DevStatePanel } from './DevStatePanel.js';
2
+ export type { DevStatePanelProps } from './DevStatePanel.js';
3
+ export { anvilAccountConnector, anvilAccountConnectors } from './anvilConnector.js';
4
+ export type { AnvilAccountConnectorOptions } from './anvilConnector.js';
5
+ export { parseLocalDeployment, ELIGIBILITY_STATES, LOCAL_CHAIN_ID } from './localDeployment.js';
6
+ export type { LocalDeployment, LocalWallet, EligibilityStateName } from './localDeployment.js';
@@ -0,0 +1,6 @@
1
+ // `@gatedpath/frontend-kit/dev`: the local-loop pieces (PLAN.md 18.1). Import this path only from
2
+ // code a dev server runs; the scaffold's `web/src/components/DevState.tsx` does, behind a build-time
3
+ // check that leaves nothing of it in a production bundle.
4
+ export { DevStatePanel } from './DevStatePanel.js';
5
+ export { anvilAccountConnector, anvilAccountConnectors } from './anvilConnector.js';
6
+ export { parseLocalDeployment, ELIGIBILITY_STATES, LOCAL_CHAIN_ID } from './localDeployment.js';
@@ -0,0 +1,37 @@
1
+ import type { Address } from '../useEligibility.js';
2
+ /** The chain id `npm run dev` gives its Anvil. The panel renders on this chain and no other. */
3
+ export declare const LOCAL_CHAIN_ID = 31337;
4
+ /** The five credential states, in `EligibilityStatus` enum order from @gatedpath/receptor-mock. */
5
+ export declare const ELIGIBILITY_STATES: readonly ["NeverIssued", "Valid", "Expired", "Revoked", "WrongJurisdiction"];
6
+ export type EligibilityStateName = (typeof ELIGIBILITY_STATES)[number];
7
+ /** One seeded wallet. `index` is the Anvil account index; the file never carries a key. */
8
+ export interface LocalWallet {
9
+ index: number;
10
+ state: EligibilityStateName;
11
+ address: Address;
12
+ note: string;
13
+ }
14
+ /**
15
+ * `deployments/local.json` as `scripts/dev.mjs` writes it in a scaffold (PLAN.md 18.1). `deployer`
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).
18
+ */
19
+ export interface LocalDeployment {
20
+ chainId: number;
21
+ forkOf: 151 | 153 | null;
22
+ verifier: Address;
23
+ contract: Address;
24
+ requestId: number;
25
+ deployer: {
26
+ index: number;
27
+ address: Address;
28
+ };
29
+ wallets: LocalWallet[];
30
+ startedAt: string;
31
+ }
32
+ /**
33
+ * Parses the JSON `next.config.ts` inlines as `NEXT_PUBLIC_LOCAL_DEPLOYMENT` in the dev server.
34
+ * Returns null for anything that is not a well-formed local deployment on chain 31337, so a
35
+ * stray value can never make the panel appear against a real network.
36
+ */
37
+ export declare function parseLocalDeployment(json: string | undefined | null): LocalDeployment | null;
@@ -0,0 +1,41 @@
1
+ /** The chain id `npm run dev` gives its Anvil. The panel renders on this chain and no other. */
2
+ export const LOCAL_CHAIN_ID = 31337;
3
+ /** The five credential states, in `EligibilityStatus` enum order from @gatedpath/receptor-mock. */
4
+ export const ELIGIBILITY_STATES = ['NeverIssued', 'Valid', 'Expired', 'Revoked', 'WrongJurisdiction'];
5
+ /**
6
+ * Parses the JSON `next.config.ts` inlines as `NEXT_PUBLIC_LOCAL_DEPLOYMENT` in the dev server.
7
+ * Returns null for anything that is not a well-formed local deployment on chain 31337, so a
8
+ * stray value can never make the panel appear against a real network.
9
+ */
10
+ export function parseLocalDeployment(json) {
11
+ if (!json)
12
+ return null;
13
+ let value;
14
+ try {
15
+ value = JSON.parse(json);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ if (!value || typeof value !== 'object')
21
+ return null;
22
+ const d = value;
23
+ const isAddress = (a) => typeof a === 'string' && /^0x[0-9a-fA-F]{40}$/.test(a);
24
+ if (d.chainId !== LOCAL_CHAIN_ID)
25
+ return null;
26
+ if (!isAddress(d.verifier) || !isAddress(d.contract))
27
+ return null;
28
+ if (typeof d.requestId !== 'number')
29
+ return null;
30
+ if (!d.deployer || !isAddress(d.deployer.address) || typeof d.deployer.index !== 'number')
31
+ return null;
32
+ if (!Array.isArray(d.wallets) || d.wallets.length !== ELIGIBILITY_STATES.length)
33
+ return null;
34
+ for (const [i, w] of d.wallets.entries()) {
35
+ if (!w || !isAddress(w.address) || w.state !== ELIGIBILITY_STATES[i] || typeof w.index !== 'number')
36
+ return null;
37
+ if (w.index < 2)
38
+ return null; // accounts 0 and 1 are never used
39
+ }
40
+ return d;
41
+ }
package/dist/dev.css ADDED
@@ -0,0 +1,37 @@
1
+ /* @gatedpath/frontend-kit/dev.css: the dev state panel's default style, on the same tokens as
2
+ styles.css. A separate file so a production build that loads styles.css carries nothing of the
3
+ panel; the scaffold's dev-only loader is the one place that imports it. */
4
+ .rb-devstate {
5
+ font-family: var(--sl-font, 'Readex Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
6
+ color: var(--rb-color-text, #283A46);
7
+ background: var(--rb-color-surface, #EFF3F6);
8
+ border: 1px dashed var(--rb-color-hairline-strong, #848B91);
9
+ border-radius: var(--rb-radius-lg, 10px);
10
+ padding: var(--rb-space-4, 1rem) var(--rb-space-5, 1.25rem);
11
+ margin: var(--rb-space-4, 1rem) 0;
12
+ font-size: var(--sl-text-sm, 0.875rem);
13
+ line-height: var(--sl-line-height, 1.6);
14
+ }
15
+ .rb-devstate-title { margin: 0 0 var(--rb-space-2, 0.5rem); font-weight: var(--rb-weight-strong, 600); }
16
+ .rb-devstate-muted { color: var(--rb-color-text-muted, #4C6B80); font-weight: 400; }
17
+ .rb-devstate-message { margin: var(--rb-space-2, 0.5rem) 0 0; }
18
+ .rb-devstate-table { border-collapse: collapse; width: 100%; margin: 0 0 var(--rb-space-3, 0.75rem); }
19
+ .rb-devstate-table th, .rb-devstate-table td { text-align: left; padding: 0.25rem 0.5rem 0.25rem 0; border-bottom: 1px solid var(--rb-color-hairline, #DFE7EC); vertical-align: top; }
20
+ .rb-devstate-connected td { font-weight: var(--rb-weight-strong, 600); }
21
+ .rb-devstate-mark { color: var(--rb-color-accent, #0F766E); font-weight: var(--rb-weight-strong, 600); }
22
+ .rb-devstate-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
23
+ .rb-devstate-button {
24
+ font: inherit;
25
+ padding: 0.25rem 0.6rem;
26
+ border-radius: var(--rb-radius-md, 6px);
27
+ border: 1px solid var(--rb-color-hairline-strong, #848B91);
28
+ background: transparent;
29
+ color: inherit;
30
+ cursor: pointer;
31
+ }
32
+ .rb-devstate-button[aria-pressed='true'] { border-color: var(--rb-color-accent, #0F766E); color: var(--rb-color-accent, #0F766E); }
33
+ .rb-devstate-button:disabled { opacity: 0.5; cursor: default; }
34
+ .rb-devstate code {
35
+ font-family: var(--sl-font-mono, 'JetBrains Mono', ui-monospace, Menlo, monospace);
36
+ font-size: var(--sl-text-code, 0.875rem);
37
+ }
@@ -0,0 +1,7 @@
1
+ import type { Address } from './useEligibility.js';
2
+ type Listener = (address: Address | undefined) => void;
3
+ /** Tells every mounted `useEligibility` for `address` (or every one, when omitted) to refetch. */
4
+ export declare function notifyEligibilityChanged(address?: Address): void;
5
+ /** Subscribes; returns the unsubscribe function. Used by `useEligibility`. */
6
+ export declare function onEligibilityChanged(listener: Listener): () => void;
7
+ export {};
package/dist/events.js ADDED
@@ -0,0 +1,13 @@
1
+ const listeners = new Set();
2
+ /** Tells every mounted `useEligibility` for `address` (or every one, when omitted) to refetch. */
3
+ export function notifyEligibilityChanged(address) {
4
+ for (const listener of listeners)
5
+ listener(address);
6
+ }
7
+ /** Subscribes; returns the unsubscribe function. Used by `useEligibility`. */
8
+ export function onEligibilityChanged(listener) {
9
+ listeners.add(listener);
10
+ return () => {
11
+ listeners.delete(listener);
12
+ };
13
+ }
@@ -0,0 +1,12 @@
1
+ import type { PublicClient } from 'viem';
2
+ import type { Address } from './useEligibility.js';
3
+ /**
4
+ * An `expiry` reader for `useEligibility` that works when the verifier is a `ReceptorMock`
5
+ * (testnet and forks). It reads `recordFor(wallet, requestId).expiresAt`; zero means no expiry.
6
+ * Real verifiers (an Iden3 `ZKPVerifier` child, a `VCVerifierBaseContract` child) do not expose
7
+ * an expiry, so on mainnet leave `expiry` unset and the `expiring` state never shows.
8
+ */
9
+ export declare function receptorMockExpiry(client: PublicClient, verifier: Address): ({ address, requestId }: {
10
+ address: Address;
11
+ requestId: bigint;
12
+ }) => Promise<number | null>;
package/dist/expiry.js ADDED
@@ -0,0 +1,19 @@
1
+ import { receptorMockAbi } from './abi.js';
2
+ /**
3
+ * An `expiry` reader for `useEligibility` that works when the verifier is a `ReceptorMock`
4
+ * (testnet and forks). It reads `recordFor(wallet, requestId).expiresAt`; zero means no expiry.
5
+ * Real verifiers (an Iden3 `ZKPVerifier` child, a `VCVerifierBaseContract` child) do not expose
6
+ * an expiry, so on mainnet leave `expiry` unset and the `expiring` state never shows.
7
+ */
8
+ export function receptorMockExpiry(client, verifier) {
9
+ return async ({ address, requestId }) => {
10
+ const record = await client.readContract({
11
+ address: verifier,
12
+ abi: receptorMockAbi,
13
+ functionName: 'recordFor',
14
+ args: [address, requestId],
15
+ });
16
+ const expiresAt = Number(record.expiresAt);
17
+ return record.isSet && expiresAt > 0 ? expiresAt : null;
18
+ };
19
+ }
@@ -0,0 +1,34 @@
1
+ import type { EligibilityStatusName } from '@gatedpath/agent-rules/failures';
2
+ export interface ExplainedError {
3
+ /** A kind from the failure table, or "unknown". */
4
+ kind: string;
5
+ plainWords: string;
6
+ cause: string;
7
+ fix: {
8
+ command?: string;
9
+ link?: string;
10
+ };
11
+ /** The table entry's heading, when known. */
12
+ title?: string;
13
+ /** The site page for this kind, when known. */
14
+ docsPage?: string;
15
+ /** `NotEligible(wallet, requestId)` arguments when they could be read from the revert. */
16
+ notEligible?: {
17
+ wallet: string;
18
+ requestId: string;
19
+ };
20
+ /** The revert selector when the error carried revert data. */
21
+ selector?: string;
22
+ /** The error's own words, shortened, for a details line. */
23
+ raw: string;
24
+ }
25
+ export interface ExplainErrorOptions {
26
+ /**
27
+ * The credential state behind a NotEligible revert, when the page already knows it (from
28
+ * `receptorMockStatus` on a ReceptorMock, or the dev state panel). Picks the entry for that
29
+ * state; a real verifier cannot tell, so leave it unset there.
30
+ */
31
+ eligibilityStatus?: EligibilityStatusName | number | null;
32
+ }
33
+ /** Explains an error from wagmi's `useWriteContract`, viem's clients, or anything with a `cause` chain. */
34
+ export declare function explainError(error: unknown, options?: ExplainErrorOptions): ExplainedError;
@@ -0,0 +1,113 @@
1
+ // explainError: a viem or wagmi error in, the plain words from the shared failure table out. The
2
+ // same table the MCP server's explain_failure reads (@gatedpath/agent-rules/failures), so a page
3
+ // shows the words an agent would see instead of a hex revert. Pure: no network, no React.
4
+ //
5
+ // What it decodes: a ContractFunctionRevertedError (NotEligible by selector or by the decoded
6
+ // error name, the deploy script's Error(string) reasons, ZeroVerifier), a user rejection in the
7
+ // wallet, a wrong or unswitchable chain, and not enough RBNT for gas. Anything else comes back as
8
+ // kind "unknown" with the error's own message, never a guess.
9
+ import { decodeRevertData, failureByKind, matchErrorName, matchText, notEligibleEntry, revertHexIn, ELIGIBILITY_STATUS_NAMES, } from '@gatedpath/agent-rules/failures';
10
+ function chain(error) {
11
+ const out = [];
12
+ let e = error;
13
+ for (let i = 0; i < 12 && e && typeof e === 'object'; i++) {
14
+ out.push(e);
15
+ e = e.cause;
16
+ }
17
+ return out;
18
+ }
19
+ function words(error) {
20
+ if (typeof error === 'string')
21
+ return error;
22
+ const parts = chain(error).flatMap((e) => [e.shortMessage, e.details, e.message]).filter((s) => typeof s === 'string' && s.length > 0);
23
+ return parts.join('\n');
24
+ }
25
+ function fromEntry(entry, raw, extra = {}) {
26
+ return {
27
+ kind: entry.kind,
28
+ plainWords: entry.plainWords,
29
+ cause: entry.cause,
30
+ fix: { ...(entry.fix.command ? { command: entry.fix.command } : {}), ...(entry.fix.link ? { link: entry.fix.link } : {}) },
31
+ title: entry.title,
32
+ docsPage: entry.docsPage,
33
+ raw: raw.slice(0, 600),
34
+ ...extra,
35
+ };
36
+ }
37
+ function statusName(status) {
38
+ if (status === null || status === undefined)
39
+ return null;
40
+ if (typeof status === 'number')
41
+ return ELIGIBILITY_STATUS_NAMES[status] ?? null;
42
+ return status;
43
+ }
44
+ /** Explains an error from wagmi's `useWriteContract`, viem's clients, or anything with a `cause` chain. */
45
+ export function explainError(error, options = {}) {
46
+ const raw = words(error);
47
+ const links = chain(error);
48
+ // 1. Revert data on a ContractFunctionRevertedError (or anything carrying `raw`/`data` hex).
49
+ for (const e of links) {
50
+ const hex = typeof e.raw === 'string' && /^0x[0-9a-fA-F]{8,}$/.test(e.raw) ? e.raw : typeof e.data === 'string' && /^0x[0-9a-fA-F]{8,}$/.test(e.data) ? e.data : null;
51
+ const decodedByViem = e.data && typeof e.data === 'object' && typeof e.data.errorName === 'string' ? e.data : null;
52
+ if (decodedByViem?.errorName === 'NotEligible') {
53
+ const [wallet, requestId] = decodedByViem.args ?? [];
54
+ const entry = notEligibleEntry(statusName(options.eligibilityStatus));
55
+ return fromEntry(entry, raw, {
56
+ selector: hex ? hex.slice(0, 10).toLowerCase() : undefined,
57
+ notEligible: { wallet: String(wallet ?? '').toLowerCase(), requestId: String(requestId ?? '') },
58
+ });
59
+ }
60
+ if (hex) {
61
+ const decoded = decodeRevertData(hex);
62
+ if (decoded?.kind === 'not-eligible') {
63
+ const entry = notEligibleEntry(statusName(options.eligibilityStatus));
64
+ return fromEntry(entry, raw, { selector: decoded.selector, ...(decoded.notEligible ? { notEligible: decoded.notEligible } : {}) });
65
+ }
66
+ if (decoded?.kind)
67
+ return fromEntry(failureByKind(decoded.kind), raw, { selector: decoded.selector });
68
+ if (decoded) {
69
+ const byText = matchText(raw);
70
+ if (byText)
71
+ return fromEntry(byText, raw, { selector: decoded.selector });
72
+ return { kind: 'unknown', plainWords: "This failure isn't in the table, so there are no plain words for it yet.", cause: `The contract reverted with selector ${decoded.selector}, which the table doesn't know.`, fix: {}, selector: decoded.selector, raw: raw.slice(0, 600) };
73
+ }
74
+ }
75
+ if (decodedByViem && decodedByViem.errorName === 'ZeroVerifier')
76
+ return fromEntry(failureByKind('zero-verifier'), raw);
77
+ if (typeof e.reason === 'string') {
78
+ const byReason = matchText(e.reason);
79
+ if (byReason)
80
+ return fromEntry(byReason, raw);
81
+ }
82
+ }
83
+ // 2. viem's own classes, by name along the cause chain (the wallet's 4001 arrives as UserRejectedRequestError).
84
+ for (const e of links) {
85
+ const byName = typeof e.name === 'string' ? matchErrorName(e.name) : undefined;
86
+ if (byName)
87
+ return fromEntry(byName, raw);
88
+ if (e.code === 4001)
89
+ return fromEntry(failureByKind('user-rejected'), raw);
90
+ }
91
+ // 3. Revert data quoted inside a message (a node's `execution reverted: custom error 0x…` text).
92
+ const quoted = revertHexIn(raw);
93
+ if (quoted) {
94
+ const decoded = decodeRevertData(quoted);
95
+ if (decoded?.kind === 'not-eligible') {
96
+ const entry = notEligibleEntry(statusName(options.eligibilityStatus));
97
+ return fromEntry(entry, raw, { selector: decoded.selector, ...(decoded.notEligible ? { notEligible: decoded.notEligible } : {}) });
98
+ }
99
+ if (decoded?.kind)
100
+ return fromEntry(failureByKind(decoded.kind), raw, { selector: decoded.selector });
101
+ }
102
+ // 4. The words themselves.
103
+ const byText = matchText(raw);
104
+ if (byText)
105
+ return fromEntry(byText, raw);
106
+ return {
107
+ kind: 'unknown',
108
+ plainWords: "This failure isn't in the table, so there are no plain words for it yet.",
109
+ cause: 'Nothing in the failure table matched the error. Its own message is in raw.',
110
+ fix: {},
111
+ raw: raw.slice(0, 600) || String(error),
112
+ };
113
+ }
@@ -0,0 +1,10 @@
1
+ export { useEligibility } from './useEligibility.js';
2
+ export type { Address, EligibilityState, EligibilityResult, UseEligibilityOptions } from './useEligibility.js';
3
+ export { receptorMockExpiry } from './expiry.js';
4
+ export { notifyEligibilityChanged, onEligibilityChanged } from './events.js';
5
+ export { EligibilityGate, NotConnected, NotVerified, Ineligible, Eligible, ExpiringSoon, Checking, EligibilityError, LastChecked, GasWarning, } from './components.js';
6
+ export type { EligibilityGateProps, StateProps, NotVerifiedProps, IneligibleProps, ExpiringSoonProps, EligibilityErrorProps, LastCheckedProps, GasWarningProps, } from './components.js';
7
+ export { verifierAbi, permissionAbi, receptorMockAbi, receptorMockStatusAbi } from './abi.js';
8
+ export { ACCESS_URL, FAUCET_URL, VINE_INTERACTION_URL, VINE_FEES_URL } from './urls.js';
9
+ export { explainError } from './explainError.js';
10
+ export type { ExplainedError, ExplainErrorOptions } from './explainError.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { useEligibility } from './useEligibility.js';
2
+ export { receptorMockExpiry } from './expiry.js';
3
+ export { notifyEligibilityChanged, onEligibilityChanged } from './events.js';
4
+ export { EligibilityGate, NotConnected, NotVerified, Ineligible, Eligible, ExpiringSoon, Checking, EligibilityError, LastChecked, GasWarning, } from './components.js';
5
+ export { verifierAbi, permissionAbi, receptorMockAbi, receptorMockStatusAbi } from './abi.js';
6
+ export { ACCESS_URL, FAUCET_URL, VINE_INTERACTION_URL, VINE_FEES_URL } from './urls.js';
7
+ export { explainError } from './explainError.js';