ox 0.14.11 → 0.14.13

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/_cjs/erc8021/Attribution.js +7 -1
  3. package/_cjs/erc8021/Attribution.js.map +1 -1
  4. package/_cjs/tempo/KeyAuthorization.js +150 -21
  5. package/_cjs/tempo/KeyAuthorization.js.map +1 -1
  6. package/_cjs/tempo/Period.js +31 -0
  7. package/_cjs/tempo/Period.js.map +1 -0
  8. package/_cjs/tempo/index.js +2 -1
  9. package/_cjs/tempo/index.js.map +1 -1
  10. package/_cjs/version.js +1 -1
  11. package/_esm/erc8021/Attribution.js +7 -1
  12. package/_esm/erc8021/Attribution.js.map +1 -1
  13. package/_esm/tempo/KeyAuthorization.js +159 -22
  14. package/_esm/tempo/KeyAuthorization.js.map +1 -1
  15. package/_esm/tempo/Period.js +92 -0
  16. package/_esm/tempo/Period.js.map +1 -0
  17. package/_esm/tempo/index.js +29 -0
  18. package/_esm/tempo/index.js.map +1 -1
  19. package/_esm/version.js +1 -1
  20. package/_types/erc8021/Attribution.d.ts +2 -0
  21. package/_types/erc8021/Attribution.d.ts.map +1 -1
  22. package/_types/tempo/KeyAuthorization.d.ts +95 -19
  23. package/_types/tempo/KeyAuthorization.d.ts.map +1 -1
  24. package/_types/tempo/Period.d.ts +78 -0
  25. package/_types/tempo/Period.d.ts.map +1 -0
  26. package/_types/tempo/index.d.ts +29 -0
  27. package/_types/tempo/index.d.ts.map +1 -1
  28. package/_types/version.d.ts +1 -1
  29. package/erc8021/Attribution.ts +12 -1
  30. package/package.json +6 -1
  31. package/tempo/KeyAuthorization.test.ts +407 -3
  32. package/tempo/KeyAuthorization.ts +291 -51
  33. package/tempo/Period/package.json +6 -0
  34. package/tempo/Period.test.ts +44 -0
  35. package/tempo/Period.ts +97 -0
  36. package/tempo/e2e.test.ts +969 -1
  37. package/tempo/index.ts +30 -0
  38. package/version.ts +1 -1
package/tempo/e2e.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ AbiFunction,
2
3
  Address,
3
4
  Hex,
4
5
  P256,
@@ -9,10 +10,11 @@ import {
9
10
  } from 'ox'
10
11
  import { getTransactionCount } from 'viem/actions'
11
12
  import { beforeEach, describe, expect, test } from 'vitest'
12
- import { chain, client, fundAddress } from '../../test/tempo/config.js'
13
+ import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js'
13
14
  import {
14
15
  AuthorizationTempo,
15
16
  KeyAuthorization,
17
+ Period,
16
18
  SignatureEnvelope,
17
19
  } from './index.js'
18
20
  import * as Transaction from './Transaction.js'
@@ -779,6 +781,87 @@ test('behavior: feePayerSignature (user → feePayer)', async () => {
779
781
  expect(from).toBe(senderAddress)
780
782
  })
781
783
 
