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.
25 changes: 18 additions & 7 deletions site/src/pages/tempo/guides/access-keys/admin.mdx
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
---
description: Create account-bound authorizations for unrestricted admin access keys.
description: Encode and decode TIP-1049 admin access key authorizations, removed at T14 by TIP-1137.
---

import { Card, Cards } from 'vocs'

# Admin Access Keys

:::warning
[TIP-1137](https://tips.sh/1137) removes admin access keys at T14. From T14, a Tempo node rejects
any key authorization with `isAdmin: true`, and only the root key, or a configurable account's
owner quorum, may sign a key authorization. Admin keys authorized before T14 act as regular
access keys without limits.

To give a second device full control on a T14 network, add its key as an owner of a
[configurable account](/tempo/guides/transaction-envelopes/configurable-accounts) instead. Ox keeps
encoding and decoding `isAdmin` so pre-T14 history round-trips.
:::

:::info
This guide is intended to be low-level. If you are looking for a high-level abstraction, check out
[Viem's Admin Access Keys guide](https://viem.sh/tempo/guides/access-keys/admin).
Expand All @@ -22,7 +33,7 @@ from being replayed for a different account.
Treat admin keys as root-equivalent key-management credentials and use scoped access keys for
routine application activity.

[See TIP-1049](https://tips.sh/1049)
[See TIP-1049](https://tips.sh/1049) and [TIP-1137](https://tips.sh/1137)

## Recipes

Expand Down Expand Up @@ -140,14 +151,14 @@ non-exportable key material, and provision as few admin keys as possible.

### Use Account Bindings Deliberately

Set `account` with `isAdmin: true` for an admin key or `isAdmin: false` for an account-bound
non-admin key. When decoding a wire value that contains `account` without `isAdmin`, Ox preserves
the binding and defaults `isAdmin` to `false`. An `isAdmin` value without `account` is discarded.
Set `account` without `isAdmin` for an account-bound non-admin key. The node requires `account`,
when present, to equal the transaction sender. When decoding a wire value that contains `account`
without `isAdmin`, Ox preserves the binding and leaves `isAdmin` undefined.

### Confirm Network Support

TIP-1049 activation can differ between Tempo networks. Confirm that the target node accepts admin
authorizations before relying on them operationally.
Admin authorizations are valid only from T6 up to, but not including, T14. Confirm the target
network's hardfork before submitting one; a T14 node rejects it.

## See More

Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/tempo/guides/access-keys/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ key, and submitting transactions require a Tempo RPC client or direct precompile
<Card
icon="lucide:shield-check"
title="Admin Access Keys"
description="Create an account-bound authorization for an unrestricted admin key."
description="Encode TIP-1049 admin authorizations, removed at T14 by TIP-1137."
to="/tempo/guides/access-keys/admin"
/>
<Card
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/tempo/guides/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ client when an application needs to read chain state or submit a signed transact
<Card
icon="lucide:key-round"
title="Access Keys"
description="Authorize scoped keys with expiry, limits, witnesses, and admin access."
description="Authorize scoped keys with expiry, limits, and witnesses."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the generated Tempo landing-page source

Updating only this hand-authored card leaves the generated /tempo landing page stale: scripts/docgen/build.ts:813-817 still emits an Access Keys card advertising “admin access,” so every pnpm docs:gen continues publishing the removed T14 capability on the main Tempo overview. Update that generator template alongside this guide text.

AGENTS.md reference: AGENTS.md:L70-L72

Useful? React with 👍 / 👎.

to="/tempo/guides/access-keys"
/>
<Card
Expand Down
10 changes: 6 additions & 4 deletions src/tempo/KeyAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ const signature_keychain = {

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 @@ -2673,7 +2673,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 @@ -2858,7 +2860,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
34 changes: 26 additions & 8 deletions src/tempo/KeyAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,25 @@ export type KeyAuthorization<
bigintType = bigint,
numberType = number,
> = {
/** 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?: Address.Address | undefined
/** Address derived from the public key of the key type. */
address: Address.Address
/** Chain ID for replay protection. */
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 @@ -104,15 +114,20 @@ export type Input = KeyAuthorization<false, bigint, number>

/** RPC representation matching the node's wire format. */
export type Rpc = {
/** Optional account address binding (TIP-1049). */
/** Optional account address binding. When present, must equal the transaction sender. */
account?: Address.Address | null | undefined
/** Allowed call scopes (node field: `allowedCalls`). */
allowedCalls?: readonly RpcCallScope[] | null | undefined
/** Chain ID (hex quantity). */
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 Down Expand Up @@ -712,7 +727,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 @@ -1140,8 +1157,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 @@ -1245,8 +1263,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 @@ -2807,23 +2807,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 @@ -2847,15 +2843,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 @@ -2865,9 +2925,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 @@ -2887,12 +2947,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 @@ -2901,7 +2957,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