@openzeppelin/guardian-evm-client 0.14.5

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,245 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ GuardianEvmClient,
4
+ GuardianEvmHttpError,
5
+ buildEvmSessionTypedData,
6
+ evmAccountId,
7
+ normalizeEvmAddress,
8
+ signProposalHash,
9
+ type Eip1193Provider,
10
+ } from './index.js';
11
+
12
+ const address = '0xE7f1725E7734CE288F8367e1Bb143E90bb3F0512';
13
+ const normalizedAddress = '0xe7f1725e7734ce288f8367e1bb143e90bb3f0512';
14
+ const validator = '0x1111111111111111111111111111111111111111';
15
+ const accountId = `evm:31337:${normalizedAddress}`;
16
+ const hash = `0x${'12'.repeat(32)}` as const;
17
+ const signature = `0x${'11'.repeat(65)}` as const;
18
+ const secondSigner = '0x2222222222222222222222222222222222222222';
19
+
20
+ describe('guardian-evm-client', () => {
21
+ afterEach(() => {
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ it('normalizes EVM addresses and account ids', () => {
26
+ expect(normalizeEvmAddress(address)).toBe(normalizedAddress);
27
+ expect(evmAccountId(31337, address)).toBe(accountId);
28
+ });
29
+
30
+ it('builds Guardian EVM session typed data', () => {
31
+ const typedData = buildEvmSessionTypedData({
32
+ address,
33
+ nonce: `0x${'ab'.repeat(32)}`,
34
+ issuedAt: 1,
35
+ expiresAt: 2,
36
+ });
37
+
38
+ expect(typedData.domain).toEqual({ name: 'Guardian EVM Session', version: '1' });
39
+ expect(typedData.primaryType).toBe('GuardianEvmSession');
40
+ expect(typedData.message.wallet).toBe(normalizedAddress);
41
+ });
42
+
43
+ it('logs in with an EIP-712 challenge and cookie fetch mode', async () => {
44
+ const provider = providerReturning(signature);
45
+ const fetchMock = mockFetch([
46
+ {
47
+ address: normalizedAddress,
48
+ nonce: `0x${'aa'.repeat(32)}`,
49
+ issued_at: 10,
50
+ expires_at: 20,
51
+ },
52
+ {
53
+ address: normalizedAddress,
54
+ expires_at: 1234,
55
+ },
56
+ ]);
57
+ const client = new GuardianEvmClient({
58
+ guardianUrl: 'http://guardian.test',
59
+ provider,
60
+ signerAddress: address,
61
+ });
62
+
63
+ const session = await client.login();
64
+
65
+ expect(session.address).toBe(normalizedAddress);
66
+ expect(provider.request).toHaveBeenCalledWith(
67
+ expect.objectContaining({ method: 'eth_signTypedData_v4' })
68
+ );
69
+ expect(fetchMock.mock.calls[0]?.[0]).toContain('/evm/auth/challenge');
70
+ expect(fetchMock.mock.calls[1]?.[0]).toContain('/evm/auth/verify');
71
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ credentials: 'include' });
72
+ });
73
+
74
+ it('registers an EVM account through /evm/accounts', async () => {
75
+ const fetchMock = mockFetch([
76
+ {
77
+ account_id: accountId,
78
+ chain_id: 31337,
79
+ account_address: normalizedAddress,
80
+ multisig_validator_address: validator,
81
+ signers: [normalizedAddress, secondSigner],
82
+ threshold: 2,
83
+ },
84
+ ]);
85
+ const client = new GuardianEvmClient({ guardianUrl: 'http://guardian.test' });
86
+
87
+ const response = await client.configure({
88
+ chainId: 31337,
89
+ smartAccountAddress: address,
90
+ multisigValidatorAddress: validator,
91
+ });
92
+
93
+ expect(response.accountId).toBe(accountId);
94
+ expect(fetchMock.mock.calls[0]?.[0]).toBe('http://guardian.test/evm/accounts');
95
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
96
+ expect(body).toEqual({
97
+ chain_id: 31337,
98
+ account_address: normalizedAddress,
99
+ multisig_validator_address: validator,
100
+ });
101
+ });
102
+
103
+ it('creates proposals through /evm/proposals', async () => {
104
+ const fetchMock = mockFetch([proposal({ signatureCount: 1 })]);
105
+ const client = new GuardianEvmClient({ guardianUrl: 'http://guardian.test' });
106
+
107
+ const response = await client.createProposal({
108
+ chainId: 31337,
109
+ smartAccountAddress: address,
110
+ userOpHash: hash,
111
+ payload: '{"kind":"userOp"}',
112
+ nonce: '0x01',
113
+ signature,
114
+ ttlSeconds: 300,
115
+ });
116
+
117
+ expect(response.proposalId).toBe(hash);
118
+ expect(response.signatures).toHaveLength(1);
119
+ expect(fetchMock.mock.calls[0]?.[0]).toBe('http://guardian.test/evm/proposals');
120
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
121
+ expect(body).toEqual({
122
+ account_id: accountId,
123
+ user_op_hash: hash,
124
+ payload: '{"kind":"userOp"}',
125
+ nonce: '0x01',
126
+ ttl_seconds: 300,
127
+ signature,
128
+ });
129
+ });
130
+
131
+ it('fetches and converts proposals from /evm/proposals', async () => {
132
+ mockFetch([{ proposals: [proposal({ signatureCount: 1 })] }]);
133
+ const client = new GuardianEvmClient({ guardianUrl: 'http://guardian.test' });
134
+
135
+ const proposals = await client.listProposals(accountId);
136
+
137
+ expect(proposals[0]?.proposalId).toBe(hash);
138
+ expect(proposals[0]?.signatures[0]?.signedAt).toBe(42);
139
+ });
140
+
141
+ it('approves proposals with raw ECDSA signatures', async () => {
142
+ const fetchMock = mockFetch([proposal({ signatureCount: 2 })]);
143
+ const client = new GuardianEvmClient({ guardianUrl: 'http://guardian.test' });
144
+
145
+ const updated = await client.approveProposal(accountId, hash, { signature });
146
+
147
+ expect(updated.signatures).toHaveLength(2);
148
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
149
+ `http://guardian.test/evm/proposals/${hash}/approve`
150
+ );
151
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
152
+ expect(body).toEqual({ account_id: accountId, signature });
153
+ });
154
+
155
+ it('fetches executable proposal data and cancels proposals', async () => {
156
+ const fetchMock = mockFetch([
157
+ {
158
+ hash,
159
+ payload: '{}',
160
+ signatures: [signature],
161
+ signers: [normalizedAddress],
162
+ },
163
+ { success: true },
164
+ ]);
165
+ const client = new GuardianEvmClient({ guardianUrl: 'http://guardian.test' });
166
+
167
+ const executable = await client.getExecutableProposal(accountId, hash);
168
+ await client.cancelProposal(accountId, hash);
169
+
170
+ expect(executable.hash).toBe(hash);
171
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
172
+ `http://guardian.test/evm/proposals/${hash}/executable?account_id=${encodeURIComponent(accountId)}`
173
+ );
174
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
175
+ `http://guardian.test/evm/proposals/${hash}/cancel`
176
+ );
177
+ });
178
+
179
+ it('parses stable error codes', async () => {
180
+ vi.stubGlobal(
181
+ 'fetch',
182
+ vi.fn(async () => new Response(JSON.stringify({ code: 'unsupported_for_network' }), { status: 400 }))
183
+ );
184
+ const client = new GuardianEvmClient({ guardianUrl: 'http://guardian.test' });
185
+
186
+ await expect(client.challenge(address)).rejects.toMatchObject<Partial<GuardianEvmHttpError>>({
187
+ code: 'unsupported_for_network',
188
+ });
189
+ });
190
+
191
+ it('can request hash signatures from injected wallets', async () => {
192
+ const provider = providerReturning(signature);
193
+
194
+ const result = await signProposalHash(provider, address, hash);
195
+
196
+ expect(result).toBe(signature);
197
+ expect(provider.request).toHaveBeenCalledWith({
198
+ method: 'eth_sign',
199
+ params: [normalizedAddress, hash],
200
+ });
201
+ });
202
+ });
203
+
204
+ function providerReturning(result: string): Eip1193Provider {
205
+ return {
206
+ request: vi.fn(async () => result),
207
+ };
208
+ }
209
+
210
+ function mockFetch(jsonResponses: unknown[]) {
211
+ const fetchMock = vi.fn(async () => {
212
+ const response = jsonResponses.shift();
213
+ return new Response(JSON.stringify(response), {
214
+ status: 200,
215
+ headers: { 'Content-Type': 'application/json' },
216
+ });
217
+ });
218
+ vi.stubGlobal('fetch', fetchMock);
219
+ return fetchMock;
220
+ }
221
+
222
+ function proposal({ signatureCount }: { signatureCount: number }) {
223
+ const signatures = Array.from({ length: signatureCount }, (_, index) => ({
224
+ signer: index === 0 ? normalizedAddress : secondSigner,
225
+ signature,
226
+ signed_at: 42 + index,
227
+ }));
228
+ return {
229
+ proposal_id: hash,
230
+ account_id: accountId,
231
+ chain_id: 31337,
232
+ smart_account_address: normalizedAddress,
233
+ validator_address: validator,
234
+ user_op_hash: hash,
235
+ payload: '{}',
236
+ nonce: '1',
237
+ nonce_key: '0',
238
+ proposer: normalizedAddress,
239
+ signer_snapshot: [normalizedAddress, secondSigner],
240
+ threshold: 2,
241
+ signatures,
242
+ created_at: 1,
243
+ expires_at: 2,
244
+ };
245
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { GuardianEvmClient, type EvmSession } from './client.js';
2
+ export {
3
+ requestWalletAddress,
4
+ signProposalHash,
5
+ signTypedData,
6
+ type Eip1193Provider,
7
+ } from './auth.js';
8
+ export { normalizeBytes32, normalizeEvmAddress, normalizeSignature, normalizeUint256String } from './encoding.js';
9
+ export { GuardianEvmHttpError } from './errors.js';
10
+ export {
11
+ buildEvmSessionTypedData,
12
+ type EvmSessionChallenge,
13
+ type GuardianTypedData,
14
+ type TypedDataField,
15
+ } from './typed-data.js';
16
+ export {
17
+ evmAccountId,
18
+ type AccountRegistration,
19
+ type ApproveRequest,
20
+ type ConfigureRequest,
21
+ type EvmProposalSignature,
22
+ type ExecutableProposal,
23
+ type Proposal,
24
+ type ProposeRequest,
25
+ } from './proposals.js';
@@ -0,0 +1,271 @@
1
+ import {
2
+ normalizeBytes32,
3
+ normalizeEvmAddress,
4
+ normalizeSignature,
5
+ normalizeUint256String,
6
+ } from './encoding.js';
7
+
8
+ export interface ConfigureRequest {
9
+ chainId: number;
10
+ smartAccountAddress: `0x${string}` | string;
11
+ multisigValidatorAddress: `0x${string}` | string;
12
+ }
13
+
14
+ export interface AccountRegistration {
15
+ accountId: string;
16
+ chainId: number;
17
+ accountAddress: `0x${string}`;
18
+ multisigValidatorAddress: `0x${string}`;
19
+ signers: `0x${string}`[];
20
+ threshold: number;
21
+ }
22
+
23
+ export interface EvmProposalSignature {
24
+ signer: `0x${string}`;
25
+ signature: `0x${string}`;
26
+ signedAt: number;
27
+ }
28
+
29
+ export interface ProposeRequest {
30
+ accountId?: string;
31
+ chainId: number;
32
+ smartAccountAddress: `0x${string}` | string;
33
+ userOpHash: `0x${string}` | string;
34
+ payload: string;
35
+ nonce: string;
36
+ signature: `0x${string}` | string;
37
+ ttlSeconds: number;
38
+ }
39
+
40
+ export interface Proposal {
41
+ proposalId: string;
42
+ accountId: string;
43
+ chainId: number;
44
+ smartAccountAddress: `0x${string}`;
45
+ validatorAddress: `0x${string}`;
46
+ userOpHash: `0x${string}`;
47
+ payload: string;
48
+ nonce: string;
49
+ nonceKey: string;
50
+ proposer: `0x${string}`;
51
+ signerSnapshot: `0x${string}`[];
52
+ threshold: number;
53
+ signatures: EvmProposalSignature[];
54
+ createdAt: number;
55
+ expiresAt: number;
56
+ }
57
+
58
+ export interface ApproveRequest {
59
+ signature: `0x${string}` | string;
60
+ }
61
+
62
+ export interface ExecutableProposal {
63
+ hash: `0x${string}`;
64
+ payload: string;
65
+ signatures: `0x${string}`[];
66
+ signers: `0x${string}`[];
67
+ }
68
+
69
+ export interface ServerRegisterAccountRequest {
70
+ chain_id: number;
71
+ account_address: string;
72
+ multisig_validator_address: string;
73
+ }
74
+
75
+ export interface ServerRegisterAccountResponse {
76
+ account_id: string;
77
+ chain_id: number;
78
+ account_address: string;
79
+ multisig_validator_address: string;
80
+ signers: string[];
81
+ threshold: number;
82
+ }
83
+
84
+ export interface ServerCreateProposalRequest {
85
+ account_id: string;
86
+ user_op_hash: string;
87
+ payload: string;
88
+ nonce: string;
89
+ ttl_seconds: number;
90
+ signature: string;
91
+ }
92
+
93
+ export interface ServerProposal {
94
+ proposal_id: string;
95
+ account_id: string;
96
+ chain_id: number;
97
+ smart_account_address: string;
98
+ validator_address: string;
99
+ user_op_hash: string;
100
+ payload: string;
101
+ nonce: string;
102
+ nonce_key: string;
103
+ proposer: string;
104
+ signer_snapshot: string[];
105
+ threshold: number;
106
+ signatures: ServerProposalSignature[];
107
+ created_at: number;
108
+ expires_at: number;
109
+ }
110
+
111
+ export interface ServerProposalSignature {
112
+ signer: string;
113
+ signature: string;
114
+ signed_at: number;
115
+ }
116
+
117
+ export interface ServerListProposalsResponse {
118
+ proposals: ServerProposal[];
119
+ }
120
+
121
+ export interface ServerApproveProposalRequest {
122
+ account_id: string;
123
+ signature: string;
124
+ }
125
+
126
+ export interface ServerCancelProposalRequest {
127
+ account_id: string;
128
+ }
129
+
130
+ export interface ServerExecutableProposal {
131
+ hash: string;
132
+ payload: string;
133
+ signatures: string[];
134
+ signers: string[];
135
+ }
136
+
137
+ export function evmAccountId(chainId: number, smartAccountAddress: string): string {
138
+ assertSafeChainId(chainId);
139
+ return `evm:${chainId}:${normalizeEvmAddress(smartAccountAddress)}`;
140
+ }
141
+
142
+ export function toServerRegisterAccountRequest(
143
+ request: ConfigureRequest
144
+ ): ServerRegisterAccountRequest {
145
+ assertSafeChainId(request.chainId);
146
+ return {
147
+ chain_id: request.chainId,
148
+ account_address: normalizeEvmAddress(request.smartAccountAddress),
149
+ multisig_validator_address: normalizeEvmAddress(request.multisigValidatorAddress),
150
+ };
151
+ }
152
+
153
+ export function fromServerAccountRegistration(
154
+ server: ServerRegisterAccountResponse
155
+ ): AccountRegistration {
156
+ return {
157
+ accountId: stringField(server.account_id, 'account_id'),
158
+ chainId: numberField(server.chain_id, 'chain_id'),
159
+ accountAddress: normalizeEvmAddress(server.account_address),
160
+ multisigValidatorAddress: normalizeEvmAddress(server.multisig_validator_address),
161
+ signers: arrayField(server.signers, 'signers').map(normalizeEvmAddress),
162
+ threshold: numberField(server.threshold, 'threshold'),
163
+ };
164
+ }
165
+
166
+ export function toServerCreateProposalRequest(
167
+ request: ProposeRequest
168
+ ): ServerCreateProposalRequest {
169
+ assertSafeChainId(request.chainId);
170
+ assertPositiveSeconds(request.ttlSeconds, 'ttlSeconds');
171
+ const accountId =
172
+ request.accountId ?? evmAccountId(request.chainId, request.smartAccountAddress);
173
+ return {
174
+ account_id: accountId,
175
+ user_op_hash: normalizeBytes32(request.userOpHash, 'userOpHash'),
176
+ payload: request.payload,
177
+ nonce: normalizeUint256String(request.nonce),
178
+ ttl_seconds: request.ttlSeconds,
179
+ signature: normalizeSignature(request.signature),
180
+ };
181
+ }
182
+
183
+ export function toServerApproveRequest(
184
+ accountId: string,
185
+ request: ApproveRequest
186
+ ): ServerApproveProposalRequest {
187
+ return {
188
+ account_id: accountId,
189
+ signature: normalizeSignature(request.signature),
190
+ };
191
+ }
192
+
193
+ export function toServerCancelRequest(accountId: string): ServerCancelProposalRequest {
194
+ return { account_id: accountId };
195
+ }
196
+
197
+ export function fromServerProposal(server: ServerProposal): Proposal {
198
+ return {
199
+ proposalId: normalizeBytes32(server.proposal_id, 'proposal_id'),
200
+ accountId: stringField(server.account_id, 'account_id'),
201
+ chainId: numberField(server.chain_id, 'chain_id'),
202
+ smartAccountAddress: normalizeEvmAddress(server.smart_account_address),
203
+ validatorAddress: normalizeEvmAddress(server.validator_address),
204
+ userOpHash: normalizeBytes32(server.user_op_hash, 'user_op_hash'),
205
+ payload: stringField(server.payload, 'payload'),
206
+ nonce: stringField(server.nonce, 'nonce'),
207
+ nonceKey: stringField(server.nonce_key, 'nonce_key'),
208
+ proposer: normalizeEvmAddress(server.proposer),
209
+ signerSnapshot: arrayField(server.signer_snapshot, 'signer_snapshot').map(
210
+ normalizeEvmAddress
211
+ ),
212
+ threshold: numberField(server.threshold, 'threshold'),
213
+ signatures: arrayField(server.signatures, 'signatures').map(fromServerSignature),
214
+ createdAt: numberField(server.created_at, 'created_at'),
215
+ expiresAt: numberField(server.expires_at, 'expires_at'),
216
+ };
217
+ }
218
+
219
+ export function fromServerExecutable(server: ServerExecutableProposal): ExecutableProposal {
220
+ return {
221
+ hash: normalizeBytes32(server.hash, 'hash'),
222
+ payload: stringField(server.payload, 'payload'),
223
+ signatures: arrayField(server.signatures, 'signatures').map(normalizeSignature),
224
+ signers: arrayField(server.signers, 'signers').map(normalizeEvmAddress),
225
+ };
226
+ }
227
+
228
+ export function accountProposalParams(accountId: string): URLSearchParams {
229
+ return new URLSearchParams({ account_id: accountId });
230
+ }
231
+
232
+ function fromServerSignature(server: ServerProposalSignature): EvmProposalSignature {
233
+ return {
234
+ signer: normalizeEvmAddress(server.signer),
235
+ signature: normalizeSignature(server.signature),
236
+ signedAt: numberField(server.signed_at, 'signed_at'),
237
+ };
238
+ }
239
+
240
+ function assertSafeChainId(chainId: number): void {
241
+ if (!Number.isSafeInteger(chainId) || chainId <= 0) {
242
+ throw new Error('chainId must be a positive safe integer');
243
+ }
244
+ }
245
+
246
+ function assertPositiveSeconds(value: number, field: string): void {
247
+ if (!Number.isSafeInteger(value) || value <= 0) {
248
+ throw new Error(`${field} must be a positive safe integer`);
249
+ }
250
+ }
251
+
252
+ function stringField(value: unknown, field: string): string {
253
+ if (typeof value !== 'string') {
254
+ throw new Error(`${field} must be a string`);
255
+ }
256
+ return value;
257
+ }
258
+
259
+ function numberField(value: unknown, field: string): number {
260
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
261
+ throw new Error(`${field} must be a safe integer`);
262
+ }
263
+ return value;
264
+ }
265
+
266
+ function arrayField<T>(value: T[] | undefined, field: string): T[] {
267
+ if (!Array.isArray(value)) {
268
+ throw new Error(`${field} must be an array`);
269
+ }
270
+ return value;
271
+ }
@@ -0,0 +1,112 @@
1
+ import type { Hex } from 'viem';
2
+ import { normalizeBytes32, normalizeEvmAddress } from './encoding.js';
3
+
4
+ export type TypedDataField = { name: string; type: string };
5
+
6
+ export type GuardianTypedData = {
7
+ domain: {
8
+ name: string;
9
+ version: string;
10
+ };
11
+ types: {
12
+ EIP712Domain: TypedDataField[];
13
+ GuardianEvmSession: TypedDataField[];
14
+ };
15
+ primaryType: 'GuardianEvmSession';
16
+ message: {
17
+ wallet: `0x${string}`;
18
+ nonce: Hex;
19
+ issued_at: number;
20
+ expires_at: number;
21
+ };
22
+ };
23
+
24
+ export interface EvmSessionChallenge {
25
+ address: `0x${string}`;
26
+ nonce: Hex;
27
+ issuedAt: number;
28
+ expiresAt: number;
29
+ typedData: GuardianTypedData;
30
+ }
31
+
32
+ export function buildEvmSessionTypedData(input: {
33
+ address: string;
34
+ nonce: string;
35
+ issuedAt: number;
36
+ expiresAt: number;
37
+ }): GuardianTypedData {
38
+ return {
39
+ domain: {
40
+ name: 'Guardian EVM Session',
41
+ version: '1',
42
+ },
43
+ types: {
44
+ EIP712Domain: [
45
+ { name: 'name', type: 'string' },
46
+ { name: 'version', type: 'string' },
47
+ ],
48
+ GuardianEvmSession: [
49
+ { name: 'wallet', type: 'address' },
50
+ { name: 'nonce', type: 'bytes32' },
51
+ { name: 'issued_at', type: 'uint64' },
52
+ { name: 'expires_at', type: 'uint64' },
53
+ ],
54
+ },
55
+ primaryType: 'GuardianEvmSession',
56
+ message: {
57
+ wallet: normalizeEvmAddress(input.address),
58
+ nonce: normalizeBytes32(input.nonce, 'nonce'),
59
+ issued_at: input.issuedAt,
60
+ expires_at: input.expiresAt,
61
+ },
62
+ };
63
+ }
64
+
65
+ export function fromServerChallenge(server: ServerChallengeResponse): EvmSessionChallenge {
66
+ const issuedAt = numberField(server.issued_at, 'issued_at');
67
+ const expiresAt = numberField(server.expires_at, 'expires_at');
68
+ const typedData = parseTypedData(server.typed_data, server.address, server.nonce, issuedAt, expiresAt);
69
+ return {
70
+ address: normalizeEvmAddress(server.address),
71
+ nonce: normalizeBytes32(server.nonce, 'nonce'),
72
+ issuedAt,
73
+ expiresAt,
74
+ typedData,
75
+ };
76
+ }
77
+
78
+ interface ServerChallengeResponse {
79
+ address: string;
80
+ nonce: string;
81
+ issued_at: number;
82
+ expires_at: number;
83
+ typed_data?: unknown;
84
+ }
85
+
86
+ function parseTypedData(
87
+ value: unknown,
88
+ address: string,
89
+ nonce: string,
90
+ issuedAt: number,
91
+ expiresAt: number
92
+ ): GuardianTypedData {
93
+ if (!isRecord(value)) {
94
+ return buildEvmSessionTypedData({ address, nonce, issuedAt, expiresAt });
95
+ }
96
+ const primaryType = value.primaryType;
97
+ if (primaryType !== 'GuardianEvmSession') {
98
+ return buildEvmSessionTypedData({ address, nonce, issuedAt, expiresAt });
99
+ }
100
+ return buildEvmSessionTypedData({ address, nonce, issuedAt, expiresAt });
101
+ }
102
+
103
+ function numberField(value: unknown, field: string): number {
104
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
105
+ throw new Error(`${field} must be a safe integer`);
106
+ }
107
+ return value;
108
+ }
109
+
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return typeof value === 'object' && value !== null;
112
+ }