accounts 0.8.1 → 0.8.2
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/CHANGELOG.md +16 -0
- package/dist/core/Adapter.d.ts +6 -0
- package/dist/core/Adapter.d.ts.map +1 -1
- package/dist/core/Adapter.js.map +1 -1
- package/dist/core/Client.d.ts.map +1 -1
- package/dist/core/Client.js +16 -2
- package/dist/core/Client.js.map +1 -1
- package/dist/core/Provider.d.ts.map +1 -1
- package/dist/core/Provider.js +15 -0
- package/dist/core/Provider.js.map +1 -1
- package/dist/core/Remote.d.ts.map +1 -1
- package/dist/core/Remote.js +0 -6
- package/dist/core/Remote.js.map +1 -1
- package/dist/core/Schema.d.ts +85 -0
- package/dist/core/Schema.d.ts.map +1 -1
- package/dist/core/Schema.js +1 -0
- package/dist/core/Schema.js.map +1 -1
- package/dist/core/adapters/dialog.d.ts.map +1 -1
- package/dist/core/adapters/dialog.js +8 -1
- package/dist/core/adapters/dialog.js.map +1 -1
- package/dist/core/adapters/local.d.ts.map +1 -1
- package/dist/core/adapters/local.js +19 -5
- package/dist/core/adapters/local.js.map +1 -1
- package/dist/core/zod/rpc.d.ts +71 -0
- package/dist/core/zod/rpc.d.ts.map +1 -1
- package/dist/core/zod/rpc.js +25 -0
- package/dist/core/zod/rpc.js.map +1 -1
- package/dist/server/internal/handlers/relay.js +85 -3
- package/dist/server/internal/handlers/relay.js.map +1 -1
- package/package.json +4 -4
- package/src/core/Adapter.ts +12 -0
- package/src/core/Client.ts +14 -2
- package/src/core/Provider.test.ts +103 -4
- package/src/core/Provider.ts +19 -0
- package/src/core/Remote.ts +0 -7
- package/src/core/Schema.test-d.ts +18 -1
- package/src/core/Schema.ts +1 -0
- package/src/core/adapters/dialog.ts +9 -1
- package/src/core/adapters/local.ts +22 -5
- package/src/core/zod/rpc.ts +28 -0
- package/src/server/internal/handlers/relay.test.ts +121 -0
- package/src/server/internal/handlers/relay.ts +105 -3
package/src/core/Client.ts
CHANGED
|
@@ -22,8 +22,9 @@ export function fromChainId(
|
|
|
22
22
|
const { chains, feePayer: feePayerOption, provider, store } = options
|
|
23
23
|
const feePayerUrl = (() => {
|
|
24
24
|
if (feePayerOption === false) return undefined
|
|
25
|
-
if (typeof feePayerOption === 'string') return feePayerOption
|
|
26
|
-
|
|
25
|
+
if (typeof feePayerOption === 'string') return normalizeFeePayerUrl(feePayerOption)
|
|
26
|
+
if (feePayerOption?.url) return normalizeFeePayerUrl(feePayerOption.url)
|
|
27
|
+
return undefined
|
|
27
28
|
})()
|
|
28
29
|
const precedence = (() => {
|
|
29
30
|
if (typeof feePayerOption === 'object' && feePayerOption !== null)
|
|
@@ -87,6 +88,17 @@ function providerTransport(provider: ox_Provider.Provider, base: Transport): Tra
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Resolves a fee payer URL to an absolute URL string. Relative paths (e.g.
|
|
93
|
+
* `/relay`) are resolved against `window.location.origin` when running in a
|
|
94
|
+
* browser; on the server, relative paths are returned as-is.
|
|
95
|
+
*/
|
|
96
|
+
function normalizeFeePayerUrl(url: string): string {
|
|
97
|
+
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
|
98
|
+
if (typeof window !== 'undefined') return new URL(url, window.location.origin).href
|
|
99
|
+
return url
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
function feePayerTransport(
|
|
91
103
|
base: Transport,
|
|
92
104
|
url: string,
|
|
@@ -642,6 +642,28 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
|
|
|
642
642
|
})
|
|
643
643
|
})
|
|
644
644
|
|
|
645
|
+
describe('wallet_send', () => {
|
|
646
|
+
test('error: throws UnsupportedMethodError when adapter has no send action', async () => {
|
|
647
|
+
const provider = Provider.create({ adapter: adapter(), chains: [chain] })
|
|
648
|
+
await connect(provider)
|
|
649
|
+
|
|
650
|
+
await expect(
|
|
651
|
+
provider.request({
|
|
652
|
+
method: 'wallet_send',
|
|
653
|
+
params: [
|
|
654
|
+
{
|
|
655
|
+
to: '0x0000000000000000000000000000000000000001',
|
|
656
|
+
token: Addresses.pathUsd,
|
|
657
|
+
value: '1',
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
}),
|
|
661
|
+
).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
662
|
+
`[Provider.UnsupportedMethodError: \`send\` not supported by adapter.]`,
|
|
663
|
+
)
|
|
664
|
+
})
|
|
665
|
+
})
|
|
666
|
+
|
|
645
667
|
describe('wallet_getCapabilities', () => {
|
|
646
668
|
test('default: returns atomic supported for all chains', async () => {
|
|
647
669
|
const provider = Provider.create({ adapter: adapter() })
|
|
@@ -1214,24 +1236,95 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
|
|
|
1214
1236
|
})
|
|
1215
1237
|
|
|
1216
1238
|
describe('wallet_revokeAccessKey', () => {
|
|
1217
|
-
test('default: revokes a granted access key', async () => {
|
|
1239
|
+
test('default: revokes a granted access key on-chain', async () => {
|
|
1218
1240
|
const provider = Provider.create({ adapter: adapter(), chains: [chain] })
|
|
1219
1241
|
await connect(provider)
|
|
1220
1242
|
|
|
1221
1243
|
const connected = (await provider.request({ method: 'eth_accounts' }))[0]!
|
|
1244
|
+
await fund(connected)
|
|
1245
|
+
|
|
1222
1246
|
const { keyAuthorization } = await provider.request({
|
|
1223
1247
|
method: 'wallet_authorizeAccessKey',
|
|
1224
1248
|
params: [{ expiry: Expiry.days(1) }],
|
|
1225
1249
|
})
|
|
1226
1250
|
|
|
1251
|
+
// Send a tx to register the key on-chain via keyAuthorization.
|
|
1252
|
+
await provider.request({
|
|
1253
|
+
method: 'eth_sendTransactionSync',
|
|
1254
|
+
params: [{ calls: [transferCall] }],
|
|
1255
|
+
})
|
|
1256
|
+
|
|
1257
|
+
// Key should exist on-chain before revocation.
|
|
1258
|
+
const client = getClient()
|
|
1259
|
+
const before = await Actions.accessKey.getMetadata(client, {
|
|
1260
|
+
account: connected,
|
|
1261
|
+
accessKey: keyAuthorization.address,
|
|
1262
|
+
})
|
|
1263
|
+
expect(before.isRevoked).toBe(false)
|
|
1264
|
+
|
|
1227
1265
|
await provider.request({
|
|
1228
1266
|
method: 'wallet_revokeAccessKey',
|
|
1229
1267
|
params: [{ address: connected, accessKeyAddress: keyAuthorization.address }],
|
|
1230
1268
|
})
|
|
1231
1269
|
|
|
1232
|
-
//
|
|
1233
|
-
const
|
|
1234
|
-
|
|
1270
|
+
// Key should be revoked on-chain.
|
|
1271
|
+
const after = await Actions.accessKey.getMetadata(client, {
|
|
1272
|
+
account: connected,
|
|
1273
|
+
accessKey: keyAuthorization.address,
|
|
1274
|
+
})
|
|
1275
|
+
expect(after.isRevoked).toBe(true)
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
test('behavior: removes key from local store', async () => {
|
|
1279
|
+
const provider = Provider.create({ adapter: adapter(), chains: [chain] })
|
|
1280
|
+
await connect(provider)
|
|
1281
|
+
|
|
1282
|
+
const connected = (await provider.request({ method: 'eth_accounts' }))[0]!
|
|
1283
|
+
await fund(connected)
|
|
1284
|
+
|
|
1285
|
+
const { keyAuthorization } = await provider.request({
|
|
1286
|
+
method: 'wallet_authorizeAccessKey',
|
|
1287
|
+
params: [{ expiry: Expiry.days(1) }],
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
// Register the key on-chain.
|
|
1291
|
+
await provider.request({
|
|
1292
|
+
method: 'eth_sendTransactionSync',
|
|
1293
|
+
params: [{ calls: [transferCall] }],
|
|
1294
|
+
})
|
|
1295
|
+
|
|
1296
|
+
expect(provider.store.getState().accessKeys).toHaveLength(1)
|
|
1297
|
+
|
|
1298
|
+
await provider.request({
|
|
1299
|
+
method: 'wallet_revokeAccessKey',
|
|
1300
|
+
params: [{ address: connected, accessKeyAddress: keyAuthorization.address }],
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
expect(provider.store.getState().accessKeys).toMatchInlineSnapshot(`[]`)
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
test('behavior: root key still works after revoking access key', async () => {
|
|
1307
|
+
const provider = Provider.create({ adapter: adapter(), chains: [chain] })
|
|
1308
|
+
await connect(provider)
|
|
1309
|
+
|
|
1310
|
+
const connected = (await provider.request({ method: 'eth_accounts' }))[0]!
|
|
1311
|
+
await fund(connected)
|
|
1312
|
+
|
|
1313
|
+
const { keyAuthorization } = await provider.request({
|
|
1314
|
+
method: 'wallet_authorizeAccessKey',
|
|
1315
|
+
params: [{ expiry: Expiry.days(1) }],
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
// Register the key on-chain, then revoke it.
|
|
1319
|
+
await provider.request({
|
|
1320
|
+
method: 'eth_sendTransactionSync',
|
|
1321
|
+
params: [{ calls: [transferCall] }],
|
|
1322
|
+
})
|
|
1323
|
+
|
|
1324
|
+
await provider.request({
|
|
1325
|
+
method: 'wallet_revokeAccessKey',
|
|
1326
|
+
params: [{ address: connected, accessKeyAddress: keyAuthorization.address }],
|
|
1327
|
+
})
|
|
1235
1328
|
|
|
1236
1329
|
const receipt = await provider.request({
|
|
1237
1330
|
method: 'eth_sendTransactionSync',
|
|
@@ -1292,6 +1385,12 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
|
|
|
1292
1385
|
})
|
|
1293
1386
|
expect(provider.store.getState().accessKeys).toHaveLength(1)
|
|
1294
1387
|
|
|
1388
|
+
// Send a tx to register the key on-chain via keyAuthorization.
|
|
1389
|
+
await provider.request({
|
|
1390
|
+
method: 'eth_sendTransactionSync',
|
|
1391
|
+
params: [{ calls: [transferCall] }],
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1295
1394
|
// Revoke the access key on-chain so the node will reject it.
|
|
1296
1395
|
const { accessKeys } = provider.store.getState()
|
|
1297
1396
|
const accessKeyAddress = accessKeys[0]!.address
|
package/src/core/Provider.ts
CHANGED
|
@@ -619,6 +619,25 @@ export function create(options: create.Options = {}): create.ReturnType {
|
|
|
619
619
|
)) satisfies Rpc.wallet_deposit.Encoded['returns']
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
+
case 'wallet_send': {
|
|
623
|
+
assertConnected()
|
|
624
|
+
if (!actions.send)
|
|
625
|
+
throw new ox_Provider.UnsupportedMethodError({
|
|
626
|
+
message: '`send` not supported by adapter.',
|
|
627
|
+
})
|
|
628
|
+
const decoded = request._decoded.params?.[0] ?? {}
|
|
629
|
+
const parameters = {
|
|
630
|
+
...decoded,
|
|
631
|
+
...(typeof decoded.feePayer !== 'undefined'
|
|
632
|
+
? { feePayer: resolveFeePayer(decoded.feePayer) }
|
|
633
|
+
: {}),
|
|
634
|
+
} as Adapter.send.Parameters
|
|
635
|
+
return (await actions.send(
|
|
636
|
+
parameters,
|
|
637
|
+
request,
|
|
638
|
+
)) satisfies Rpc.wallet_send.Encoded['returns']
|
|
639
|
+
}
|
|
640
|
+
|
|
622
641
|
case 'wallet_switchEthereumChain': {
|
|
623
642
|
const { chainId } = request._decoded.params[0]
|
|
624
643
|
if (!chains.some((c) => c.id === chainId))
|
package/src/core/Remote.ts
CHANGED
|
@@ -210,15 +210,8 @@ export function create(options: create.Options): Remote {
|
|
|
210
210
|
if (typeof window !== 'undefined') {
|
|
211
211
|
const params = new URLSearchParams(window.location.search)
|
|
212
212
|
const mode = params.get('mode') as State['mode']
|
|
213
|
-
const chainId = Number(params.get('chainId'))
|
|
214
213
|
|
|
215
214
|
if (mode) store.setState({ mode })
|
|
216
|
-
|
|
217
|
-
if (chainId && provider.store.getState().chainId !== chainId)
|
|
218
|
-
provider.request({
|
|
219
|
-
method: 'wallet_switchEthereumChain',
|
|
220
|
-
params: [{ chainId: Hex.fromNumber(chainId) }],
|
|
221
|
-
})
|
|
222
215
|
}
|
|
223
216
|
},
|
|
224
217
|
|
|
@@ -121,6 +121,22 @@ describe('Encoded', () => {
|
|
|
121
121
|
}>()
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
+
test('wallet_send', () => {
|
|
125
|
+
expectTypeOf<Rpc.wallet_send.Encoded>().toMatchTypeOf<{
|
|
126
|
+
method: 'wallet_send'
|
|
127
|
+
params:
|
|
128
|
+
| readonly [
|
|
129
|
+
{
|
|
130
|
+
to?: Hex | undefined
|
|
131
|
+
token?: Hex | undefined
|
|
132
|
+
value?: string | undefined
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
| undefined
|
|
136
|
+
returns: { receipt: { transactionHash: Hex } }
|
|
137
|
+
}>()
|
|
138
|
+
})
|
|
139
|
+
|
|
124
140
|
test('wallet_switchEthereumChain', () => {
|
|
125
141
|
expectTypeOf<Rpc.wallet_switchEthereumChain.Encoded>().toEqualTypeOf<{
|
|
126
142
|
method: 'wallet_switchEthereumChain'
|
|
@@ -159,7 +175,7 @@ describe('Ox', () => {
|
|
|
159
175
|
describe('Viem', () => {
|
|
160
176
|
test('is a tuple of all provider methods', () => {
|
|
161
177
|
expectTypeOf<Schema.Viem[0]['Method']>().toEqualTypeOf<'eth_accounts'>()
|
|
162
|
-
expectTypeOf<Schema.Viem[
|
|
178
|
+
expectTypeOf<Schema.Viem[19]['Method']>().toEqualTypeOf<'wallet_switchEthereumChain'>()
|
|
163
179
|
})
|
|
164
180
|
})
|
|
165
181
|
|
|
@@ -186,6 +202,7 @@ describe('Request', () => {
|
|
|
186
202
|
| 'wallet_deposit'
|
|
187
203
|
| 'wallet_getBalances'
|
|
188
204
|
| 'wallet_revokeAccessKey'
|
|
205
|
+
| 'wallet_send'
|
|
189
206
|
>()
|
|
190
207
|
})
|
|
191
208
|
|
package/src/core/Schema.ts
CHANGED
|
@@ -166,7 +166,8 @@ export function dialog(options: dialog.Options = {}): Adapter.Adapter {
|
|
|
166
166
|
const result = await fn(account, keyAuthorization ?? undefined)
|
|
167
167
|
AccessKey.removePending(account, { store })
|
|
168
168
|
return result
|
|
169
|
-
} catch {
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.warn('[accounts] silent sign with access key failed, removing key:', err)
|
|
170
171
|
AccessKey.remove(account, { store })
|
|
171
172
|
return undefined
|
|
172
173
|
}
|
|
@@ -395,6 +396,13 @@ export function dialog(options: dialog.Options = {}): Adapter.Adapter {
|
|
|
395
396
|
return await provider.request(request)
|
|
396
397
|
},
|
|
397
398
|
|
|
399
|
+
async send(params, request) {
|
|
400
|
+
return await provider.request({
|
|
401
|
+
...request,
|
|
402
|
+
params: [z.encode(Rpc.wallet_send.parameters, params)] as const,
|
|
403
|
+
})
|
|
404
|
+
},
|
|
405
|
+
|
|
398
406
|
async disconnect() {
|
|
399
407
|
store.setState({ accessKeys: [], accounts: [], activeAccount: 0 })
|
|
400
408
|
},
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Address as ox_Address, Hex, Provider as ox_Provider, PublicKey, WebCryptoP256 } from 'ox'
|
|
2
2
|
import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo'
|
|
3
|
+
import { BaseError } from 'viem'
|
|
3
4
|
import { prepareTransactionRequest } from 'viem/actions'
|
|
4
|
-
import { Account as TempoAccount } from 'viem/tempo'
|
|
5
|
+
import { Account as TempoAccount, Actions } from 'viem/tempo'
|
|
5
6
|
|
|
6
7
|
import * as AccessKey from '../AccessKey.js'
|
|
7
8
|
import * as Account from '../Account.js'
|
|
@@ -184,10 +185,26 @@ export function local(options: local.Options): Adapter.Adapter {
|
|
|
184
185
|
return { accounts, email, keyAuthorization, signature: signature_, username }
|
|
185
186
|
},
|
|
186
187
|
async revokeAccessKey(parameters) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
const account = getAccount({ accessKey: false, signable: true })
|
|
189
|
+
const client = getClient()
|
|
190
|
+
try {
|
|
191
|
+
await Actions.accessKey.revoke(client, {
|
|
192
|
+
account,
|
|
193
|
+
accessKey: parameters.accessKeyAddress,
|
|
194
|
+
} as never)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const isKeyNotFound =
|
|
197
|
+
error instanceof BaseError &&
|
|
198
|
+
!!error.walk(
|
|
199
|
+
(e) => (e as { data?: { errorName?: string } }).data?.errorName === 'KeyNotFound',
|
|
200
|
+
)
|
|
201
|
+
if (!isKeyNotFound) throw error
|
|
202
|
+
}
|
|
203
|
+
store.setState((state) => ({
|
|
204
|
+
accessKeys: state.accessKeys.filter(
|
|
205
|
+
(a) => a.address?.toLowerCase() !== parameters.accessKeyAddress.toLowerCase(),
|
|
206
|
+
),
|
|
207
|
+
}))
|
|
191
208
|
},
|
|
192
209
|
async signPersonalMessage({ data, address }) {
|
|
193
210
|
const account = getAccount({ address, signable: true })
|
package/src/core/zod/rpc.ts
CHANGED
|
@@ -531,6 +531,34 @@ export namespace wallet_getCallsStatus {
|
|
|
531
531
|
export type Decoded = Schema.Decoded<typeof schema>
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
export namespace wallet_send {
|
|
535
|
+
/** Parameters object for `wallet_send`. */
|
|
536
|
+
export const parameters = z.object({
|
|
537
|
+
/**
|
|
538
|
+
* Fee payer override. `false` to disable the wallet's default fee
|
|
539
|
+
* payer, a URL string to use a custom fee payer service.
|
|
540
|
+
*/
|
|
541
|
+
feePayer: z.optional(z.union([z.boolean(), z.string()])),
|
|
542
|
+
/** Recipient address to pre-fill. */
|
|
543
|
+
to: z.optional(u.address()),
|
|
544
|
+
/** Token contract address to pre-fill. Omit to let the user choose. */
|
|
545
|
+
token: z.optional(u.address()),
|
|
546
|
+
/** Human-readable amount to pre-fill (e.g. "1.5"). */
|
|
547
|
+
value: z.optional(z.string()),
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
export const schema = Schema.defineItem({
|
|
551
|
+
method: z.literal('wallet_send'),
|
|
552
|
+
params: z.optional(z.readonly(z.tuple([parameters]))),
|
|
553
|
+
returns: z.object({
|
|
554
|
+
/** Receipt of the submitted send. */
|
|
555
|
+
receipt,
|
|
556
|
+
}),
|
|
557
|
+
})
|
|
558
|
+
export type Encoded = Schema.Encoded<typeof schema>
|
|
559
|
+
export type Decoded = Schema.Decoded<typeof schema>
|
|
560
|
+
}
|
|
561
|
+
|
|
534
562
|
export namespace wallet_switchEthereumChain {
|
|
535
563
|
export const schema = Schema.defineItem({
|
|
536
564
|
method: z.literal('wallet_switchEthereumChain'),
|
|
@@ -281,6 +281,127 @@ describe('behavior: with app-provided feePayer URL', () => {
|
|
|
281
281
|
})
|
|
282
282
|
})
|
|
283
283
|
|
|
284
|
+
describe('behavior: with app-provided feePayer URL + autoSwap', () => {
|
|
285
|
+
let appServer: Server
|
|
286
|
+
let walletServer: Server
|
|
287
|
+
let client: ReturnType<typeof getClient<typeof chain>>
|
|
288
|
+
|
|
289
|
+
beforeAll(async () => {
|
|
290
|
+
// App relay sponsors fees AND has `features: 'all'` so it can recover
|
|
291
|
+
// from InsufficientBalance via autoSwap.
|
|
292
|
+
appServer = await createServer(
|
|
293
|
+
relay({
|
|
294
|
+
chains: [chain],
|
|
295
|
+
features: 'all',
|
|
296
|
+
transports: { [chain.id]: http() },
|
|
297
|
+
feePayer: {
|
|
298
|
+
account: feePayerAccount,
|
|
299
|
+
name: 'App Sponsor',
|
|
300
|
+
url: 'https://app.example.com',
|
|
301
|
+
},
|
|
302
|
+
}).listener,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
// Wallet relay forwards to the app relay; also has features:'all' so its
|
|
306
|
+
// own fill() can detect upstream `capabilities.error` as InsufficientBalance.
|
|
307
|
+
walletServer = await createServer(
|
|
308
|
+
relay({
|
|
309
|
+
chains: [chain],
|
|
310
|
+
features: 'all',
|
|
311
|
+
transports: { [chain.id]: http() },
|
|
312
|
+
}).listener,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
client = getClient({ transport: http(walletServer.url) })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
afterAll(() => {
|
|
319
|
+
appServer.close()
|
|
320
|
+
walletServer.close()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('behavior: autoSwap recovers when external feePayer surfaces InsufficientBalance', async () => {
|
|
324
|
+
const sender = accounts[6]!
|
|
325
|
+
|
|
326
|
+
// Token pair + DEX liquidity. Use alphaUsd as the quote token so the
|
|
327
|
+
// relay can swap alphaUsd → base to cover the deficit.
|
|
328
|
+
const rpc = getClient({ account: accounts[0]! })
|
|
329
|
+
const { token: base } = await Actions.token.createSync(rpc, {
|
|
330
|
+
name: 'External Swap Base',
|
|
331
|
+
symbol: 'EXTBASE',
|
|
332
|
+
currency: 'USD',
|
|
333
|
+
quoteToken: addresses.alphaUsd,
|
|
334
|
+
})
|
|
335
|
+
await sendTransactionSync(rpc, {
|
|
336
|
+
calls: [
|
|
337
|
+
Actions.token.grantRoles.call({ token: base, role: 'issuer', to: rpc.account!.address }),
|
|
338
|
+
Actions.token.mint.call({
|
|
339
|
+
token: base,
|
|
340
|
+
to: rpc.account!.address,
|
|
341
|
+
amount: parseUnits('10000', 6),
|
|
342
|
+
}),
|
|
343
|
+
Actions.token.mint.call({
|
|
344
|
+
token: addresses.alphaUsd,
|
|
345
|
+
to: rpc.account!.address,
|
|
346
|
+
amount: parseUnits('10000', 6),
|
|
347
|
+
}),
|
|
348
|
+
Actions.token.approve.call({
|
|
349
|
+
token: base,
|
|
350
|
+
spender: Addresses.stablecoinDex,
|
|
351
|
+
amount: parseUnits('10000', 6),
|
|
352
|
+
}),
|
|
353
|
+
Actions.token.approve.call({
|
|
354
|
+
token: addresses.alphaUsd,
|
|
355
|
+
spender: Addresses.stablecoinDex,
|
|
356
|
+
amount: parseUnits('10000', 6),
|
|
357
|
+
}),
|
|
358
|
+
],
|
|
359
|
+
})
|
|
360
|
+
await Actions.dex.createPairSync(rpc, { base })
|
|
361
|
+
await Actions.dex.placeSync(rpc, {
|
|
362
|
+
token: base,
|
|
363
|
+
amount: parseUnits('500', 6),
|
|
364
|
+
type: 'sell',
|
|
365
|
+
tick: Tick.fromPrice('1.001'),
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
// Give sender alphaUsd (fee + swap source) but NO base tokens.
|
|
369
|
+
await Actions.token.mintSync(rpc, {
|
|
370
|
+
token: addresses.alphaUsd,
|
|
371
|
+
amount: parseUnits('1000', 6),
|
|
372
|
+
to: sender.address,
|
|
373
|
+
})
|
|
374
|
+
await Actions.fee.setUserToken(getClient({ account: sender }), { token: addresses.alphaUsd })
|
|
375
|
+
|
|
376
|
+
// Sender attempts to transfer base via the wallet relay, which forwards
|
|
377
|
+
// to the app relay. The app relay returns 200 with capabilities.error =
|
|
378
|
+
// InsufficientBalance and a stub tx; the wallet relay must convert that
|
|
379
|
+
// into a synthetic throw so its own fill() autoSwap branch can recover.
|
|
380
|
+
const transferAmount = parseUnits('5', 6)
|
|
381
|
+
const result = await fillTransaction(client, {
|
|
382
|
+
account: sender.address,
|
|
383
|
+
...Actions.token.transfer.call({
|
|
384
|
+
token: base,
|
|
385
|
+
to: accounts[7]!.address,
|
|
386
|
+
amount: transferAmount,
|
|
387
|
+
}),
|
|
388
|
+
feePayer: appServer.url as never,
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
const { transaction, capabilities } = result
|
|
392
|
+
|
|
393
|
+
// Tx is filled with the swap calls prepended (approve + buy + transfer).
|
|
394
|
+
expect(transaction.calls).toHaveLength(3)
|
|
395
|
+
expect(transaction.feePayerSignature).toBeDefined()
|
|
396
|
+
|
|
397
|
+
// autoSwap metadata is surfaced.
|
|
398
|
+
expect(capabilities?.autoSwap?.slippage).toBe(0.05)
|
|
399
|
+
expect(capabilities?.autoSwap?.maxIn.symbol).toBe('AlphaUSD')
|
|
400
|
+
expect(capabilities?.autoSwap?.minOut.symbol).toBe('EXTBASE')
|
|
401
|
+
expect(capabilities?.autoSwap?.minOut.formatted).toBe('5')
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
284
405
|
describe('behavior: chainId path parameter', () => {
|
|
285
406
|
let server: Server
|
|
286
407
|
let client: ReturnType<typeof getClient<typeof chain>>
|
|
@@ -573,10 +573,36 @@ async function fill(
|
|
|
573
573
|
// @ts-expect-error
|
|
574
574
|
if (result.tx.gas && request.feePayer && !result.tx.feePayerSignature)
|
|
575
575
|
result.tx.gas = Hex.fromNumber(BigInt(result.tx.gas) + 20_000n)
|
|
576
|
-
const
|
|
576
|
+
const upstreamCapabilities = (result as { capabilities?: Record<string, unknown> }).capabilities
|
|
577
|
+
const sponsor = upstreamCapabilities?.sponsor as
|
|
577
578
|
| { address: Address; name?: string; url?: string }
|
|
578
579
|
| undefined
|
|
579
|
-
|
|
580
|
+
// External fee-payer relays surface chain reverts (e.g. InsufficientBalance)
|
|
581
|
+
// inside `capabilities.error` with a stub `tx` instead of throwing. Detect
|
|
582
|
+
// that here and re-throw so the autoSwap branch below can recover the same
|
|
583
|
+
// way it does for the direct-chain path.
|
|
584
|
+
const upstreamError = upstreamCapabilities?.error as
|
|
585
|
+
| { errorName?: string; message?: string; data?: `0x${string}` }
|
|
586
|
+
| undefined
|
|
587
|
+
if (upstreamError?.errorName === 'InsufficientBalance') {
|
|
588
|
+
const synthetic = new Error(upstreamError.message ?? 'InsufficientBalance')
|
|
589
|
+
synthetic.name = 'UpstreamRevertError'
|
|
590
|
+
;(synthetic as { data?: `0x${string}` | undefined }).data = upstreamError.data
|
|
591
|
+
throw synthetic
|
|
592
|
+
}
|
|
593
|
+
// Reconstruct a `swap` shape from upstream's `capabilities.autoSwap` so the
|
|
594
|
+
// wallet relay's outer code can re-resolve autoSwap metadata locally —
|
|
595
|
+
// otherwise upstream-driven swaps are silently dropped from the response.
|
|
596
|
+
const swap = extractSwapFromCapabilities(upstreamCapabilities?.autoSwap)
|
|
597
|
+
// The chain's `eth_fillTransaction` doesn't echo back `calls`, so merge
|
|
598
|
+
// them in from the original request before normalizing — otherwise the
|
|
599
|
+
// typed envelope built for sponsorship signing throws CallsEmptyError.
|
|
600
|
+
const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, request)
|
|
601
|
+
return {
|
|
602
|
+
transaction: Utils.normalizeTempoTransaction(mergedTx),
|
|
603
|
+
sponsor,
|
|
604
|
+
...(swap ? { swap } : {}),
|
|
605
|
+
}
|
|
580
606
|
} catch (error) {
|
|
581
607
|
if (!(error instanceof Error)) throw error
|
|
582
608
|
if (!autoSwap) throw error
|
|
@@ -613,8 +639,12 @@ async function fill(
|
|
|
613
639
|
const sponsor = (result as Record<string, any>).capabilities?.sponsor as
|
|
614
640
|
| { address: Address; name?: string; url?: string }
|
|
615
641
|
| undefined
|
|
642
|
+
const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, {
|
|
643
|
+
...request,
|
|
644
|
+
calls: [...swapCalls, ...originalCalls],
|
|
645
|
+
})
|
|
616
646
|
return {
|
|
617
|
-
transaction: Utils.normalizeTempoTransaction(
|
|
647
|
+
transaction: Utils.normalizeTempoTransaction(mergedTx),
|
|
618
648
|
sponsor,
|
|
619
649
|
swap: {
|
|
620
650
|
calls: swapCalls,
|
|
@@ -1015,3 +1045,75 @@ function buildSwapCalls(
|
|
|
1015
1045
|
{ to: buy.to, data: buy.data, value: 0n },
|
|
1016
1046
|
] as const
|
|
1017
1047
|
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Merges the original fill request into the result tx. The chain's
|
|
1051
|
+
* `eth_fillTransaction` returns only the "filled" gas/nonce/fee fields and
|
|
1052
|
+
* omits envelope inputs like `calls`, `chainId`, `validBefore`, `nonceKey`,
|
|
1053
|
+
* `keyData`, `keyType`, `feePayer`. Without these the typed Tempo envelope
|
|
1054
|
+
* built for sponsorship signing throws `CallsEmptyError` or
|
|
1055
|
+
* `Cannot convert undefined to a BigInt` when serializing.
|
|
1056
|
+
*
|
|
1057
|
+
* Result fields take precedence (they are the chain's authoritative filled
|
|
1058
|
+
* values); request fields fill in everything else. Calls are normalized
|
|
1059
|
+
* separately so legacy `to`/`data`/`value` requests are also supported.
|
|
1060
|
+
*/
|
|
1061
|
+
function mergeCallsFromRequest(
|
|
1062
|
+
resultTx: Record<string, unknown>,
|
|
1063
|
+
request: Record<string, unknown>,
|
|
1064
|
+
): Record<string, unknown> {
|
|
1065
|
+
const merged: Record<string, unknown> = { ...request, ...resultTx }
|
|
1066
|
+
const resultCalls = resultTx.calls
|
|
1067
|
+
if (Array.isArray(resultCalls) && resultCalls.length > 0) return merged
|
|
1068
|
+
|
|
1069
|
+
const reqCalls = request.calls
|
|
1070
|
+
if (Array.isArray(reqCalls) && reqCalls.length > 0) {
|
|
1071
|
+
merged.calls = reqCalls
|
|
1072
|
+
return merged
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const { to, data, value } = request
|
|
1076
|
+
if (typeof to === 'undefined' && typeof data === 'undefined' && typeof value === 'undefined')
|
|
1077
|
+
return merged
|
|
1078
|
+
|
|
1079
|
+
merged.calls = [
|
|
1080
|
+
{
|
|
1081
|
+
...(typeof to !== 'undefined' ? { to } : {}),
|
|
1082
|
+
...(typeof data !== 'undefined' ? { data } : {}),
|
|
1083
|
+
...(typeof value !== 'undefined' ? { value } : {}),
|
|
1084
|
+
},
|
|
1085
|
+
]
|
|
1086
|
+
return merged
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Reconstructs a `swap` shape (matching the inner autoSwap branch's return
|
|
1091
|
+
* value) from an upstream relay's `capabilities.autoSwap`. Used so a wallet
|
|
1092
|
+
* relay forwarding to an external feePayer URL can re-emit autoSwap metadata
|
|
1093
|
+
* locally without losing track of the upstream's swap.
|
|
1094
|
+
*/
|
|
1095
|
+
function extractSwapFromCapabilities(autoSwap: unknown):
|
|
1096
|
+
| {
|
|
1097
|
+
calls: readonly { to: Address; data: `0x${string}` }[]
|
|
1098
|
+
tokenIn: Address
|
|
1099
|
+
tokenOut: Address
|
|
1100
|
+
amountOut: bigint
|
|
1101
|
+
maxAmountIn: bigint
|
|
1102
|
+
}
|
|
1103
|
+
| undefined {
|
|
1104
|
+
if (!autoSwap || typeof autoSwap !== 'object') return undefined
|
|
1105
|
+
const a = autoSwap as {
|
|
1106
|
+
calls?: readonly { to: Address; data: `0x${string}` }[]
|
|
1107
|
+
maxIn?: { token?: Address; value?: `0x${string}` }
|
|
1108
|
+
minOut?: { token?: Address; value?: `0x${string}` }
|
|
1109
|
+
}
|
|
1110
|
+
if (!a.calls || !a.maxIn?.token || !a.maxIn.value || !a.minOut?.token || !a.minOut.value)
|
|
1111
|
+
return undefined
|
|
1112
|
+
return {
|
|
1113
|
+
calls: a.calls,
|
|
1114
|
+
tokenIn: a.maxIn.token,
|
|
1115
|
+
tokenOut: a.minOut.token,
|
|
1116
|
+
amountOut: BigInt(a.minOut.value),
|
|
1117
|
+
maxAmountIn: BigInt(a.maxIn.value),
|
|
1118
|
+
}
|
|
1119
|
+
}
|