784
+ test('behavior: feePayerSignature (feePayer → user)', async () => {
785
+ const userPrivateKey = Secp256k1.randomPrivateKey()
786
+ const userAddress = Address.fromPublicKey(
787
+ Secp256k1.getPublicKey({ privateKey: userPrivateKey }),
788
+ )
789
+
790
+ const feePayerPrivateKey = Secp256k1.randomPrivateKey()
791
+ const feePayerAddress = Address.fromPublicKey(
792
+ Secp256k1.getPublicKey({ privateKey: feePayerPrivateKey }),
793
+ )
794
+
795
+ await Promise.all([
796
+ fundAddress(client, { address: userAddress }),
797
+ fundAddress(client, { address: feePayerAddress }),
798
+ ])
799
+
800
+ const nonce = await getTransactionCount(client, {
801
+ address: userAddress,
802
+ blockTag: 'pending',
803
+ })
804
+
805
+ //////////////////////////////////////////////////////////////////
806
+ // Fee payer flow
807
+
808
+ // 1. Build the transaction with `feePayerSignature: null` to indicate
809
+ // fee sponsorship intent. The user does NOT commit to `feeToken`.
810
+ const transaction = TxEnvelopeTempo.from({
811
+ calls: [
812
+ {
813
+ to: '0x0000000000000000000000000000000000000000',
814
+ },
815
+ ],
816
+ chainId,
817
+ feePayerSignature: null,
818
+ feeToken: '0x20c0000000000000000000000000000000000001',
819
+ nonce: BigInt(nonce),
820
+ gas: 500_000n,
821
+ maxFeePerGas: Value.fromGwei('20'),
822
+ maxPriorityFeePerGas: Value.fromGwei('10'),
823
+ })
824
+
825
+ // 2. Fee payer signs first — commits to the sender address and fee token.
826
+ const feePayerSignature = Secp256k1.sign({
827
+ payload: TxEnvelopeTempo.getFeePayerSignPayload(transaction, {
828
+ sender: userAddress,
829
+ }),
830
+ privateKey: feePayerPrivateKey,
831
+ })
832
+
833
+ // 3. Attach fee payer signature to the transaction.
834
+ const transaction_feePayer = TxEnvelopeTempo.from(transaction, {
835
+ feePayerSignature,
836
+ })
837
+
838
+ //////////////////////////////////////////////////////////////////
839
+ // User flow
840
+
841
+ // 4. User signs second — `feePayerSignature` presence causes `feeToken`
842
+ // to be skipped from the user's signing payload.
843
+ const userSignature = Secp256k1.sign({
844
+ payload: TxEnvelopeTempo.getSignPayload(transaction_feePayer),
845
+ privateKey: userPrivateKey,
846
+ })
847
+
848
+ // 5. Serialize with both signatures.
849
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction_feePayer, {
850
+ signature: SignatureEnvelope.from(userSignature),
851
+ })
852
+
853
+ const receipt = (await client
854
+ .request({
855
+ method: 'eth_sendRawTransactionSync',
856
+ params: [serialized_signed],
857
+ })
858
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
859
+ expect(receipt).toBeDefined()
860
+ expect(receipt.status).toBe('success')
861
+ expect(receipt.from).toBe(userAddress)
862
+ expect(receipt.feePayer).toBe(feePayerAddress)
863
+ })
864
+
782
865
  describe('behavior: keyAuthorization', () => {
783
866
  const privateKey = Secp256k1.randomPrivateKey()
784
867
  const address = Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey }))
@@ -1680,4 +1763,889 @@ describe('behavior: keyAuthorization', () => {
1680
1763
  expect(response.keyAuthorization?.limits).toBeUndefined()
1681
1764
  }
1682
1765
  })
