Skip to content

Commit b6b50d4

Browse files
jxomampcode-com
andauthored
feat(tempo): expiring nonces (wevm#4304)
* feat(tempo): expiring nonces (TIP-1009) - Rename nonceKey: 'random' to nonceKey: 'expiring' to align with TIP-1009 terminology - Add automatic concurrent transaction detection using event loop tick tracking - Extract validBefore from test snapshots and add direct assertions - Refactor concurrent detection into separate module (internal/concurrent.ts) - Update documentation with explanation and link to TIP-1009 - Fix validator.test.ts to use random addresses to avoid ValidatorAlreadyExists errors BREAKING CHANGE: nonceKey: 'random' renamed to nonceKey: 'expiring' Amp-Thread-ID: https://ampcode.com/threads/T-019c0606-05cd-7147-8ed8-c9c6c50e208c Co-authored-by: Amp <amp@ampcode.com> * Update shiny-nonces-expire.md * chore: fmt * chore: tweaks * u * chore: u * chore: up * chore: up * chore: up --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 803d4d9 commit b6b50d4

15 files changed

Lines changed: 320 additions & 377 deletions

.changeset/shiny-nonces-expire.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"viem": minor
3+
---
4+
5+
**Breaking (`viem/tempo`):** Renamed `nonceKey: 'random'` to `nonceKey: 'expiring'` to align with [TIP-1009](https://docs.tempo.xyz/protocol/tips/tip-1009) terminology.
6+
7+
TIP-1009 defines "expiring nonces" as time-based replay protection using `validBefore` timestamps. The name `'expiring'` better describes the mechanism than `'random'`.
8+
9+
```diff
10+
await sendTransaction(client, {
11+
account,
12+
- nonceKey: 'random',
13+
+ nonceKey: 'expiring',
14+
to: '0x...',
15+
})
16+
```

pnpm-lock.yaml

Lines changed: 17 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/snippets/tempo/write-parameters.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ Nonce for the transaction.
4646

4747
### nonceKey (optional)
4848

49-
- **Type:** `'random' | bigint`
49+
- **Type:** `'expiring' | bigint`
5050

51-
Nonce key for the transaction. Use `'random'` to generate a random nonce key.
51+
Nonce key for the transaction. Use `'expiring'` to use [expiring nonces (TIP-1009)](https://docs.tempo.xyz/protocol/tips/tip-1009), which enables concurrent transaction submission without nonce ordering.
5252

5353
### validBefore (optional)
5454

src/tempo/Decorator.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ describe('decorator', () => {
2929
"amm",
3030
"dex",
3131
"faucet",
32-
"nonce",
3332
"fee",
3433
"policy",
3534
"reward",

src/tempo/Transaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export type TransactionRequestTempo<
121121
calls?: readonly TxTempo.Call<quantity>[] | undefined
122122
feePayer?: Account | true | undefined
123123
feeToken?: Address | bigint | undefined
124-
nonceKey?: 'random' | quantity | undefined
124+
nonceKey?: 'expiring' | quantity | undefined
125125
validBefore?: index | undefined
126126
validAfter?: index | undefined
127127
}

src/tempo/Transport.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ describe('withFeePayer', () => {
107107
const receipt = await sendTransactionSync(client, {
108108
account,
109109
feePayer: true,
110-
to: '0x0000000000000000000000000000000000000000',
110+
to: '0x0000000000000000000000000000000000000001',
111111
})
112112

113113
expect(receipt.status).toBe('success')
@@ -118,7 +118,7 @@ describe('withFeePayer', () => {
118118
test('behavior: non-sponsored transaction uses default transport', async () => {
119119
const receipt = await sendTransactionSync(client, {
120120
account: accounts[0],
121-
to: '0x0000000000000000000000000000000000000000',
121+
to: '0x0000000000000000000000000000000000000002',
122122
})
123123

124124
expect(receipt.status).toBe('success')

src/tempo/actions/validator.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { describe, expect, test } from 'vitest'
22
import { accounts, getClient, nodeEnv } from '~test/tempo/config.js'
3+
import {
4+
generatePrivateKey,
5+
privateKeyToAddress,
6+
} from '../../accounts/index.js'
37
import { isAddress } from '../../utils/address/isAddress.js'
48
import * as actions from './index.js'
59

610
const account = accounts[0]
711
const account2 = accounts[1]
812
const validator = accounts[19]
13+
const randomValidatorAddress = privateKeyToAddress(generatePrivateKey())
914

1015
const client = getClient({
1116
account,
@@ -16,7 +21,7 @@ describe.runIf(nodeEnv === 'localnet')('add', () => {
1621
const initialCount = await actions.validator.getCount(client)
1722

1823
const { receipt } = await actions.validator.addSync(client, {
19-
newValidatorAddress: account2.address,
24+
newValidatorAddress: randomValidatorAddress,
2025
publicKey:
2126
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
2227
active: true,
@@ -31,9 +36,9 @@ describe.runIf(nodeEnv === 'localnet')('add', () => {
3136

3237
// Verify the validator was added
3338
const validator = await actions.validator.get(client, {
34-
validator: account2.address,
39+
validator: randomValidatorAddress,
3540
})
36-
expect(validator.validatorAddress).toBe(account2.address)
41+
expect(validator.validatorAddress).toBe(randomValidatorAddress)
3742
expect(validator.active).toBe(true)
3843
})
3944

src/tempo/chainConfig.test.ts

Lines changed: 45 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -18,42 +18,33 @@ const client = getClient({
1818
account: accounts.at(0)!,
1919
})
2020

21+
const maxUint256 = 2n ** 256n - 1n
22+
2123
describe('prepareTransactionRequest', () => {
22-
test('behavior: sequential nonce keys for feePayer transactions', async () => {
24+
test('behavior: expiring nonces for feePayer transactions', async () => {
25+
const now = Math.floor(Date.now() / 1000)
2326
const requests = await Promise.all([
2427
prepareTransactionRequest(client, { feePayer: true }),
2528
prepareTransactionRequest(client, { feePayer: true }),
2629
prepareTransactionRequest(client, { feePayer: true }),
2730
])
2831

29-
expect(requests[0]?.nonceKey).toBe(0n)
30-
expect(requests[1]?.nonceKey).toBeGreaterThan(0n)
31-
expect(requests[2]?.nonceKey).toBeGreaterThan(0n)
32-
})
33-
34-
test('behavior: nonce key counter resets after event loop tick', async () => {
35-
const requests1 = await Promise.all([
36-
prepareTransactionRequest(client, { feePayer: true }),
37-
prepareTransactionRequest(client, { feePayer: true }),
38-
])
39-
40-
expect(requests1[0]?.nonceKey).toBe(0n)
41-
expect(requests1[1]?.nonceKey).toBeGreaterThan(0n)
32+
// All feePayer transactions use expiring nonces (nonceKey = uint256.max)
33+
expect(requests[0]?.nonceKey).toBe(maxUint256)
34+
expect(requests[1]?.nonceKey).toBe(maxUint256)
35+
expect(requests[2]?.nonceKey).toBe(maxUint256)
4236

43-
// Wait for microtask queue to flush
44-
await new Promise((resolve) => queueMicrotask(() => resolve(undefined)))
45-
46-
const requests2 = await Promise.all([
47-
prepareTransactionRequest(client, { feePayer: true }),
48-
prepareTransactionRequest(client, { feePayer: true }),
49-
])
37+
// All should have nonce = 0 for expiring nonces
38+
expect(requests[0]?.nonce).toBe(0)
39+
expect(requests[1]?.nonce).toBe(0)
40+
expect(requests[2]?.nonce).toBe(0)
5041

51-
// Counter should have reset
52-
expect(requests2[0]?.nonceKey).toBe(0n)
53-
expect(requests2[1]?.nonceKey).toBeGreaterThan(0n)
42+
// All should have validBefore set within 30 seconds
43+
expect(requests[0]?.validBefore).toBeGreaterThanOrEqual(now)
44+
expect(requests[0]?.validBefore).toBeLessThanOrEqual(now + 31)
5445
})
5546

56-
test('behavior: explicit nonceKey overrides counter', async () => {
47+
test('behavior: explicit nonceKey overrides expiring nonce', async () => {
5748
const requests = await Promise.all([
5849
prepareTransactionRequest(client, {
5950
feePayer: true,
@@ -66,50 +57,46 @@ describe('prepareTransactionRequest', () => {
6657
}),
6758
])
6859

60+
// Explicit nonceKey uses 2D nonce mode
6961
expect(requests[0]?.nonceKey).toBe(42n)
70-
expect(requests[1]?.nonceKey).toBe(0n)
62+
expect(requests[0]?.validBefore).toBeUndefined()
63+
64+
// Default feePayer uses expiring nonces
65+
expect(requests[1]?.nonceKey).toBe(maxUint256)
66+
expect(requests[1]?.validBefore).toBeDefined()
67+
7168
expect(requests[2]?.nonceKey).toBe(100n)
69+
expect(requests[2]?.validBefore).toBeUndefined()
7270
})
7371

74-
test('behavior: default nonceKey when feePayer is not true', async () => {
72+
test('behavior: default nonceKey when feePayer is not set', async () => {
7573
const request = await prepareTransactionRequest(client, {})
7674
expect(request?.nonceKey).toBe(undefined)
75+
expect(request?.validBefore).toBeUndefined()
7776
})
7877

79-
test('behavior: nonce with sequential nonceKey', async () => {
80-
const requests = await Promise.all([
81-
prepareTransactionRequest(client, { feePayer: true }), // nonceKey: 0n
82-
prepareTransactionRequest(client, { feePayer: true }), // nonceKey: 1n
83-
prepareTransactionRequest(client, { feePayer: true }), // nonceKey: 2n
84-
])
85-
86-
expect(requests[0]?.nonce).toBeDefined()
87-
expect(requests[0]?.nonceKey).toBe(0n)
88-
89-
// nonceKey >= 1n is truthy, so nonce is 0
90-
expect(requests[1]?.nonce).toBe(0)
91-
expect(requests[1]?.nonceKey).toBeGreaterThan(0n)
92-
93-
expect(requests[2]?.nonce).toBe(0)
94-
expect(requests[2]?.nonceKey).toBeGreaterThan(0n)
78+
test('behavior: nonceKey expiring uses expiring nonces', async () => {
79+
const now = Math.floor(Date.now() / 1000)
80+
const request = await prepareTransactionRequest(client, {
81+
nonceKey: 'expiring',
82+
})
83+
expect(request?.nonceKey).toBe(maxUint256)
84+
expect(request?.nonce).toBe(0)
85+
expect(request?.validBefore).toBeGreaterThanOrEqual(now)
86+
expect(request?.validBefore).toBeLessThanOrEqual(now + 31)
9587
})
9688

97-
test('behavior: explicit nonce is preserved', async () => {
89+
test('behavior: explicit validBefore is preserved', async () => {
90+
const customValidBefore = Math.floor(Date.now() / 1000) + 15
9891
const request = await prepareTransactionRequest(client, {
9992
feePayer: true,
100-
nonce: 123,
93+
validBefore: customValidBefore,
10194
})
102-
expect(request?.nonce).toBe(123)
103-
expect(request?.nonceKey).toBe(0n)
104-
})
105-
106-
test('behavior: default nonceKey is 0n (falsy)', async () => {
107-
const request = await prepareTransactionRequest(client, {})
108-
expect(request?.nonceKey).toBe(undefined)
109-
expect(request?.nonce).toBeDefined()
95+
expect(request?.nonceKey).toBe(maxUint256)
96+
expect(request?.validBefore).toBe(customValidBefore)
11097
})
11198

112-
test('behavior: sendTransaction', async () => {
99+
test('behavior: sendTransaction with expiring nonces', async () => {
113100
const receipts = await Promise.all([
114101
sendTransactionSync(client, {
115102
to: '0x0000000000000000000000000000000000000000',
@@ -134,9 +121,10 @@ describe('prepareTransactionRequest', () => {
134121
hash: receipts[2].transactionHash,
135122
}),
136123
])
137-
expect(transactions[0].nonceKey).toBe(undefined)
138-
expect(transactions[1].nonceKey).toBeGreaterThan(0n)
139-
expect(transactions[2].nonceKey).toBeGreaterThan(0n)
124+
// Concurrent transactions automatically use expiring nonces
125+
expect(transactions[0].nonceKey).toBe(maxUint256)
126+
expect(transactions[1].nonceKey).toBe(maxUint256)
127+
expect(transactions[2].nonceKey).toBe(maxUint256)
140128
})
141129

142130
test('behavior: feeToken from chain config', async () => {

0 commit comments

Comments
 (0)