@lukso/core 1.2.10 → 1.2.11
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 +171 -3
- package/dist/chunk-2KVLFGLM.cjs +260 -0
- package/dist/chunk-2KVLFGLM.cjs.map +1 -0
- package/dist/{chunk-GFLV5EJV.js → chunk-RECO5F6T.js} +102 -1
- package/dist/chunk-RECO5F6T.js.map +1 -0
- package/dist/index.cjs +24 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +25 -9
- package/dist/index.js.map +1 -1
- package/dist/mixins/index.cjs +1 -1
- package/dist/mixins/index.cjs.map +1 -1
- package/dist/mixins/index.js +1 -1
- package/dist/services/index.cjs +2 -2
- package/dist/services/index.cjs.map +1 -1
- package/dist/services/index.js +3 -3
- package/dist/utils/index.cjs +18 -2
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.cts +202 -1
- package/dist/utils/index.d.ts +202 -1
- package/dist/utils/index.js +19 -3
- package/package.json +1 -1
- package/src/utils/__tests__/signed-profile-urls.spec.ts +276 -0
- package/src/utils/index.ts +10 -1
- package/src/utils/signed-profile-urls.ts +385 -0
- package/dist/chunk-GFLV5EJV.js.map +0 -1
- package/dist/chunk-QU6NUTY6.cjs +0 -159
- package/dist/chunk-QU6NUTY6.cjs.map +0 -1
package/dist/utils/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DeviceService } from '../services/device.js';
|
|
2
|
+
import { Hex } from 'viem';
|
|
2
3
|
import 'ua-parser-js';
|
|
3
4
|
|
|
4
5
|
type BrowserName = 'chrome' | 'safari' | 'firefox' | 'edge' | 'opera' | 'brave';
|
|
@@ -21,6 +22,206 @@ declare const EXTENSION_STORE_LINKS: {
|
|
|
21
22
|
*/
|
|
22
23
|
declare const browserInfo: (deviceService: DeviceService) => BrowserInfo;
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @service-checkin/signed-qr-code
|
|
27
|
+
*
|
|
28
|
+
* Create and verify signed QR codes for Universal Profile check-ins.
|
|
29
|
+
* Pure TypeScript - compatible with React Native, Node.js, and browsers.
|
|
30
|
+
*
|
|
31
|
+
* URI Format: ethereum:<address>@<chainId>?ts=<unixTimestamp>&sig=<signature>
|
|
32
|
+
*
|
|
33
|
+
* The signed message is everything before &sig= (the URI without the signature).
|
|
34
|
+
* Uses EIP-191 prefixed signing (signMessage) for safety - this prevents
|
|
35
|
+
* signed messages from being confused with transaction hashes.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Custom signer function type.
|
|
40
|
+
* Takes the message string and returns an EIP-191 signature.
|
|
41
|
+
* The signer should use `signMessage` (which adds the EIP-191 prefix).
|
|
42
|
+
* Compatible with hardware wallets, secure enclaves, WalletConnect, etc.
|
|
43
|
+
*
|
|
44
|
+
* The EIP-191 prefix prevents signed messages from being confused with
|
|
45
|
+
* transaction hashes, making this safe for identity verification.
|
|
46
|
+
*/
|
|
47
|
+
type SignerFunction = (message: string) => Promise<Hex>;
|
|
48
|
+
interface CreateSignedQROptions {
|
|
49
|
+
/**
|
|
50
|
+
* Seconds to add to current time to account for QR generation/display latency.
|
|
51
|
+
* The QR timestamp will be set to (now + generationOffsetSeconds).
|
|
52
|
+
* Capped at 5 seconds maximum to prevent abuse.
|
|
53
|
+
* Default: 0
|
|
54
|
+
*/
|
|
55
|
+
generationOffsetSeconds?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Custom signer function for signing the message.
|
|
58
|
+
* If provided, the privateKey parameter can be omitted or set to null.
|
|
59
|
+
* Useful for hardware wallets, secure enclaves, or WalletConnect.
|
|
60
|
+
*
|
|
61
|
+
* The signer receives the message string and should use `signMessage`
|
|
62
|
+
* (EIP-191 prefixed signing) to return the signature.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* // Using a wallet that signs messages
|
|
67
|
+
* const uri = await createSignedQR(null, profileAddress, 42, {
|
|
68
|
+
* signer: async (message) => {
|
|
69
|
+
* return await wallet.signMessage(message)
|
|
70
|
+
* }
|
|
71
|
+
* })
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
signer?: SignerFunction;
|
|
75
|
+
}
|
|
76
|
+
interface VerifySignedQROptions {
|
|
77
|
+
/** Maximum age in seconds before QR is considered expired (default: 60) */
|
|
78
|
+
maxAgeSeconds?: number;
|
|
79
|
+
/** Skip timestamp validation (useful for testing) */
|
|
80
|
+
skipTimestampValidation?: boolean;
|
|
81
|
+
}
|
|
82
|
+
interface VerificationResult {
|
|
83
|
+
/** Whether the QR code is valid and not expired */
|
|
84
|
+
isValid: boolean;
|
|
85
|
+
/** The Universal Profile address from the QR */
|
|
86
|
+
profileAddress: string;
|
|
87
|
+
/** The chain ID from the QR */
|
|
88
|
+
chainId: number;
|
|
89
|
+
/** The timestamp from the QR (unix seconds) */
|
|
90
|
+
timestamp: number;
|
|
91
|
+
/** The address recovered from the signature (the controller) */
|
|
92
|
+
recoveredAddress: string;
|
|
93
|
+
/** Whether the QR has expired based on maxAgeSeconds */
|
|
94
|
+
isExpired: boolean;
|
|
95
|
+
/** The original signed message (for EIP-1271 verification) */
|
|
96
|
+
message: string;
|
|
97
|
+
/** The hash of the message (for EIP-1271 verification) */
|
|
98
|
+
messageHash: Hex;
|
|
99
|
+
}
|
|
100
|
+
interface ParsedQRData {
|
|
101
|
+
profileAddress: string;
|
|
102
|
+
chainId: number;
|
|
103
|
+
timestamp: number;
|
|
104
|
+
signature: Hex;
|
|
105
|
+
message: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Create the message that will be signed.
|
|
109
|
+
* This is the URI without the signature parameter.
|
|
110
|
+
*/
|
|
111
|
+
declare function createMessage(profileAddress: string, chainId: number, timestamp: number): string;
|
|
112
|
+
/**
|
|
113
|
+
* Create a signed QR URI string.
|
|
114
|
+
*
|
|
115
|
+
* @param privateKey - The controller's private key (hex string with 0x prefix).
|
|
116
|
+
* Can be '0x' or omitted if using options.signer.
|
|
117
|
+
* @param profileAddress - The Universal Profile address to sign for
|
|
118
|
+
* @param chainId - The chain ID (42 for LUKSO mainnet, 4201 for testnet)
|
|
119
|
+
* @param options - Optional configuration including custom signer
|
|
120
|
+
* @returns The complete signed URI string ready to encode as QR
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* // Using a private key directly
|
|
125
|
+
* const uri = await createSignedQR(
|
|
126
|
+
* '0x1234...', // controller private key
|
|
127
|
+
* '0xabcd...', // profile address
|
|
128
|
+
* 42, // LUKSO mainnet
|
|
129
|
+
* { generationOffsetSeconds: 2 }
|
|
130
|
+
* )
|
|
131
|
+
*
|
|
132
|
+
* // Using a custom signer (WalletConnect, hardware wallet, etc.)
|
|
133
|
+
* const uri = await createSignedQR(
|
|
134
|
+
* null, // not needed when signer is provided
|
|
135
|
+
* '0xabcd...', // profile address
|
|
136
|
+
* 42, // LUKSO mainnet
|
|
137
|
+
* {
|
|
138
|
+
* signer: async (message) => await wallet.signMessage(message)
|
|
139
|
+
* }
|
|
140
|
+
* )
|
|
141
|
+
* // Returns: ethereum:0xabcd...@42?ts=1706345678&sig=0x...
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
declare function createSignedQR(privateKey: Hex | null, profileAddress: string, chainId: number, options?: CreateSignedQROptions): Promise<string>;
|
|
145
|
+
/**
|
|
146
|
+
* Parse a signed QR URI without verification.
|
|
147
|
+
* Use this when you need to extract data before full verification.
|
|
148
|
+
*
|
|
149
|
+
* @param uri - The signed QR URI string
|
|
150
|
+
* @returns Parsed data or null if format is invalid
|
|
151
|
+
*/
|
|
152
|
+
declare function parseSignedQR(uri: string): ParsedQRData | null;
|
|
153
|
+
/**
|
|
154
|
+
* Verify a signed QR URI and recover the signer.
|
|
155
|
+
*
|
|
156
|
+
* This performs:
|
|
157
|
+
* 1. URI format validation
|
|
158
|
+
* 2. Signature recovery (gets the controller address that signed)
|
|
159
|
+
* 3. Timestamp validation (checks if expired)
|
|
160
|
+
*
|
|
161
|
+
* Note: This does NOT verify that the recovered address is an authorized
|
|
162
|
+
* controller of the profile. That check should be done separately using
|
|
163
|
+
* EIP-1271 isValidSignature() or by checking controller permissions on-chain.
|
|
164
|
+
*
|
|
165
|
+
* @param uri - The signed QR URI string
|
|
166
|
+
* @param options - Optional configuration
|
|
167
|
+
* @returns Verification result with recovered address
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```ts
|
|
171
|
+
* const result = await verifySignedQR(uri)
|
|
172
|
+
*
|
|
173
|
+
* if (result.isValid) {
|
|
174
|
+
* console.log('Profile:', result.profileAddress)
|
|
175
|
+
* console.log('Signed by:', result.recoveredAddress)
|
|
176
|
+
*
|
|
177
|
+
* // Now verify the signer is an authorized controller
|
|
178
|
+
* // Option 1: Check if recoveredAddress === profileAddress (EOA case)
|
|
179
|
+
* // Option 2: Call isValidSignature() on the profile contract
|
|
180
|
+
* }
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
declare function verifySignedQR(uri: string, options?: VerifySignedQROptions): Promise<VerificationResult>;
|
|
184
|
+
/**
|
|
185
|
+
* Check if a string looks like a valid signed QR URI.
|
|
186
|
+
* This is a quick format check without full verification.
|
|
187
|
+
*/
|
|
188
|
+
declare function isSignedQRFormat(uri: string): boolean;
|
|
189
|
+
/**
|
|
190
|
+
* Get the controller address from a private key.
|
|
191
|
+
* Useful for displaying which address will sign the QR codes.
|
|
192
|
+
*/
|
|
193
|
+
declare function getControllerAddress(privateKey: Hex): string;
|
|
194
|
+
/**
|
|
195
|
+
* EIP-1271 magic value returned for valid signatures
|
|
196
|
+
*/
|
|
197
|
+
declare const EIP1271_MAGIC_VALUE: "0x1626ba7e";
|
|
198
|
+
/**
|
|
199
|
+
* Prepare data for EIP-1271 verification.
|
|
200
|
+
* Returns the hash and signature needed to call isValidSignature().
|
|
201
|
+
*
|
|
202
|
+
* @param uri - The original signed QR URI (needed to extract the signature)
|
|
203
|
+
* @param verificationResult - The result from verifySignedQR()
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```ts
|
|
207
|
+
* const result = await verifySignedQR(uri)
|
|
208
|
+
* const { hash, signature } = getEIP1271Data(uri, result)
|
|
209
|
+
*
|
|
210
|
+
* const magicValue = await client.readContract({
|
|
211
|
+
* address: result.profileAddress,
|
|
212
|
+
* abi: EIP1271_ABI,
|
|
213
|
+
* functionName: 'isValidSignature',
|
|
214
|
+
* args: [hash, signature],
|
|
215
|
+
* })
|
|
216
|
+
*
|
|
217
|
+
* const isAuthorizedController = magicValue === EIP1271_MAGIC_VALUE
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
declare function getEIP1271Data(uri: string, verificationResult: VerificationResult): {
|
|
221
|
+
hash: Hex;
|
|
222
|
+
signature: Hex;
|
|
223
|
+
};
|
|
224
|
+
|
|
24
225
|
/**
|
|
25
226
|
* Make slug from text
|
|
26
227
|
*
|
|
@@ -62,4 +263,4 @@ declare class UrlResolver {
|
|
|
62
263
|
resolveUrl(url_: string): string;
|
|
63
264
|
}
|
|
64
265
|
|
|
65
|
-
export { type BrowserInfo, type BrowserName, EXTENSION_STORE_LINKS, UrlConverter, UrlResolver, browserInfo, slug };
|
|
266
|
+
export { type BrowserInfo, type BrowserName, EIP1271_MAGIC_VALUE, EXTENSION_STORE_LINKS, UrlConverter, UrlResolver, browserInfo, createMessage, createSignedQR, getControllerAddress, getEIP1271Data, isSignedQRFormat, parseSignedQR, slug, verifySignedQR };
|
package/dist/utils/index.js
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
|
+
EIP1271_MAGIC_VALUE,
|
|
2
3
|
EXTENSION_STORE_LINKS,
|
|
3
4
|
UrlConverter,
|
|
4
5
|
UrlResolver,
|
|
5
6
|
browserInfo,
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
createMessage,
|
|
8
|
+
createSignedQR,
|
|
9
|
+
getControllerAddress,
|
|
10
|
+
getEIP1271Data,
|
|
11
|
+
isSignedQRFormat,
|
|
12
|
+
parseSignedQR,
|
|
13
|
+
slug,
|
|
14
|
+
verifySignedQR
|
|
15
|
+
} from "../chunk-RECO5F6T.js";
|
|
8
16
|
import "../chunk-ET7EYHRY.js";
|
|
9
17
|
export {
|
|
18
|
+
EIP1271_MAGIC_VALUE,
|
|
10
19
|
EXTENSION_STORE_LINKS,
|
|
11
20
|
UrlConverter,
|
|
12
21
|
UrlResolver,
|
|
13
22
|
browserInfo,
|
|
14
|
-
|
|
23
|
+
createMessage,
|
|
24
|
+
createSignedQR,
|
|
25
|
+
getControllerAddress,
|
|
26
|
+
getEIP1271Data,
|
|
27
|
+
isSignedQRFormat,
|
|
28
|
+
parseSignedQR,
|
|
29
|
+
slug,
|
|
30
|
+
verifySignedQR
|
|
15
31
|
};
|
|
16
32
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { Hex } from 'viem'
|
|
2
|
+
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
createMessage,
|
|
6
|
+
createSignedQR,
|
|
7
|
+
EIP1271_MAGIC_VALUE,
|
|
8
|
+
getControllerAddress,
|
|
9
|
+
getEIP1271Data,
|
|
10
|
+
isSignedQRFormat,
|
|
11
|
+
parseSignedQR,
|
|
12
|
+
verifySignedQR,
|
|
13
|
+
} from '../signed-profile-urls.js'
|
|
14
|
+
|
|
15
|
+
describe('signed-qr-code', () => {
|
|
16
|
+
let privateKey: Hex
|
|
17
|
+
const profileAddress = '0x1234567890123456789012345678901234567890'
|
|
18
|
+
const chainId = 42
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
privateKey = generatePrivateKey()
|
|
22
|
+
// Mock Date.now for consistent timestamps in tests
|
|
23
|
+
vi.useFakeTimers()
|
|
24
|
+
vi.setSystemTime(new Date('2024-01-27T12:00:00Z'))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.useRealTimers()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('createMessage', () => {
|
|
32
|
+
it('creates a deterministic message', () => {
|
|
33
|
+
const message = createMessage(profileAddress, chainId, 1706356800)
|
|
34
|
+
expect(message).toBe(
|
|
35
|
+
'ethereum:0x1234567890123456789012345678901234567890@42?ts=1706356800'
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('lowercases the address', () => {
|
|
40
|
+
const message = createMessage(
|
|
41
|
+
'0xABCDEF1234567890123456789012345678901234',
|
|
42
|
+
chainId,
|
|
43
|
+
1706356800
|
|
44
|
+
)
|
|
45
|
+
expect(message).toContain('0xabcdef1234567890123456789012345678901234')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('createSignedQR', () => {
|
|
50
|
+
it('creates a valid signed URI', async () => {
|
|
51
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId)
|
|
52
|
+
|
|
53
|
+
expect(uri).toMatch(
|
|
54
|
+
/^ethereum:0x[a-f0-9]{40}@\d+\?ts=\d+&sig=0x[a-f0-9]+$/
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('uses current timestamp by default (no offset)', async () => {
|
|
59
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId)
|
|
60
|
+
const parsed = parseSignedQR(uri)
|
|
61
|
+
|
|
62
|
+
const now = Math.floor(Date.now() / 1000)
|
|
63
|
+
expect(parsed?.timestamp).toBe(now) // default offset is 0
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('respects generationOffsetSeconds option', async () => {
|
|
67
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId, {
|
|
68
|
+
generationOffsetSeconds: 3,
|
|
69
|
+
})
|
|
70
|
+
const parsed = parseSignedQR(uri)
|
|
71
|
+
|
|
72
|
+
const now = Math.floor(Date.now() / 1000)
|
|
73
|
+
expect(parsed?.timestamp).toBe(now + 3)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('caps generationOffsetSeconds at 5 seconds', async () => {
|
|
77
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId, {
|
|
78
|
+
generationOffsetSeconds: 100, // way over the limit
|
|
79
|
+
})
|
|
80
|
+
const parsed = parseSignedQR(uri)
|
|
81
|
+
|
|
82
|
+
const now = Math.floor(Date.now() / 1000)
|
|
83
|
+
expect(parsed?.timestamp).toBe(now + 5) // capped at 5
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does not allow negative generationOffsetSeconds', async () => {
|
|
87
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId, {
|
|
88
|
+
generationOffsetSeconds: -10,
|
|
89
|
+
})
|
|
90
|
+
const parsed = parseSignedQR(uri)
|
|
91
|
+
|
|
92
|
+
const now = Math.floor(Date.now() / 1000)
|
|
93
|
+
expect(parsed?.timestamp).toBe(now) // clamped to 0
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('uses custom signer when provided', async () => {
|
|
97
|
+
const controllerAddress = getControllerAddress(privateKey)
|
|
98
|
+
|
|
99
|
+
// Create a custom signer that uses signMessage (EIP-191)
|
|
100
|
+
const customSigner = async (message: string) => {
|
|
101
|
+
const account = privateKeyToAccount(privateKey)
|
|
102
|
+
return account.signMessage({ message })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const uri = await createSignedQR(null, profileAddress, chainId, {
|
|
106
|
+
signer: customSigner,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const result = await verifySignedQR(uri)
|
|
110
|
+
expect(result.isValid).toBe(true)
|
|
111
|
+
expect(result.recoveredAddress).toBe(controllerAddress.toLowerCase())
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('throws when neither privateKey nor signer is provided', async () => {
|
|
115
|
+
await expect(
|
|
116
|
+
createSignedQR(null, profileAddress, chainId)
|
|
117
|
+
).rejects.toThrow(
|
|
118
|
+
'Either a valid privateKey or options.signer must be provided'
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('throws when privateKey is 0x and no signer provided', async () => {
|
|
123
|
+
await expect(
|
|
124
|
+
createSignedQR('0x', profileAddress, chainId)
|
|
125
|
+
).rejects.toThrow(
|
|
126
|
+
'Either a valid privateKey or options.signer must be provided'
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('parseSignedQR', () => {
|
|
132
|
+
it('parses a valid URI', async () => {
|
|
133
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId)
|
|
134
|
+
const parsed = parseSignedQR(uri)
|
|
135
|
+
|
|
136
|
+
expect(parsed).not.toBeNull()
|
|
137
|
+
expect(parsed?.profileAddress).toBe(profileAddress.toLowerCase())
|
|
138
|
+
expect(parsed?.chainId).toBe(chainId)
|
|
139
|
+
expect(parsed?.signature).toMatch(/^0x[a-f0-9]+$/)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('returns null for invalid format', () => {
|
|
143
|
+
expect(parseSignedQR('invalid')).toBeNull()
|
|
144
|
+
expect(parseSignedQR('ethereum:invalid')).toBeNull()
|
|
145
|
+
expect(parseSignedQR('http://example.com')).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('returns null for missing signature', () => {
|
|
149
|
+
expect(
|
|
150
|
+
parseSignedQR(
|
|
151
|
+
'ethereum:0x1234567890123456789012345678901234567890@42?ts=123'
|
|
152
|
+
)
|
|
153
|
+
).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('verifySignedQR', () => {
|
|
158
|
+
it('verifies a freshly created QR', async () => {
|
|
159
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId, {
|
|
160
|
+
generationOffsetSeconds: 0,
|
|
161
|
+
})
|
|
162
|
+
const result = await verifySignedQR(uri)
|
|
163
|
+
|
|
164
|
+
expect(result.isValid).toBe(true)
|
|
165
|
+
expect(result.isExpired).toBe(false)
|
|
166
|
+
expect(result.profileAddress).toBe(profileAddress.toLowerCase())
|
|
167
|
+
expect(result.chainId).toBe(chainId)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('recovers the correct controller address', async () => {
|
|
171
|
+
const controllerAddress = getControllerAddress(privateKey)
|
|
172
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId)
|
|
173
|
+
const result = await verifySignedQR(uri)
|
|
174
|
+
|
|
175
|
+
expect(result.recoveredAddress).toBe(controllerAddress.toLowerCase())
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('marks expired QR as invalid', async () => {
|
|
179
|
+
// Create a QR at current time
|
|
180
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId)
|
|
181
|
+
|
|
182
|
+
// Advance time by 2 minutes (past the 60 second maxAge)
|
|
183
|
+
vi.advanceTimersByTime(120 * 1000)
|
|
184
|
+
|
|
185
|
+
const result = await verifySignedQR(uri, { maxAgeSeconds: 60 })
|
|
186
|
+
|
|
187
|
+
expect(result.isValid).toBe(false)
|
|
188
|
+
expect(result.isExpired).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('allows skipping timestamp validation', async () => {
|
|
192
|
+
// Create a QR at current time
|
|
193
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId)
|
|
194
|
+
|
|
195
|
+
// Advance time by 2 minutes (would normally be expired)
|
|
196
|
+
vi.advanceTimersByTime(120 * 1000)
|
|
197
|
+
|
|
198
|
+
const result = await verifySignedQR(uri, {
|
|
199
|
+
skipTimestampValidation: true,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(result.isValid).toBe(true)
|
|
203
|
+
expect(result.isExpired).toBe(false)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('throws for invalid URI format', async () => {
|
|
207
|
+
await expect(verifySignedQR('invalid')).rejects.toThrow(
|
|
208
|
+
'Invalid QR format'
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('isSignedQRFormat', () => {
|
|
214
|
+
it('returns true for valid format', async () => {
|
|
215
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId)
|
|
216
|
+
expect(isSignedQRFormat(uri)).toBe(true)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('returns false for invalid formats', () => {
|
|
220
|
+
expect(isSignedQRFormat('invalid')).toBe(false)
|
|
221
|
+
expect(isSignedQRFormat('ethereum:0x123')).toBe(false)
|
|
222
|
+
expect(isSignedQRFormat('http://example.com')).toBe(false)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('getControllerAddress', () => {
|
|
227
|
+
it('returns the address for a private key', () => {
|
|
228
|
+
const address = getControllerAddress(privateKey)
|
|
229
|
+
expect(address).toMatch(/^0x[a-fA-F0-9]{40}$/)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe('getEIP1271Data', () => {
|
|
234
|
+
it('returns hash and signature for EIP-1271 verification', async () => {
|
|
235
|
+
const uri = await createSignedQR(privateKey, profileAddress, chainId)
|
|
236
|
+
const result = await verifySignedQR(uri)
|
|
237
|
+
const { hash, signature } = getEIP1271Data(uri, result)
|
|
238
|
+
|
|
239
|
+
expect(hash).toMatch(/^0x[a-f0-9]{64}$/)
|
|
240
|
+
expect(signature).toMatch(/^0x[a-f0-9]+$/)
|
|
241
|
+
expect(hash).toBe(result.messageHash)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('EIP1271_MAGIC_VALUE', () => {
|
|
246
|
+
it('is the correct magic value', () => {
|
|
247
|
+
expect(EIP1271_MAGIC_VALUE).toBe('0x1626ba7e')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('round-trip verification', () => {
|
|
252
|
+
it('creates and verifies QR for different chain IDs', async () => {
|
|
253
|
+
for (const testChainId of [42, 4201, 1]) {
|
|
254
|
+
const uri = await createSignedQR(
|
|
255
|
+
privateKey,
|
|
256
|
+
profileAddress,
|
|
257
|
+
testChainId,
|
|
258
|
+
{ generationOffsetSeconds: 0 }
|
|
259
|
+
)
|
|
260
|
+
const result = await verifySignedQR(uri)
|
|
261
|
+
|
|
262
|
+
expect(result.isValid).toBe(true)
|
|
263
|
+
expect(result.chainId).toBe(testChainId)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('signature is deterministic for same inputs', async () => {
|
|
268
|
+
// With the same timestamp, the signature should be the same
|
|
269
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
270
|
+
const message1 = createMessage(profileAddress, chainId, timestamp)
|
|
271
|
+
const message2 = createMessage(profileAddress, chainId, timestamp)
|
|
272
|
+
|
|
273
|
+
expect(message1).toBe(message2)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
})
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
export type { BrowserInfo, BrowserName } from './browserInfo.js'
|
|
2
2
|
export { browserInfo, EXTENSION_STORE_LINKS } from './browserInfo.js'
|
|
3
|
-
|
|
3
|
+
export {
|
|
4
|
+
createMessage,
|
|
5
|
+
createSignedQR,
|
|
6
|
+
EIP1271_MAGIC_VALUE,
|
|
7
|
+
getControllerAddress,
|
|
8
|
+
getEIP1271Data,
|
|
9
|
+
isSignedQRFormat,
|
|
10
|
+
parseSignedQR,
|
|
11
|
+
verifySignedQR,
|
|
12
|
+
} from './signed-profile-urls.js'
|
|
4
13
|
export { slug } from './slug.js'
|
|
5
14
|
export { UrlConverter, UrlResolver } from './url-resolver.js'
|