1766
+
1767
+ // TODO: remove skipIf when testnet has T3
1768
+ test.skipIf(nodeEnv === 'testnet')(
1769
+ 'behavior: access key with periodic spending limit',
1770
+ async () => {
1771
+ const privateKey = Secp256k1.randomPrivateKey()
1772
+ const publicKey = Secp256k1.getPublicKey({ privateKey })
1773
+ const address = Address.fromPublicKey(publicKey)
1774
+ const access = {
1775
+ address,
1776
+ publicKey,
1777
+ privateKey,
1778
+ } as const
1779
+
1780
+ const keyAuth = KeyAuthorization.from({
1781
+ address: access.address,
1782
+ chainId: BigInt(chainId),
1783
+ type: 'secp256k1',
1784
+ limits: [
1785
+ {
1786
+ token: '0x20c0000000000000000000000000000000000001',
1787
+ limit: Value.from('1000', 6),
1788
+ period: Period.months(1),
1789
+ },
1790
+ ],
1791
+ })
1792
+
1793
+ const keyAuth_signature = Secp256k1.sign({
1794
+ payload: KeyAuthorization.getSignPayload(keyAuth),
1795
+ privateKey: root.privateKey,
1796
+ })
1797
+
1798
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
1799
+ signature: SignatureEnvelope.from(keyAuth_signature),
1800
+ })
1801
+
1802
+ const nonce = await getTransactionCount(client, {
1803
+ address: root.address,
1804
+ blockTag: 'pending',
1805
+ })
1806
+
1807
+ const transaction = TxEnvelopeTempo.from({
1808
+ calls: [
1809
+ {
1810
+ to: '0x0000000000000000000000000000000000000000',
1811
+ },
1812
+ ],
1813
+ chainId,
1814
+ feeToken: '0x20c0000000000000000000000000000000000001',
1815
+ keyAuthorization: keyAuth_signed,
1816
+ nonce: BigInt(nonce),
1817
+ gas: 1_000_000n,
1818
+ maxFeePerGas: Value.fromGwei('20'),
1819
+ maxPriorityFeePerGas: Value.fromGwei('10'),
1820
+ })
1821
+
1822
+ const signature = Secp256k1.sign({
1823
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
1824
+ from: root.address,
1825
+ }),
1826
+ privateKey: access.privateKey,
1827
+ })
1828
+
1829
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
1830
+ signature: SignatureEnvelope.from({
1831
+ userAddress: root.address,
1832
+ inner: SignatureEnvelope.from(signature),
1833
+ type: 'keychain',
1834
+ }),
1835
+ })
1836
+
1837
+ const receipt = (await client
1838
+ .request({
1839
+ method: 'eth_sendRawTransactionSync',
1840
+ params: [serialized_signed],
1841
+ })
1842
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
1843
+ expect(receipt).toBeDefined()
1844
+ expect(receipt.status).toBe('success')
1845
+
1846
+ {
1847
+ const response = await client
1848
+ .request({
1849
+ method: 'eth_getTransactionByHash',
1850
+ params: [receipt.transactionHash],
1851
+ })
1852
+ .then((tx) => Transaction.fromRpc(tx as any))
1853
+ if (!response) throw new Error()
1854
+
1855
+ expect(response.from).toBe(root.address)
1856
+ expect(response.keyAuthorization).toBeDefined()
1857
+ expect(response.keyAuthorization?.limits?.[0]?.limit).toBe(
1858
+ Value.from('1000', 6),
1859
+ )
1860
+ expect(response.keyAuthorization?.limits?.[0]?.period).toBe(2592000)
1861
+ }
1862
+ },
1863
+ )
1864
+
1865
+ // TODO: remove skipIf when testnet has T3
1866
+ test.skipIf(nodeEnv === 'testnet')(
1867
+ 'behavior: rejects transfer exceeding periodic spending limit',
1868
+ async () => {
1869
+ const privateKey = Secp256k1.randomPrivateKey()
1870
+ const publicKey = Secp256k1.getPublicKey({ privateKey })
1871
+ const address = Address.fromPublicKey(publicKey)
1872
+ const token = '0x20c0000000000000000000000000000000000001'
1873
+ const transfer = AbiFunction.from(
1874
+ 'function transfer(address to, uint256 amount)',
1875
+ )
1876
+
1877
+ // Key with a 5 USDC periodic limit
1878
+ const keyAuth = KeyAuthorization.from({
1879
+ address,
1880
+ chainId: BigInt(chainId),
1881
+ type: 'secp256k1',
1882
+ limits: [
1883
+ {
1884
+ token,
1885
+ limit: Value.from('5', 6),
1886
+ period: Period.months(1),
1887
+ },
1888
+ ],
1889
+ })
1890
+
1891
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
1892
+ signature: SignatureEnvelope.from(
1893
+ Secp256k1.sign({
1894
+ payload: KeyAuthorization.getSignPayload(keyAuth),
1895
+ privateKey: root.privateKey,
1896
+ }),
1897
+ ),
1898
+ })
1899
+
1900
+ const nonce = await getTransactionCount(client, {
1901
+ address: root.address,
1902
+ blockTag: 'pending',
1903
+ })
1904
+
1905
+ // Try to transfer 10 USDC (exceeds 5 USDC limit)
1906
+ const transferData = AbiFunction.encodeData(transfer, [
1907
+ '0x0000000000000000000000000000000000000001',
1908
+ Value.from('10', 6),
1909
+ ])
1910
+
1911
+ const transaction = TxEnvelopeTempo.from({
1912
+ calls: [{ to: token, data: transferData }],
1913
+ chainId,
1914
+ feeToken: token,
1915
+ keyAuthorization: keyAuth_signed,
1916
+ nonce: BigInt(nonce),
1917
+ gas: 5_000_000n,
1918
+ maxFeePerGas: Value.fromGwei('20'),
1919
+ maxPriorityFeePerGas: Value.fromGwei('10'),
1920
+ })
1921
+
1922
+ const signature = Secp256k1.sign({
1923
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
1924
+ from: root.address,
1925
+ }),
1926
+ privateKey,
1927
+ })
1928
+
1929
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
1930
+ signature: SignatureEnvelope.from({
1931
+ userAddress: root.address,
1932
+ inner: SignatureEnvelope.from(signature),
1933
+ type: 'keychain',
1934
+ }),
1935
+ })
1936
+
1937
+ const receipt = (await client
1938
+ .request({
1939
+ method: 'eth_sendRawTransactionSync',
1940
+ params: [serialized_signed],
1941
+ })
1942
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
1943
+ expect(receipt).toBeDefined()
1944
+ expect(receipt.status).toBe('reverted')
1945
+ },
1946
+ )
1947
+
1948
+ test.runIf(nodeEnv === 'localnet')(
1949
+ 'behavior: periodic spending limit resets after period',
1950
+ async () => {
1951
+ const accessPrivateKey = Secp256k1.randomPrivateKey()
1952
+ const accessAddress = Address.fromPublicKey(
1953
+ Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
1954
+ )
1955
+ const token = '0x20c0000000000000000000000000000000000001'
1956
+ const transfer = AbiFunction.from(
1957
+ 'function transfer(address to, uint256 amount)',
1958
+ )
1959
+ const recipient = '0x0000000000000000000000000000000000000001'
1960
+
1961
+ // Key with a 5 USDC limit that resets every 5 seconds
1962
+ const keyAuth = KeyAuthorization.from({
1963
+ address: accessAddress,
1964
+ chainId: BigInt(chainId),
1965
+ type: 'secp256k1',
1966
+ limits: [
1967
+ {
1968
+ token,
1969
+ limit: Value.from('5', 6),
1970
+ period: Period.seconds(5),
1971
+ },
1972
+ ],
1973
+ })
1974
+
1975
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
1976
+ signature: SignatureEnvelope.from(
1977
+ Secp256k1.sign({
1978
+ payload: KeyAuthorization.getSignPayload(keyAuth),
1979
+ privateKey: root.privateKey,
1980
+ }),
1981
+ ),
1982
+ })
1983
+
1984
+ // 1. Provision key + transfer 4 USDC
1985
+ {
1986
+ const nonce = await getTransactionCount(client, {
1987
+ address: root.address,
1988
+ blockTag: 'pending',
1989
+ })
1990
+
1991
+ const transferData = AbiFunction.encodeData(transfer, [
1992
+ recipient,
1993
+ Value.from('4', 6),
1994
+ ])
1995
+
1996
+ const transaction = TxEnvelopeTempo.from({
1997
+ calls: [{ to: token, data: transferData }],
1998
+ chainId,
1999
+ feeToken: token,
2000
+ keyAuthorization: keyAuth_signed,
2001
+ nonce: BigInt(nonce),
2002
+ gas: 5_000_000n,
2003
+ maxFeePerGas: Value.fromGwei('20'),
2004
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2005
+ })
2006
+
2007
+ const signature = Secp256k1.sign({
2008
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2009
+ from: root.address,
2010
+ }),
2011
+ privateKey: accessPrivateKey,
2012
+ })
2013
+
2014
+ const serialized = TxEnvelopeTempo.serialize(transaction, {
2015
+ signature: SignatureEnvelope.from({
2016
+ userAddress: root.address,
2017
+ inner: SignatureEnvelope.from(signature),
2018
+ type: 'keychain',
2019
+ }),
2020
+ })
2021
+
2022
+ const receipt = (await client
2023
+ .request({
2024
+ method: 'eth_sendRawTransactionSync',
2025
+ params: [serialized],
2026
+ })
2027
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
2028
+ expect(receipt.status).toBe('success')
2029
+ }
2030
+
2031
+ // 2. Immediately try another 4 USDC transfer (should revert — limit exhausted)
2032
+ {
2033
+ const nonce = await getTransactionCount(client, {
2034
+ address: root.address,
2035
+ blockTag: 'pending',
2036
+ })
2037
+
2038
+ const transferData = AbiFunction.encodeData(transfer, [
2039
+ recipient,
2040
+ Value.from('4', 6),
2041
+ ])
2042
+
2043
+ const transaction = TxEnvelopeTempo.from({
2044
+ calls: [{ to: token, data: transferData }],
2045
+ chainId,
2046
+ feeToken: token,
2047
+ nonce: BigInt(nonce),
2048
+ gas: 5_000_000n,
2049
+ maxFeePerGas: Value.fromGwei('20'),
2050
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2051
+ })
2052
+
2053
+ const signature = Secp256k1.sign({
2054
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2055
+ from: root.address,
2056
+ }),
2057
+ privateKey: accessPrivateKey,
2058
+ })
2059
+
2060
+ const serialized = TxEnvelopeTempo.serialize(transaction, {
2061
+ signature: SignatureEnvelope.from({
2062
+ userAddress: root.address,
2063
+ inner: SignatureEnvelope.from(signature),
2064
+ type: 'keychain',
2065
+ }),
2066
+ })
2067
+
2068
+ const receipt = (await client
2069
+ .request({
2070
+ method: 'eth_sendRawTransactionSync',
2071
+ params: [serialized],
2072
+ })
2073
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
2074
+ expect(receipt.status).toBe('reverted')
2075
+ }
2076
+
2077
+ // 3. Wait for period to reset
2078
+ await new Promise((resolve) => setTimeout(resolve, 6000))
2079
+
2080
+ // 4. Transfer 4 USDC again (should succeed — period reset)
2081
+ {
2082
+ const nonce = await getTransactionCount(client, {
2083
+ address: root.address,
2084
+ blockTag: 'pending',
2085
+ })
2086
+
2087
+ const transferData = AbiFunction.encodeData(transfer, [
2088
+ recipient,
2089
+ Value.from('4', 6),
2090
+ ])
2091
+
2092
+ const transaction = TxEnvelopeTempo.from({
2093
+ calls: [{ to: token, data: transferData }],
2094
+ chainId,
2095
+ feeToken: token,
2096
+ nonce: BigInt(nonce),
2097
+ gas: 5_000_000n,
2098
+ maxFeePerGas: Value.fromGwei('20'),
2099
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2100
+ })
2101
+
2102
+ const signature = Secp256k1.sign({
2103
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2104
+ from: root.address,
2105
+ }),
2106
+ privateKey: accessPrivateKey,
2107
+ })
2108
+
2109
+ const serialized = TxEnvelopeTempo.serialize(transaction, {
2110
+ signature: SignatureEnvelope.from({
2111
+ userAddress: root.address,
2112
+ inner: SignatureEnvelope.from(signature),
2113
+ type: 'keychain',
2114
+ }),
2115
+ })
2116
+
2117
+ const receipt = (await client
2118
+ .request({
2119
+ method: 'eth_sendRawTransactionSync',
2120
+ params: [serialized],
2121
+ })
2122
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
2123
+ expect(receipt.status).toBe('success')
2124
+ }
2125
+ },
2126
+ )
2127
+
2128
+ // TODO: remove skipIf when testnet has T3
2129
+ test.skipIf(nodeEnv === 'testnet')(
2130
+ 'behavior: access key with call scopes (transfer)',
2131
+ async () => {
2132
+ const accessPrivateKey = Secp256k1.randomPrivateKey()
2133
+ const accessAddress = Address.fromPublicKey(
2134
+ Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
2135
+ )
2136
+ const recipient = '0x0000000000000000000000000000000000000001'
2137
+ const token = '0x20c0000000000000000000000000000000000001'
2138
+ const transfer = AbiFunction.from(
2139
+ 'function transfer(address to, uint256 amount)',
2140
+ )
2141
+ const transferData = AbiFunction.encodeData(transfer, [
2142
+ recipient,
2143
+ Value.from('1', 6),
2144
+ ])
2145
+
2146
+ // Scope key: only transfer() on token contract, with sufficient spending limit
2147
+ const keyAuth = KeyAuthorization.from({
2148
+ address: accessAddress,
2149
+ chainId: BigInt(chainId),
2150
+ type: 'secp256k1',
2151
+ limits: [{ token, limit: Value.from('10000', 6) }],
2152
+ scopes: [
2153
+ {
2154
+ address: token,
2155
+ selector: AbiFunction.getSelector(transfer),
2156
+ },
2157
+ ],
2158
+ })
2159
+
2160
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
2161
+ signature: SignatureEnvelope.from(
2162
+ Secp256k1.sign({
2163
+ payload: KeyAuthorization.getSignPayload(keyAuth),
2164
+ privateKey: root.privateKey,
2165
+ }),
2166
+ ),
2167
+ })
2168
+
2169
+ const nonce = await getTransactionCount(client, {
2170
+ address: root.address,
2171
+ blockTag: 'pending',
2172
+ })
2173
+
2174
+ const transaction = TxEnvelopeTempo.from({
2175
+ calls: [{ to: token, data: transferData }],
2176
+ chainId,
2177
+ feeToken: token,
2178
+ keyAuthorization: keyAuth_signed,
2179
+ nonce: BigInt(nonce),
2180
+ gas: 5_000_000n,
2181
+ maxFeePerGas: Value.fromGwei('20'),
2182
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2183
+ })
2184
+
2185
+ const signature = Secp256k1.sign({
2186
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2187
+ from: root.address,
2188
+ }),
2189
+ privateKey: accessPrivateKey,
2190
+ })
2191
+
2192
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
2193
+ signature: SignatureEnvelope.from({
2194
+ userAddress: root.address,
2195
+ inner: SignatureEnvelope.from(signature),
2196
+ type: 'keychain',
2197
+ }),
2198
+ })
2199
+
2200
+ const receipt = (await client
2201
+ .request({
2202
+ method: 'eth_sendRawTransactionSync',
2203
+ params: [serialized_signed],
2204
+ })
2205
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
2206
+ expect(receipt).toBeDefined()
2207
+ expect(receipt.status).toBe('success')
2208
+
2209
+ {
2210
+ const response = await client
2211
+ .request({
2212
+ method: 'eth_getTransactionByHash',
2213
+ params: [receipt.transactionHash],
2214
+ })
2215
+ .then((tx) => Transaction.fromRpc(tx as any))
2216
+ if (!response) throw new Error()
2217
+
2218
+ expect(response.from).toBe(root.address)
2219
+ expect(response.keyAuthorization).toBeDefined()
2220
+ expect(response.keyAuthorization?.scopes?.[0]?.address).toBe(token)
2221
+ expect(response.keyAuthorization?.scopes?.[0]?.selector).toBe(
2222
+ '0xa9059cbb',
2223
+ )
2224
+ }
2225
+ },
2226
+ )
2227
+
2228
+ // TODO: remove skipIf when testnet has T3
2229
+ test.skipIf(nodeEnv === 'testnet')(
2230
+ 'behavior: access key with call scopes + recipient allowlist (transfer)',
2231
+ async () => {
2232
+ const accessPrivateKey = Secp256k1.randomPrivateKey()
2233
+ const accessAddress = Address.fromPublicKey(
2234
+ Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
2235
+ )
2236
+ const recipient = '0x0000000000000000000000000000000000000001'
2237
+ const token = '0x20c0000000000000000000000000000000000001'
2238
+ const transfer = AbiFunction.from(
2239
+ 'function transfer(address to, uint256 amount)',
2240
+ )
2241
+ const transferData = AbiFunction.encodeData(transfer, [
2242
+ recipient,
2243
+ Value.from('1', 6),
2244
+ ])
2245
+
2246
+ // Scope key: transfer() on token, only to recipient, with sufficient spending limit
2247
+ const keyAuth = KeyAuthorization.from({
2248
+ address: accessAddress,
2249
+ chainId: BigInt(chainId),
2250
+ type: 'secp256k1',
2251
+ limits: [{ token, limit: Value.from('10000', 6) }],
2252
+ scopes: [
2253
+ {
2254
+ address: token,
2255
+ selector: AbiFunction.getSelector(transfer),
2256
+ recipients: [recipient],
2257
+ },
2258
+ ],
2259
+ })
2260
+
2261
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
2262
+ signature: SignatureEnvelope.from(
2263
+ Secp256k1.sign({
2264
+ payload: KeyAuthorization.getSignPayload(keyAuth),
2265
+ privateKey: root.privateKey,
2266
+ }),
2267
+ ),
2268
+ })
2269
+
2270
+ const nonce = await getTransactionCount(client, {
2271
+ address: root.address,
2272
+ blockTag: 'pending',
2273
+ })
2274
+
2275
+ const transaction = TxEnvelopeTempo.from({
2276
+ calls: [{ to: token, data: transferData }],
2277
+ chainId,
2278
+ feeToken: token,
2279
+ keyAuthorization: keyAuth_signed,
2280
+ nonce: BigInt(nonce),
2281
+ gas: 5_000_000n,
2282
+ maxFeePerGas: Value.fromGwei('20'),
2283
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2284
+ })
2285
+
2286
+ const signature = Secp256k1.sign({
2287
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2288
+ from: root.address,
2289
+ }),
2290
+ privateKey: accessPrivateKey,
2291
+ })
2292
+
2293
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
2294
+ signature: SignatureEnvelope.from({
2295
+ userAddress: root.address,
2296
+ inner: SignatureEnvelope.from(signature),
2297
+ type: 'keychain',
2298
+ }),
2299
+ })
2300
+
2301
+ const receipt = (await client
2302
+ .request({
2303
+ method: 'eth_sendRawTransactionSync',
2304
+ params: [serialized_signed],
2305
+ })
2306
+ .then((tx) => TransactionReceipt.fromRpc(tx as any)))!
2307
+ expect(receipt).toBeDefined()
2308
+ expect(receipt.status).toBe('success')
2309
+
2310
+ {
2311
+ const response = await client
2312
+ .request({
2313
+ method: 'eth_getTransactionByHash',
2314
+ params: [receipt.transactionHash],
2315
+ })
2316
+ .then((tx) => Transaction.fromRpc(tx as any))
2317
+ if (!response) throw new Error()
2318
+
2319
+ expect(response.from).toBe(root.address)
2320
+ expect(response.keyAuthorization).toBeDefined()
2321
+ expect(response.keyAuthorization?.scopes?.[0]?.selector).toBe(
2322
+ '0xa9059cbb',
2323
+ )
2324
+ expect(response.keyAuthorization?.scopes?.[0]?.recipients).toEqual([
2325
+ recipient,
2326
+ ])
2327
+ }
2328
+ },
2329
+ )
2330
+
2331
+ // TODO: remove skipIf when testnet has T3
2332
+ test.skipIf(nodeEnv === 'testnet')(
2333
+ 'behavior: rejects transfer to wrong contract (outside scope)',
2334
+ async () => {
2335
+ const accessPrivateKey = Secp256k1.randomPrivateKey()
2336
+ const accessAddress = Address.fromPublicKey(
2337
+ Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
2338
+ )
2339
+ const token1 = '0x20c0000000000000000000000000000000000001'
2340
+ const token2 = '0x20c0000000000000000000000000000000000002'
2341
+ const transfer = AbiFunction.from(
2342
+ 'function transfer(address to, uint256 amount)',
2343
+ )
2344
+ const transferData = AbiFunction.encodeData(transfer, [
2345
+ '0x0000000000000000000000000000000000000001',
2346
+ Value.from('1', 6),
2347
+ ])
2348
+
2349
+ // Scope key to only token1
2350
+ const keyAuth = KeyAuthorization.from({
2351
+ address: accessAddress,
2352
+ chainId: BigInt(chainId),
2353
+ type: 'secp256k1',
2354
+ scopes: [
2355
+ {
2356
+ address: token1,
2357
+ selector: AbiFunction.getSelector(transfer),
2358
+ },
2359
+ ],
2360
+ })
2361
+
2362
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
2363
+ signature: SignatureEnvelope.from(
2364
+ Secp256k1.sign({
2365
+ payload: KeyAuthorization.getSignPayload(keyAuth),
2366
+ privateKey: root.privateKey,
2367
+ }),
2368
+ ),
2369
+ })
2370
+
2371
+ const nonce = await getTransactionCount(client, {
2372
+ address: root.address,
2373
+ blockTag: 'pending',
2374
+ })
2375
+
2376
+ // Call transfer on token2 (not scoped) — should be rejected
2377
+ const transaction = TxEnvelopeTempo.from({
2378
+ calls: [{ to: token2, data: transferData }],
2379
+ chainId,
2380
+ feeToken: token1,
2381
+ keyAuthorization: keyAuth_signed,
2382
+ nonce: BigInt(nonce),
2383
+ gas: 5_000_000n,
2384
+ maxFeePerGas: Value.fromGwei('20'),
2385
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2386
+ })
2387
+
2388
+ const signature = Secp256k1.sign({
2389
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2390
+ from: root.address,
2391
+ }),
2392
+ privateKey: accessPrivateKey,
2393
+ })
2394
+
2395
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
2396
+ signature: SignatureEnvelope.from({
2397
+ userAddress: root.address,
2398
+ inner: SignatureEnvelope.from(signature),
2399
+ type: 'keychain',
2400
+ }),
2401
+ })
2402
+
2403
+ await expect(
2404
+ client.request({
2405
+ method: 'eth_sendRawTransactionSync',
2406
+ params: [serialized_signed],
2407
+ }),
2408
+ ).rejects.toThrow()
2409
+ },
2410
+ )
2411
+
2412
+ // TODO: remove skipIf when testnet has T3
2413
+ test.skipIf(nodeEnv === 'testnet')(
2414
+ 'behavior: rejects approve when only transfer is scoped',
2415
+ async () => {
2416
+ const accessPrivateKey = Secp256k1.randomPrivateKey()
2417
+ const accessAddress = Address.fromPublicKey(
2418
+ Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
2419
+ )
2420
+ const token = '0x20c0000000000000000000000000000000000001'
2421
+ const transfer = AbiFunction.from(
2422
+ 'function transfer(address to, uint256 amount)',
2423
+ )
2424
+ const approve = AbiFunction.from(
2425
+ 'function approve(address spender, uint256 amount)',
2426
+ )
2427
+ const approveData = AbiFunction.encodeData(approve, [
2428
+ '0x0000000000000000000000000000000000000001',
2429
+ Value.from('1', 6),
2430
+ ])
2431
+
2432
+ // Scope key to only transfer()
2433
+ const keyAuth = KeyAuthorization.from({
2434
+ address: accessAddress,
2435
+ chainId: BigInt(chainId),
2436
+ type: 'secp256k1',
2437
+ scopes: [
2438
+ {
2439
+ address: token,
2440
+ selector: AbiFunction.getSelector(transfer),
2441
+ },
2442
+ ],
2443
+ })
2444
+
2445
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
2446
+ signature: SignatureEnvelope.from(
2447
+ Secp256k1.sign({
2448
+ payload: KeyAuthorization.getSignPayload(keyAuth),
2449
+ privateKey: root.privateKey,
2450
+ }),
2451
+ ),
2452
+ })
2453
+
2454
+ const nonce = await getTransactionCount(client, {
2455
+ address: root.address,
2456
+ blockTag: 'pending',
2457
+ })
2458
+
2459
+ // Call approve() instead — should be rejected
2460
+ const transaction = TxEnvelopeTempo.from({
2461
+ calls: [{ to: token, data: approveData }],
2462
+ chainId,
2463
+ feeToken: token,
2464
+ keyAuthorization: keyAuth_signed,
2465
+ nonce: BigInt(nonce),
2466
+ gas: 5_000_000n,
2467
+ maxFeePerGas: Value.fromGwei('20'),
2468
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2469
+ })
2470
+
2471
+ const signature = Secp256k1.sign({
2472
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2473
+ from: root.address,
2474
+ }),
2475
+ privateKey: accessPrivateKey,
2476
+ })
2477
+
2478
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
2479
+ signature: SignatureEnvelope.from({
2480
+ userAddress: root.address,
2481
+ inner: SignatureEnvelope.from(signature),
2482
+ type: 'keychain',
2483
+ }),
2484
+ })
2485
+
2486
+ await expect(
2487
+ client.request({
2488
+ method: 'eth_sendRawTransactionSync',
2489
+ params: [serialized_signed],
2490
+ }),
2491
+ ).rejects.toThrow()
2492
+ },
2493
+ )
2494
+
2495
+ // TODO: remove skipIf when testnet has T3
2496
+ test.skipIf(nodeEnv === 'testnet')(
2497
+ 'behavior: rejects transfer to wrong recipient',
2498
+ async () => {
2499
+ const accessPrivateKey = Secp256k1.randomPrivateKey()
2500
+ const accessAddress = Address.fromPublicKey(
2501
+ Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
2502
+ )
2503
+ const token = '0x20c0000000000000000000000000000000000001'
2504
+ const allowedRecipient = '0x0000000000000000000000000000000000000001'
2505
+ const wrongRecipient = '0x0000000000000000000000000000000000000002'
2506
+ const transfer = AbiFunction.from(
2507
+ 'function transfer(address to, uint256 amount)',
2508
+ )
2509
+ const transferData = AbiFunction.encodeData(transfer, [
2510
+ wrongRecipient,
2511
+ Value.from('1', 6),
2512
+ ])
2513
+
2514
+ // Scope key: transfer only to allowedRecipient
2515
+ const keyAuth = KeyAuthorization.from({
2516
+ address: accessAddress,
2517
+ chainId: BigInt(chainId),
2518
+ type: 'secp256k1',
2519
+ scopes: [
2520
+ {
2521
+ address: token,
2522
+ selector: AbiFunction.getSelector(transfer),
2523
+ recipients: [allowedRecipient],
2524
+ },
2525
+ ],
2526
+ })
2527
+
2528
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
2529
+ signature: SignatureEnvelope.from(
2530
+ Secp256k1.sign({
2531
+ payload: KeyAuthorization.getSignPayload(keyAuth),
2532
+ privateKey: root.privateKey,
2533
+ }),
2534
+ ),
2535
+ })
2536
+
2537
+ const nonce = await getTransactionCount(client, {
2538
+ address: root.address,
2539
+ blockTag: 'pending',
2540
+ })
2541
+
2542
+ // transfer to wrongRecipient — should be rejected
2543
+ const transaction = TxEnvelopeTempo.from({
2544
+ calls: [{ to: token, data: transferData }],
2545
+ chainId,
2546
+ feeToken: token,
2547
+ keyAuthorization: keyAuth_signed,
2548
+ nonce: BigInt(nonce),
2549
+ gas: 5_000_000n,
2550
+ maxFeePerGas: Value.fromGwei('20'),
2551
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2552
+ })
2553
+
2554
+ const signature = Secp256k1.sign({
2555
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2556
+ from: root.address,
2557
+ }),
2558
+ privateKey: accessPrivateKey,
2559
+ })
2560
+
2561
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
2562
+ signature: SignatureEnvelope.from({
2563
+ userAddress: root.address,
2564
+ inner: SignatureEnvelope.from(signature),
2565
+ type: 'keychain',
2566
+ }),
2567
+ })
2568
+
2569
+ await expect(
2570
+ client.request({
2571
+ method: 'eth_sendRawTransactionSync',
2572
+ params: [serialized_signed],
2573
+ }),
2574
+ ).rejects.toThrow()
2575
+ },
2576
+ )
2577
+
2578
+ // TODO: remove skipIf when testnet has T3
2579
+ test.skipIf(nodeEnv === 'testnet')(
2580
+ 'behavior: rejects any call when scopes = [] (empty)',
2581
+ async () => {
2582
+ const accessPrivateKey = Secp256k1.randomPrivateKey()
2583
+ const accessAddress = Address.fromPublicKey(
2584
+ Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
2585
+ )
2586
+ const token = '0x20c0000000000000000000000000000000000001'
2587
+ const transfer = AbiFunction.from(
2588
+ 'function transfer(address to, uint256 amount)',
2589
+ )
2590
+ const transferData = AbiFunction.encodeData(transfer, [
2591
+ '0x0000000000000000000000000000000000000001',
2592
+ Value.from('1', 6),
2593
+ ])
2594
+
2595
+ // scopes = [] → scoped mode with NO calls allowed
2596
+ const keyAuth = KeyAuthorization.from({
2597
+ address: accessAddress,
2598
+ chainId: BigInt(chainId),
2599
+ type: 'secp256k1',
2600
+ scopes: [],
2601
+ })
2602
+
2603
+ const keyAuth_signed = KeyAuthorization.from(keyAuth, {
2604
+ signature: SignatureEnvelope.from(
2605
+ Secp256k1.sign({
2606
+ payload: KeyAuthorization.getSignPayload(keyAuth),
2607
+ privateKey: root.privateKey,
2608
+ }),
2609
+ ),
2610
+ })
2611
+
2612
+ const nonce = await getTransactionCount(client, {
2613
+ address: root.address,
2614
+ blockTag: 'pending',
2615
+ })
2616
+
2617
+ const transaction = TxEnvelopeTempo.from({
2618
+ calls: [{ to: token, data: transferData }],
2619
+ chainId,
2620
+ feeToken: token,
2621
+ keyAuthorization: keyAuth_signed,
2622
+ nonce: BigInt(nonce),
2623
+ gas: 5_000_000n,
2624
+ maxFeePerGas: Value.fromGwei('20'),
2625
+ maxPriorityFeePerGas: Value.fromGwei('10'),
2626
+ })
2627
+
2628
+ const signature = Secp256k1.sign({
2629
+ payload: TxEnvelopeTempo.getSignPayload(transaction, {
2630
+ from: root.address,
2631
+ }),
2632
+ privateKey: accessPrivateKey,
2633
+ })
2634
+
2635
+ const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
2636
+ signature: SignatureEnvelope.from({
2637
+ userAddress: root.address,
2638
+ inner: SignatureEnvelope.from(signature),
2639
+ type: 'keychain',
2640
+ }),
2641
+ })
2642
+
2643
+ await expect(
2644
+ client.request({
2645
+ method: 'eth_sendRawTransactionSync',
2646
+ params: [serialized_signed],
2647
+ }),
2648
+ ).rejects.toThrow()
2649
+ },
2650
+ )
1683
2651
  })