@lukso/core 1.2.10 → 1.2.11-dev.6cff3c3

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.
@@ -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 };
@@ -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
- slug
7
- } from "../chunk-GFLV5EJV.js";
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
- slug
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukso/core",
3
- "version": "1.2.10",
3
+ "version": "1.2.11-dev.6cff3c3",
4
4
  "description": "Core utilities, services, and mixins for LUKSO web components and applications",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -91,9 +91,9 @@
91
91
  "access": "public"
92
92
  },
93
93
  "dependencies": {
94
- "@formatjs/intl": "^4.0.8",
95
- "@preact/signals-core": "^1.12.1",
96
- "ua-parser-js": "^2.0.7"
94
+ "@formatjs/intl": "^4.1.2",
95
+ "@preact/signals-core": "^1.13.0",
96
+ "ua-parser-js": "^2.0.9"
97
97
  },
98
98
  "peerDependencies": {
99
99
  "lit": "3.3.1",
@@ -105,11 +105,11 @@
105
105
  }
106
106
  },
107
107
  "devDependencies": {
108
- "lit": "3.3.1",
108
+ "lit": "3.3.2",
109
109
  "tsup": "^8.5.1",
110
110
  "typescript": "^5.9.3",
111
- "viem": "^2.43.5",
112
- "vitest": "^4.0.16"
111
+ "viem": "^2.45.2",
112
+ "vitest": "^4.0.18"
113
113
  },
114
114
  "tsup": {
115
115
  "entry": [
@@ -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
+ })
@@ -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'