accounts 0.5.5 → 0.5.7

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.
@@ -155,7 +155,12 @@ export function iframe(): Dialog {
155
155
  root.appendChild(style)
156
156
  root.appendChild(frame)
157
157
 
158
+ let readyResult: Messenger.ReadyOptions | undefined
159
+ let switchedToPopup = false
160
+
158
161
  function createMessenger() {
162
+ readyResult = undefined
163
+
159
164
  const m = Messenger.bridge({
160
165
  from: Messenger.fromWindow(window, { targetOrigin: hostUrl.origin }),
161
166
  to: Messenger.fromWindow(frame.contentWindow!, {
@@ -164,6 +169,23 @@ export function iframe(): Dialog {
164
169
  waitForReady: true,
165
170
  })
166
171
  m.on('rpc-response', (response) => handleResponse(store!, response))
172
+ m.waitForReady().then((result) => {
173
+ readyResult = result
174
+ if (result.colorScheme) frame.style.colorScheme = result.colorScheme
175
+ })
176
+ m.on('switch-mode', () => {
177
+ hideDialog()
178
+ activatePage()
179
+ open = false
180
+ switchedToPopup = true
181
+
182
+ const pending = store
183
+ ?.getState()
184
+ .requestQueue.filter(
185
+ (x): x is Store.QueuedRequest & { status: 'pending' } => x.status === 'pending',
186
+ )
187
+ if (pending && pending.length > 0) fallback?.syncRequests(pending)
188
+ })
167
189
  return m
168
190
  }
169
191
 
@@ -254,23 +276,6 @@ export function iframe(): Dialog {
254
276
  }
255
277
  }
256
278
 
257
- messenger.waitForReady().then(({ colorScheme }) => {
258
- if (colorScheme) frame.style.colorScheme = colorScheme
259
- })
260
-
261
- messenger.on('switch-mode', () => {
262
- hideDialog()
263
- activatePage()
264
- open = false
265
-
266
- const pending = store!
267
- .getState()
268
- .requestQueue.filter(
269
- (x): x is Store.QueuedRequest & { status: 'pending' } => x.status === 'pending',
270
- )
271
- if (pending.length > 0) fallback!.syncRequests(pending)
272
- })
273
-
274
279
  const instance: Instance = {
275
280
  close() {
276
281
  fallback!.close()
@@ -304,7 +309,12 @@ export function iframe(): Dialog {
304
309
  activateDialog()
305
310
  },
306
311
  async syncRequests(requests) {
307
- const { trustedHosts } = await messenger.waitForReady()
312
+ if (switchedToPopup) {
313
+ fallback!.syncRequests(requests)
314
+ return
315
+ }
316
+
317
+ const { trustedHosts } = readyResult ?? (await messenger.waitForReady())
308
318
 
309
319
  // Safari does not support WebAuthn credential creation in iframes.
310
320
  if (
@@ -358,10 +368,6 @@ export function popup(options: popup.Options = {}): Dialog {
358
368
 
359
369
  let win: Window | null = null
360
370
 
361
- function onBlur() {
362
- if (win) handleBlur(store)
363
- }
364
-
365
371
  const offDetectClosed = (() => {
366
372
  const timer = setInterval(() => {
367
373
  if (win?.closed) handleBlur(store)
@@ -371,19 +377,57 @@ export function popup(options: popup.Options = {}): Dialog {
371
377
 
372
378
  let messenger: Messenger.Bridge | undefined
373
379
 
380
+ const overlay = document.createElement('div')
381
+ Object.assign(overlay.style, {
382
+ alignItems: 'center',
383
+ background: 'rgba(0, 0, 0, 0.5)',
384
+ color: 'white',
385
+ display: 'none',
386
+ flexDirection: 'column',
387
+ fontFamily: 'system-ui, sans-serif',
388
+ fontSize: '16px',
389
+ gap: '12px',
390
+ inset: '0',
391
+ justifyContent: 'center',
392
+ position: 'fixed',
393
+ zIndex: '2147483647',
394
+ })
395
+ const overlayMessage = document.createElement('p')
396
+ Object.assign(overlayMessage.style, { margin: '0' })
397
+ overlayMessage.textContent = 'Continue in the popup window'
398
+ const overlayClose = document.createElement('button')
399
+ Object.assign(overlayClose.style, {
400
+ background: 'none',
401
+ border: 'none',
402
+ color: 'white',
403
+ cursor: 'pointer',
404
+ font: 'inherit',
405
+ padding: '0',
406
+ textDecoration: 'underline',
407
+ })
408
+ overlayClose.textContent = 'Close'
409
+ overlayClose.addEventListener('click', () => handleBlur(store))
410
+ overlay.appendChild(overlayMessage)
411
+ overlay.appendChild(overlayClose)
412
+ document.body.appendChild(overlay)
413
+
374
414
  return {
375
415
  close() {
416
+ overlay.style.display = 'none'
376
417
  if (!win) return
377
418
  win.close()
378
419
  win = null
379
420
  },
380
421
  destroy() {
381
422
  this.close()
382
- window.removeEventListener('focus', onBlur)
383
423
  messenger?.destroy()
384
424
  offDetectClosed()
425
+ overlay.remove()
385
426
  },
386
427
  open() {
428
+ messenger?.destroy()
429
+ win?.close()
430
+
387
431
  const referrer = getReferrer()
388
432
 
389
433
  const hostUrl = new URL(host)
@@ -415,14 +459,13 @@ export function popup(options: popup.Options = {}): Dialog {
415
459
 
416
460
  messenger.on('rpc-response', (response) => handleResponse(store, response))
417
461
 
418
- window.removeEventListener('focus', onBlur)
419
- window.addEventListener('focus', onBlur)
462
+ overlay.style.display = 'flex'
420
463
  },
421
464
  async syncRequests(requests) {
422
465
  const requiresConfirm = requests.some((x) => x.status === 'pending')
423
466
  if (requiresConfirm) {
424
467
  if (!win || win.closed) this.open()
425
- win?.focus()
468
+ else win.focus()
426
469
  }
427
470
  messenger?.send('rpc-requests', {
428
471
  account: getAccount(store),
@@ -1642,6 +1642,20 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
1642
1642
  expect(signed).toMatch(/^0x/)
1643
1643
  })
1644
1644
 
1645
+ test('behavior: feePayer URL on eth_fillTransaction', async () => {
1646
+ const provider = Provider.create({ adapter: adapter(), chains: [chain] })
1647
+
1648
+ const connected = await connect(provider)
1649
+ await fund(connected)
1650
+
1651
+ const result = await provider.request({
1652
+ method: 'eth_fillTransaction',
1653
+ params: [{ calls: [transferCall], feePayer: server.url, from: connected }],
1654
+ })
1655
+
1656
+ expect((result.tx as { feePayerSignature?: unknown }).feePayerSignature).toBeDefined()
1657
+ })
1658
+
1645
1659
  test('behavior: feePayer: true uses default from Provider.create', async () => {
1646
1660
  const provider = Provider.create({
1647
1661
  adapter: adapter(),
@@ -208,6 +208,7 @@ export function create(options: create.Options = {}): create.ReturnType {
208
208
  return (await actions.sendTransaction(
209
209
  {
210
210
  ...rest,
211
+ chainId: decoded.chainId ?? store.getState().chainId,
211
212
  ...(calls ? { calls } : {}),
212
213
  feePayer: resolveFeePayer(decoded.feePayer),
213
214
  },
@@ -216,16 +217,31 @@ export function create(options: create.Options = {}): create.ReturnType {
216
217
  }
217
218
 
218
219
  case 'eth_fillTransaction': {
219
- const [parameters] = request.params
220
+ const [decoded] = request._decoded.params
221
+ const parameters = { ...decoded }
220
222
  const chainId = parameters.chainId
221
- ? Hex.toNumber(parameters.chainId)
222
- : undefined
223
+ const feePayer = resolveFeePayer(parameters.feePayer)
223
224
 
224
- const fill = (params: typeof parameters) =>
225
- getClient({ chainId }).request({
225
+ type FillParams = z.output<typeof Rpc.transactionRequest> & {
226
+ keyAuthorization?: unknown
227
+ }
228
+ const fill = (params: FillParams) => {
229
+ const client = getClient({ chainId, feePayer })
230
+ const fillRequest = {
231
+ ...params,
232
+ chainId: params.chainId ?? client.chain?.id,
233
+ ...(feePayer ? { feePayer: true } : {}),
234
+ }
235
+ const formatter = client.chain?.formatters?.transactionRequest
236
+ const formatted =
237
+ formatter && !fillRequest.keyAuthorization
238
+ ? formatter.format({ ...fillRequest } as never, 'fillTransaction')
239
+ : fillRequest
240
+ return client.request({
226
241
  method: 'eth_fillTransaction',
227
- params: [params],
242
+ params: [formatted as never],
228
243
  })
244
+ }
229
245
 
230
246
  // Inject pending keyAuthorization so the node accounts for
231
247
  // key authorization gas during estimation.
@@ -243,8 +259,10 @@ export function create(options: create.Options = {}): create.ReturnType {
243
259
  try {
244
260
  const result = await fill({
245
261
  ...parameters,
246
- // @ts-expect-error - TODO: fix
247
- keyAuthorization: KeyAuthorization.toRpc(keyAuth),
262
+ keyAuthorization: {
263
+ address: keyAuth.address,
264
+ ...KeyAuthorization.toRpc(keyAuth),
265
+ } as never,
248
266
  })
249
267
  AccessKey.removePending(account, { store })
250
268
  return result
@@ -268,6 +286,7 @@ export function create(options: create.Options = {}): create.ReturnType {
268
286
  return (await actions.signTransaction(
269
287
  {
270
288
  ...rest,
289
+ chainId: decoded.chainId ?? store.getState().chainId,
271
290
  ...(calls ? { calls } : {}),
272
291
  feePayer: resolveFeePayer(decoded.feePayer),
273
292
  },
@@ -284,6 +303,7 @@ export function create(options: create.Options = {}): create.ReturnType {
284
303
  return (await actions.sendTransactionSync(
285
304
  {
286
305
  ...rest,
306
+ chainId: decoded.chainId ?? store.getState().chainId,
287
307
  ...(calls ? { calls } : {}),
288
308
  feePayer: resolveFeePayer(decoded.feePayer),
289
309
  },
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import { accounts, privateKeys } from '../../../test/config.js'
4
+ import * as Provider from '../Provider.js'
5
+ import * as Storage from '../Storage.js'
6
+ import { dangerous_secp256k1 } from './dangerous_secp256k1.js'
7
+
8
+ describe('dangerous_secp256k1', () => {
9
+ test('behavior: privateKey option pins the connected account', async () => {
10
+ const account = accounts[1]!
11
+ const provider = Provider.create({
12
+ adapter: dangerous_secp256k1({ privateKey: privateKeys[1]! }),
13
+ storage: Storage.memory({ key: 'dangerous-secp256k1-private-key' }),
14
+ })
15
+
16
+ const result = await provider.request({ method: 'wallet_connect' })
17
+ expect(result.accounts).toHaveLength(1)
18
+ expect(result.accounts[0]!.address).toBe(account.address)
19
+
20
+ const connected = await provider.request({ method: 'eth_accounts' })
21
+ expect(connected).toHaveLength(1)
22
+ expect(connected[0]).toBe(account.address)
23
+ })
24
+ })
@@ -1,4 +1,5 @@
1
- import { Account, Secp256k1 } from 'viem/tempo'
1
+ import type { Hex } from 'viem'
2
+ import { Account as TempoAccount, Secp256k1 } from 'viem/tempo'
2
3
 
3
4
  import * as Adapter from '../Adapter.js'
4
5
  import { local } from './local.js'
@@ -21,20 +22,30 @@ import { local } from './local.js'
21
22
  * ```
22
23
  */
23
24
  export function dangerous_secp256k1(options: dangerous_secp256k1.Options = {}): Adapter.Adapter {
24
- const { icon, name, rdns } = options
25
+ const { icon, name, privateKey, rdns } = options
26
+ const fixed = privateKey
27
+ ? {
28
+ address: TempoAccount.fromSecp256k1(privateKey).address,
29
+ keyType: 'secp256k1' as const,
30
+ privateKey,
31
+ }
32
+ : undefined
25
33
 
26
34
  return Adapter.define({ icon, name, rdns }, (config) => {
27
35
  const { store } = config
28
36
 
29
37
  return local({
30
38
  async createAccount() {
39
+ if (fixed) return { accounts: [fixed] }
40
+
31
41
  const privateKey = Secp256k1.randomPrivateKey()
32
- const account = Account.fromSecp256k1(privateKey)
42
+ const generated = TempoAccount.fromSecp256k1(privateKey)
33
43
  return {
34
- accounts: [{ address: account.address, keyType: 'secp256k1' as const, privateKey }],
44
+ accounts: [{ address: generated.address, keyType: 'secp256k1' as const, privateKey }],
35
45
  }
36
46
  },
37
47
  async loadAccounts() {
48
+ if (fixed) return { accounts: [fixed] }
38
49
  return { accounts: [...store.getState().accounts] }
39
50
  },
40
51
  })(config)
@@ -47,6 +58,8 @@ export declare namespace dangerous_secp256k1 {
47
58
  icon?: `data:image/${string}` | undefined
48
59
  /** Display name of the provider (e.g. `"My Wallet"`). @default "Injected Wallet" */
49
60
  name?: string | undefined
61
+ /** Fixed private key to expose instead of generating/loading one from storage. */
62
+ privateKey?: Hex | undefined
50
63
  /** Reverse DNS identifier. @default `com.{lowercase name}` */
51
64
  rdns?: string | undefined
52
65
  }
@@ -1,9 +1,10 @@
1
1
  import { Elysia } from 'elysia'
2
2
  import express from 'express'
3
3
  import { Hono } from 'hono'
4
- import type { RpcRequest } from 'ox'
4
+ import { Json, type RpcRequest } from 'ox'
5
+ import { SignatureEnvelope, Transaction as core_Transaction, TxEnvelopeTempo } from 'ox/tempo'
5
6
  import { sendTransactionSync } from 'viem/actions'
6
- import { withFeePayer } from 'viem/tempo'
7
+ import { Transaction, withFeePayer } from 'viem/tempo'
7
8
  import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vp/test'
8
9
 
9
10
  import { accounts, chain, getClient, http } from '../../test/config.js'
@@ -732,7 +733,98 @@ describe('feePayer', () => {
732
733
  requests = []
733
734
  })
734
735
 
736
+ async function rpc(request: Record<string, unknown>) {
737
+ return await fetch(server.url, {
738
+ body: Json.stringify(request),
739
+ headers: { 'content-type': 'application/json' },
740
+ method: 'POST',
741
+ }).then((response) => response.json())
742
+ }
743
+
744
+ /** Signs a sponsor-bound Tempo transaction, preserving the feePayerSignature. */
745
+ async function signSponsoredTx(account: (typeof accounts)[number], transaction: object) {
746
+ const serialized = (await Transaction.serialize(transaction as never)) as `0x76${string}`
747
+ const envelope = TxEnvelopeTempo.deserialize(serialized)
748
+ const signature = await account.sign({
749
+ hash: TxEnvelopeTempo.getSignPayload(envelope),
750
+ })
751
+ return TxEnvelopeTempo.serialize(envelope, {
752
+ signature: SignatureEnvelope.from(signature),
753
+ })
754
+ }
755
+
735
756
  describe('POST /', () => {
757
+ test('default: eth_fillTransaction returns a sponsor-bound transaction the sender can broadcast', async () => {
758
+ const response = (await rpc({
759
+ id: 1,
760
+ jsonrpc: '2.0',
761
+ method: 'eth_fillTransaction',
762
+ params: [
763
+ {
764
+ chainId: chain.id,
765
+ feePayer: true,
766
+ from: userAccount.address,
767
+ to: '0x0000000000000000000000000000000000000000',
768
+ },
769
+ ],
770
+ })) as {
771
+ result: {
772
+ sponsor: { address: string }
773
+ tx: Record<string, unknown>
774
+ }
775
+ }
776
+ const prepared = core_Transaction.fromRpc(response.result.tx as never) as {
777
+ feePayerSignature?: unknown
778
+ }
779
+ const signed = await signSponsoredTx(userAccount, prepared)
780
+ const receipt = (await getClient().request({
781
+ method: 'eth_sendRawTransactionSync',
782
+ params: [signed],
783
+ })) as { feePayer?: string | undefined }
784
+
785
+ expect(response.result.sponsor.address).toBe(feePayerAccount.address)
786
+ expect(prepared?.feePayerSignature).toBeDefined()
787
+ expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
788
+ expect(requests.map(({ method }) => method)).toMatchInlineSnapshot(`
789
+ [
790
+ "eth_fillTransaction",
791
+ ]
792
+ `)
793
+ })
794
+
795
+ test('behavior: mutating a sponsor-bound transaction invalidates the fee payer binding', async () => {
796
+ const response = (await rpc({
797
+ id: 1,
798
+ jsonrpc: '2.0',
799
+ method: 'eth_fillTransaction',
800
+ params: [
801
+ {
802
+ chainId: chain.id,
803
+ feePayer: true,
804
+ from: userAccount.address,
805
+ to: '0x0000000000000000000000000000000000000000',
806
+ },
807
+ ],
808
+ })) as {
809
+ result: { tx: Record<string, unknown> }
810
+ }
811
+ const prepared = core_Transaction.fromRpc(response.result.tx as never) as {
812
+ gas?: bigint | undefined
813
+ feePayerSignature?: unknown
814
+ }
815
+ const signed = await signSponsoredTx(userAccount, {
816
+ ...prepared,
817
+ gas: (prepared?.gas ?? 0n) + 1n,
818
+ })
819
+
820
+ await expect(
821
+ getClient().request({
822
+ method: 'eth_sendRawTransactionSync',
823
+ params: [signed],
824
+ }),
825
+ ).rejects.toThrowError()
826
+ })
827
+
736
828
  test('behavior: eth_signRawTransaction', async () => {
737
829
  const client = getClient({
738
830
  account: userAccount,
@@ -835,8 +927,8 @@ describe('feePayer', () => {
835
927
  expect(data).toMatchInlineSnapshot(`
836
928
  {
837
929
  "error": {
838
- "code": -32603,
839
- "name": "RpcResponse.InternalError",
930
+ "code": -32602,
931
+ "name": "RpcResponse.InvalidParamsError",
840
932
  "stack": "",
841
933
  },
842
934
  "id": 1,