Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tip-1137-admin-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ox": patch
---

Deprecated `KeyAuthorization` `isAdmin` and updated `account` docs for TIP-1137, which removes admin access keys at T14 while keeping their wire encoding decodable.
4 changes: 2 additions & 2 deletions .github/actions/install-dependencies/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ runs:
using: "composite"
steps:
- name: Set up foundry
uses: foundry-rs/foundry-toolchain@8b0419c685ef46cb79ec93fbdc131174afceb730 # v1.6.0
uses: wevm/actions/vendor/foundry-rs/foundry-toolchain/v1.6.0@8963daf30237417f873c8e2f17e3bf9415e3a7b9
with:
version: nightly

- name: Set up pnpm
uses: wevm/actions/.github/actions/pnpm@f95dc1dadcba18fe3f02b8205e653e98e4a93516
uses: wevm/actions/.github/actions/pnpm@8963daf30237417f873c8e2f17e3bf9415e3a7b9
with:
node-version: 24.5
skip-cache: ${{ inputs.skip-cache }}
10 changes: 6 additions & 4 deletions src/tempo/KeyAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ describe('configurable account binding', () => {
})

test('behavior: accepts unbound configurable grants', () => {
// The node binds a root-signed grant to its signer, so only admin-signed
// grants need `account` (TIP-1049).
// The node binds a grant to its signer, so `account` is optional. From T14
// only the root key or owner quorum may sign a grant (TIP-1137).
const signed = KeyAuthorization.from(
{ address, chainId: 1n, type: 'configurable' },
{ signature: signature_secp256k1 },
Expand Down Expand Up @@ -2743,7 +2743,9 @@ describe('witness (TIP-1053)', () => {
})
})

describe('admin keys (TIP-1049)', () => {
// TIP-1137 removes admin keys at T14, but the wire fields are unchanged, so
// pre-T14 history (T6 to T13) must still encode, decode, and hash.
describe('historical admin keys (TIP-1049)', () => {
const account = '0x1111111111111111111111111111111111111111' as const

test('from: preserves isAdmin and account', () => {
Expand Down Expand Up @@ -2928,7 +2930,7 @@ describe('admin keys (TIP-1049)', () => {
})

describe('configurable admin keys', () => {
test('behavior: a configurable account may sign or be granted an admin key', () => {
test('behavior: round-trips a pre-T14 configurable admin grant', () => {
const signed = KeyAuthorization.from(
{
account: signature_configurable.account,
Expand Down
41 changes: 31 additions & 10 deletions src/tempo/KeyAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ export type KeyAuthorization<
chainId: bigintType
/** Unix timestamp when key expires (undefined = never expires). */
expiry?: numberType | null | undefined
/** Whether this authorization provisions an admin access key. */
/**
* Whether this authorization provisions an admin access key (TIP-1049).
*
* @deprecated Admin access keys are removed at T14 by
* [TIP-1137](https://docs.tempo.xyz/protocol/tips/tip-1137). From T14 the Tempo
* node rejects any key authorization with `isAdmin: true`. The field is still
* encoded and decoded so pre-T14 history round-trips.
*/
isAdmin?: boolean | undefined
/**
* TIP20 spending limits for this key.
Expand Down Expand Up @@ -77,13 +84,19 @@ export type KeyAuthorization<
witness?: Hex.Hex | undefined
} & (
| {
/** Account address this authorization is bound to. Required when an admin access key signs the grant (TIP-1049). */
/**
* Account address this authorization is bound to. When present, the Tempo node
* requires it to equal the transaction sender.
*/
account?: addressType | undefined
/** Primitive key type. */
type: SignatureEnvelope.Type
}
| {
/** Account address this authorization is bound to. Required when an admin access key signs the grant (TIP-1049). */
/**
* Account address this authorization is bound to. When present, the Tempo node
* requires it to equal the transaction sender.
*/
account?: addressType | undefined
/** Configurable account key type. */
type: 'configurable'
Expand Down Expand Up @@ -128,7 +141,12 @@ export type Rpc = {
chainId: Hex.Hex
/** Expiry timestamp (hex quantity or null). */
expiry: Hex.Hex | null | undefined
/** Whether this authorization provisions an admin access key (TIP-1049). */
/**
* Whether this authorization provisions an admin access key (TIP-1049).
*
* @deprecated Rejected from T14 by [TIP-1137](https://docs.tempo.xyz/protocol/tips/tip-1137).
* Still decoded for pre-T14 history.
*/
isAdmin?: boolean | null | undefined
/** Key identifier. */
keyId: Address.Address
Expand All @@ -140,13 +158,13 @@ export type Rpc = {
witness?: Hex.Hex | null | undefined
} & (
| {
/** Optional account address binding (TIP-1049). */
/** Optional account address binding. When present, must equal the transaction sender. */
account?: Address.Address | null | undefined
/** Primitive key type. */
keyType: SignatureEnvelope.Type
}
| {
/** Optional account address binding (TIP-1049). */
/** Optional account address binding. When present, must equal the transaction sender. */
account?: Address.Address | null | undefined
/** Configurable account key type, named `configurableAccount` by the node. */
keyType: 'configurableAccount'
Expand Down Expand Up @@ -723,7 +741,9 @@ export function fromTuple<const tuple extends Tuple>(
const isAdmin = (() => {
if (isAbsent(rawIsAdmin)) return undefined
// TIP-1049: the admin marker is strictly `0x01`. Any other value is a
// protocol-level decode error on the node, so reject it here too.
// protocol-level decode error on the node, so reject it here too. T14
// (TIP-1137) rejects admin authorizations at validation, not decoding,
// so pre-T14 history still decodes.
if (rawIsAdmin !== '0x01')
throw new InvalidAdminMarkerError(rawIsAdmin as Hex.Hex)
return true
Expand Down Expand Up @@ -1135,8 +1155,9 @@ export function toTuple<const authorization extends KeyAuthorization>(
{ value: witness, placeholder: '0x' },
// TIP-1049: admin marker. Present = `0x01` (RLP integer 1); absent
// skipped or omitted. Any other value is a hard decode error on the node.
// TIP-1137 keeps this encoding but rejects `0x01` from T14.
{ value: isAdmin ? '0x01' : undefined, placeholder: '0x' },
// TIP-1049: optional account binding. Last field — never a placeholder.
// Optional account binding. Last field — never a placeholder.
{ value: account, placeholder: '0x' },
]
let lastPresent = -1
Expand Down Expand Up @@ -1238,8 +1259,8 @@ export class InvalidSignatureTypeError extends Error {
/**
* Thrown when a configurable account key grant omits its parent account binding.
*
* @deprecated No longer thrown. The Tempo node only requires `account` on grants
* signed by an admin access key, which it checks against the transaction sender.
* @deprecated No longer thrown. The Tempo node does not require `account`; when
* present, it must equal the transaction sender.
*/
export class MissingAccountError extends Error {
override readonly name = 'KeyAuthorization.MissingAccountError'
Expand Down
90 changes: 73 additions & 17 deletions src/tempo/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2849,23 +2849,19 @@ describe('behavior: keyAuthorization', () => {
},
)

// TIP-1049 admin keys are not yet in any released tempo tag (only on
// `main` via PR #4265). CI runs the localnet job against the `edge`
// image which tracks main; devnet/testnet still ship the older
// release and would reject the 9-field wire format with
// `failed to decode signed transaction`.
// TODO: remove skipIf when devnet/testnet have T6 (TIP-1049).
// TIP-1137 removes TIP-1049 admin keys at T14. The localnet job runs the
// `edge` image, which has T14 active; devnet/testnet may not.
test.skipIf(nodeEnv !== 'localnet')(
'behavior: TIP-1049 admin access key round-trips through registration',
'behavior: TIP-1137 rejects an admin key authorization',
async () => {
const accessPrivateKey = Secp256k1.randomPrivateKey()
const accessAddress = Address.fromPublicKey(
Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
)

const keyAuth = KeyAuthorization.from({
address: accessAddress,
account: root.address,
address: accessAddress,
chainId: BigInt(chainId),
isAdmin: true,
type: 'secp256k1',
Expand All @@ -2889,15 +2885,79 @@ describe('behavior: keyAuthorization', () => {
calls: [{ to: '0x0000000000000000000000000000000000000000' }],
chainId,
feeToken: '0x20c0000000000000000000000000000000000001',
gas: 1_000_000n,
keyAuthorization: keyAuth_signed,
maxFeePerGas: Value.fromGwei('20'),
maxPriorityFeePerGas: Value.fromGwei('10'),
nonce: BigInt(nonce),
})

const signature = Secp256k1.sign({
payload: TxEnvelopeTempo.getSignPayload(transaction, {
from: root.address,
}),
privateKey: accessPrivateKey,
})

const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
signature: SignatureEnvelope.from({
inner: SignatureEnvelope.from(signature),
type: 'keychain',
userAddress: root.address,
}),
})

// From T14 no path can set `is_admin = true`.
await expect(
client.request({
method: 'eth_sendRawTransactionSync',
params: [serialized_signed],
}),
).rejects.toThrow('InvalidKeyId')
},
)

test.skipIf(nodeEnv !== 'localnet')(
'behavior: account-bound key authorization round-trips through registration',
async () => {
const accessPrivateKey = Secp256k1.randomPrivateKey()
const accessAddress = Address.fromPublicKey(
Secp256k1.getPublicKey({ privateKey: accessPrivateKey }),
)

// From T14 an `account` binding must equal the transaction sender.
const keyAuth = KeyAuthorization.from({
account: root.address,
address: accessAddress,
chainId: BigInt(chainId),
type: 'secp256k1',
})

const keyAuth_signed = KeyAuthorization.from(keyAuth, {
signature: SignatureEnvelope.from(
Secp256k1.sign({
payload: KeyAuthorization.getSignPayload(keyAuth),
privateKey: root.privateKey,
}),
),
})

const nonce = await getTransactionCount(client, {
address: root.address,
blockTag: 'pending',
})

const transaction = TxEnvelopeTempo.from({
calls: [{ to: '0x0000000000000000000000000000000000000000' }],
chainId,
feeToken: '0x20c0000000000000000000000000000000000001',
gas: 1_000_000n,
keyAuthorization: keyAuth_signed,
maxFeePerGas: Value.fromGwei('20'),
maxPriorityFeePerGas: Value.fromGwei('10'),
nonce: BigInt(nonce),
})

// The admin access key signs and authorizes itself in the same tx
// (the canonical "auth+use" registration pattern).
const signature = Secp256k1.sign({
payload: TxEnvelopeTempo.getSignPayload(transaction, {
from: root.address,
Expand All @@ -2907,9 +2967,9 @@ describe('behavior: keyAuthorization', () => {

const serialized_signed = TxEnvelopeTempo.serialize(transaction, {
signature: SignatureEnvelope.from({
userAddress: root.address,
inner: SignatureEnvelope.from(signature),
type: 'keychain',
userAddress: root.address,
}),
})

Expand All @@ -2929,12 +2989,8 @@ describe('behavior: keyAuthorization', () => {
.then((tx) => Transaction.fromRpc(tx as any))
if (!response) throw new Error()

// isAdmin + account must survive the round trip through the node.
expect(response.keyAuthorization?.isAdmin).toBe(true)
expect(response.keyAuthorization?.isAdmin).toBeUndefined()
expect(response.keyAuthorization?.account).toBe(root.address)

// The signing hash of the round-tripped authorization equals the
// admin-bearing hash.
expect(KeyAuthorization.hash(response.keyAuthorization!)).toBe(
KeyAuthorization.hash(keyAuth_signed),
)
Expand All @@ -2943,7 +2999,7 @@ describe('behavior: keyAuthorization', () => {

// TODO: remove skipIf when devnet/testnet have T6.
test.skipIf(nodeEnv !== 'localnet')(
'behavior: TIP-1049 non-admin authorization is byte-equivalent to pre-TIP-1049',
'behavior: unbound authorization is byte-equivalent to pre-TIP-1049',
async () => {
const accessPrivateKey = Secp256k1.randomPrivateKey()
const accessAddress = Address.fromPublicKey(
Expand Down
Loading