viem
Version:
469 lines (442 loc) • 17.5 kB
text/typescript
import type { Address } from 'abitype'
import * as Hex from 'ox/Hex'
import {
MultisigConfig,
MultisigOperation,
type MultisigSimulation,
SignatureEnvelope,
type TokenId,
} from 'ox/tempo'
import { getCode } from '../actions/public/getCode.js'
import { getTransaction } from '../actions/public/getTransaction.js'
import { verifyHash } from '../actions/public/verifyHash.js'
import { maxUint256 } from '../constants/number.js'
import type { Chain, ChainConfig as viem_ChainConfig } from '../types/chain.js'
import { isAddressEqual } from '../utils/address/isAddressEqual.js'
import { extendSchema } from '../utils/chain/defineChain.js'
import { defineTransaction } from '../utils/formatters/transaction.js'
import { defineTransactionReceipt } from '../utils/formatters/transactionReceipt.js'
import { defineTransactionRequest } from '../utils/formatters/transactionRequest.js'
import { getAction } from '../utils/getAction.js'
import { keccak256 } from '../utils/hash/keccak256.js'
import type { SerializeTransactionFn } from '../utils/transaction/serializeTransaction.js'
import type { Account, MultisigAccount } from './Account.js'
import { getMetadata } from './actions/accessKey.js'
import { getConfig } from './actions/multisig.js'
import * as Formatters from './Formatters.js'
import type { Hardfork } from './Hardfork.js'
import * as Concurrent from './internal/concurrent.js'
import * as Transaction from './Transaction.js'
const maxExpirySecs = 25
// TODO: casting to satisfy viem – viem v3 to have more flexible serializer type.
const serializeTransaction = ((transaction, signature) =>
Transaction.serialize(transaction, signature)) as SerializeTransactionFn
export const chainConfig = {
blockTime: 1_000,
extendSchema: extendSchema<{
feeToken?: TokenId.TokenIdOrAddress | undefined
hardfork?: Hardfork | undefined
}>(),
formatters: {
transaction: defineTransaction({
exclude: ['aaAuthorizationList' as never],
format: Formatters.formatTransaction,
}),
transactionReceipt: defineTransactionReceipt({
format: Formatters.formatTransactionReceipt,
}),
transactionRequest: defineTransactionRequest({
format: Formatters.formatTransactionRequest,
}),
},
prepareTransactionRequest: [
async (r, { client, phase }) => {
const request = r as Transaction.TransactionRequest & {
account?: Account | MultisigAccount | undefined
chainId?: number | undefined
chain?:
| (Chain & {
feeToken?: TokenId.TokenIdOrAddress | undefined
})
| undefined
feePayerSignature?: Transaction.TransactionSerializableTempo['feePayerSignature']
from?: Address | undefined
hash?: Hex.Hex | undefined
keyData?: Hex.Hex | undefined
keyType?: 'p256' | 'secp256k1' | 'webAuthn' | undefined
multisigSimulation?: MultisigSimulation.Spec | undefined
owner?: Account | MultisigAccount | Address | undefined
signatures?: readonly unknown[] | undefined
}
if (request.hash) {
if (
!request.account ||
typeof request.account === 'string' ||
request.account.source !== 'multisig'
)
throw new Error(
'A local multisig account is required to approve a stored multisig transaction.',
)
if (!request.owner || typeof request.owner === 'string')
throw new Error(
'A local owner account is required to approve a stored multisig transaction.',
)
if (request.owner.source !== 'root')
throw new Error(
'A Tempo owner account is required to approve a stored multisig transaction.',
)
const transaction = await getAction(
client,
getTransaction,
'getTransaction',
)({ hash: request.hash })
const operation =
'multisig' in transaction
? (transaction.multisig as
| MultisigOperation.TransactionOperation
| undefined)
: undefined
if (!operation)
throw new Error('Expected a multisig operation transaction.')
if (!isAddressEqual(operation.account, request.account.address))
throw new Error(
'Multisig operation account does not match the requested account.',
)
const storedTransaction = Transaction.deserialize(
operation.transaction as Transaction.TransactionSerializedTempo,
)
const hash = MultisigOperation.getHash({
account: operation.account,
config: operation.config,
transaction: operation.transaction,
type: 'transaction',
})
if (hash.toLowerCase() !== request.hash.toLowerCase())
throw new Error('Multisig operation hash does not match transaction.')
return {
...storedTransaction,
account: request.account,
from: operation.account,
multisigSimulation: getMultisigSimulation({
config: operation.config,
local: request.account,
}),
owner: request.owner,
} as unknown as typeof r
}
// FIXME: node estimates gas with secp256k1 dummy sig + null feePayerSignature.
// Actual tx has larger keychain/webAuthn sigs + real fee payer sig, costing more intrinsic gas.
if (phase === 'afterFillParameters') {
// Fee payer signature covers the gas limit, so the relay must set it before signing and Viem must not change it afterward.
if (
typeof request.gas !== 'undefined' &&
request.feePayer &&
!request.feePayerSignature
) {
if (request.keyAuthorization?.signature.type === 'webAuthn')
request.gas = (request.gas ?? 0n) + 20_000n
else if (request.account?.source === 'accessKey')
request.gas = (request.gas ?? 0n) + 10_000n
}
return request as unknown as typeof r
}
const multisigIdentity = (() => {
if (request.account?.source === 'multisig')
return {
account: request.account.address,
config: (request.account as MultisigAccount).config,
local: request.account as MultisigAccount,
}
return undefined
})()
if (request.owner && !multisigIdentity)
throw new Error(
'A multisig account is required when an owner is provided.',
)
if (request.owner && typeof request.owner === 'string')
throw new Error(
'A local owner account is required to approve a multisig transaction.',
)
if (request.owner && request.owner.source !== 'root')
throw new Error(
'A Tempo owner account is required to approve a multisig transaction.',
)
const coordinatedMultisig =
!!multisigIdentity &&
!!request.owner &&
(client.transport as { multisig?: boolean }).multisig === true
if (multisigIdentity) {
const { account, local } = multisigIdentity
if (!account)
throw new Error(
'A multisig account address is required with a current config.',
)
const config = await (async () => {
if (multisigIdentity.config) return multisigIdentity.config
if (!coordinatedMultisig) return undefined
const cachedConfig = await getAction(
client,
getConfig,
'getConfig',
)({ address: account })
if (!cachedConfig)
throw new Error(
`No current multisig config is cached for account ${account}. Provide the current config.`,
)
return cachedConfig
})()
if (!config)
throw new Error(
'A multisig config is required to prepare a transaction.',
)
request.from = account
request.multisigSimulation = getMultisigSimulation({
config,
local,
})
}
// Register concurrency before account preparation performs storage or
// network I/O so overlapping requests cannot miss each other.
const useExpiringNonce = await (async () => {
if (request.nonceKey === 'expiring' || request.nonceKey === maxUint256)
return true
if (typeof request.nonceKey !== 'undefined') return false
if (multisigIdentity) return false
if (request.feePayer && typeof request.nonceKey === 'undefined')
return true
const account = request.account as
| Account
| MultisigAccount
| Address
| undefined
const address = typeof account === 'string' ? account : account?.address
if (address && typeof request.nonceKey === 'undefined')
return await Concurrent.detect(address.toLowerCase())
return false
})()
if (coordinatedMultisig && typeof request.nonceKey === 'undefined') {
// A random nonce lane lets a stored approval ceremony remain valid
// indefinitely without blocking other pending operations.
request.nonceKey = Hex.toBigInt(Hex.random(31)) + 1n
request.nonce = 0
} else if (useExpiringNonce) {
request.nonceKey = maxUint256
request.nonce = 0
if (typeof request.validAfter === 'undefined')
request.validAfter = randomValidAfter()
if (typeof request.validBefore === 'undefined')
request.validBefore = Math.floor(Date.now() / 1000) + maxExpirySecs
} else if (typeof request.nonceKey !== 'undefined') {
request.nonce = typeof request.nonce === 'number' ? request.nonce : 0
}
if (
!request.keyAuthorization &&
request.account?.source === 'accessKey'
) {
const keyAuthorizationManager = request.account.keyAuthorizationManager
if (keyAuthorizationManager) {
const chainId = request.chainId ?? request.chain?.id
if (typeof chainId !== 'undefined') {
const address = request.account.address
const accessKey = request.account.accessKeyAddress
const key = { address, accessKey, chainId }
const keyAuthorization = await keyAuthorizationManager.get(key)
if (keyAuthorization) {
const now = BigInt(Math.floor(Date.now() / 1000))
if (
keyAuthorization.expiry != null &&
BigInt(keyAuthorization.expiry) <= now
) {
await keyAuthorizationManager.remove(key)
} else {
const metadata = await getAction(
client,
getMetadata,
'getMetadata',
)({ account: address, accessKey })
if (
isAddressEqual(metadata.address, accessKey) &&
!metadata.isRevoked &&
metadata.expiry > now
)
await keyAuthorizationManager.remove(key)
else request.keyAuthorization = keyAuthorization
}
}
}
}
}
if (!request.feeToken && request.chain?.feeToken)
request.feeToken = request.chain.feeToken
return request as unknown as typeof r
},
{ runAt: ['beforeFillTransaction', 'afterFillParameters'] },
],
serializers: {
transaction: serializeTransaction,
async transactionEnvelope({ serializedTransaction, transaction }) {
const request = transaction as Transaction.TransactionSerializableTempo
if (!request.multisigSimulation) return serializedTransaction
try {
SignatureEnvelope.deserialize(serializedTransaction)
} catch {
const transaction = Transaction.deserialize(
serializedTransaction as Transaction.TransactionSerializedTempo,
)
if (transaction.signature?.type === 'multisig')
return serializedTransaction
throw new Error(
'A Tempo owner account is required to approve a multisig transaction.',
)
}
return await serializeTransaction({
...request,
signatures: [...(request.signatures ?? []), serializedTransaction],
} as never)
},
},
async verifyHash(client, parameters) {
const { address, hash, signature, mode } = parameters
const envelope = (() => {
if (typeof signature !== 'string') return
try {
return SignatureEnvelope.deserialize(signature)
} catch {
return undefined
}
})()
// `verifyHash` supports "signature envelopes" (a Tempo proposal) to natively verify arbitrary
// envelope-compatible (WebAuthn, P256, etc.) signatures.
if (envelope) {
// Access key (keychain) signature verification: check the key is
// authorized, not expired, and not revoked on the AccountKeychain.
if (envelope?.type === 'keychain' && mode === 'allowAccessKey') {
// For v2 keychain envelopes, the inner signature signs
// keccak256(0x04 || hash || userAddress).
const innerPayload =
envelope.version === 'v2'
? keccak256(Hex.concat('0x04', hash, address))
: hash
const accessKeyAddress = (() => {
try {
return SignatureEnvelope.extractAddress({
payload: innerPayload,
signature: envelope.inner,
})
} catch {
return undefined
}
})()
if (!accessKeyAddress) return false
const keyInfo = await getMetadata(client, {
account: address,
accessKey: accessKeyAddress,
blockHash: parameters.blockHash,
blockNumber: parameters.blockNumber,
blockTag: parameters.blockTag,
requireCanonical: parameters.requireCanonical,
} as never)
if (keyInfo.isRevoked) return false
if (keyInfo.expiry <= BigInt(Math.floor(Date.now() / 1000)))
return false
return SignatureEnvelope.verify(envelope.inner, {
address: accessKeyAddress,
payload: innerPayload,
})
}
// Stateless, non-keychain signature envelopes (P256, WebAuthn) can be
// verified directly without a network request.
if (envelope.type === 'p256' || envelope.type === 'webAuthn') {
const code = await getCode(client, {
address,
blockHash: parameters.blockHash,
blockNumber: parameters.blockNumber,
blockTag: parameters.blockTag,
requireCanonical: parameters.requireCanonical,
} as never)
// Check if EOA, if not, we want to go down the ERC-1271 flow.
if (
// not a contract (EOA)
!code ||
// default delegation (tempo EOA)
code === '0xef01007702c00000000000000000000000000000000000'
)
return SignatureEnvelope.verify(envelope, {
address,
payload: hash,
})
}
}
return await getAction(
client,
verifyHash,
'verifyHash',
)({ ...parameters, chain: null })
},
} as const satisfies viem_ChainConfig & { blockTime: number }
export type ChainConfig = typeof chainConfig
/** Builds a bounded multisig spec for gas simulation. */
function getMultisigSimulation(options: {
config: MultisigConfig.Config
local?: MultisigAccount | undefined
}): MultisigSimulation.Spec {
const { config, local } = options
return {
approvals: selectOwners(config).map((owner) => {
const localOwner = local?.owners.find((account) =>
isAddressEqual(account.address, owner.owner),
)
return {
...getSimulationKey(localOwner),
owner: owner.owner,
}
}),
config,
}
}
/** Returns signature metadata for conservative gas estimation. */
function getSimulationKey(
account: unknown,
):
| { keyData: Hex.Hex; keyType: 'webAuthn' }
| { keyType: 'p256' | 'secp256k1' } {
const keyType = (() => {
if (
account &&
typeof account === 'object' &&
'keyType' in account &&
typeof account.keyType === 'string'
)
return account.keyType
return undefined
})()
if (!keyType || keyType === 'webAuthn')
return { keyData: '0x0578' as const, keyType: 'webAuthn' as const }
if (keyType === 'p256' || keyType === 'secp256k1') return { keyType }
return { keyData: '0x0578' as const, keyType: 'webAuthn' as const }
}
/** Returns random past seconds to distinguish otherwise-identical expiring transactions. */
function randomValidAfter(): number {
const now = BigInt(Math.floor(Date.now() / 1_000))
const latest = now - 60n
if (latest <= 0n) return 0
return Number(BigInt(Hex.random(8)) % latest)
}
/** Selects a deterministic minimum-cardinality owner quorum. */
function selectOwners(config: MultisigConfig.Config) {
const owners = [...config.owners].sort(
(a, b) =>
Number(b.weight) - Number(a.weight) ||
a.owner.toLowerCase().localeCompare(b.owner.toLowerCase()),
)
const selected: typeof owners = []
let weight = 0
for (const owner of owners.slice(0, MultisigConfig.maxSignatures)) {
if (weight >= Number(config.threshold)) break
selected.push(owner)
weight += Number(owner.weight)
}
return selected.sort((a, b) =>
a.owner.toLowerCase().localeCompare(b.owner.toLowerCase()),
)
}