Skip to content
Open
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
51 changes: 38 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ const signed = await signFrameTx(tx, remoteAccount)
```

A raw-digest `sign` is required. `signMessage` will not do: it EIP-191-prefixes its
argument, so the signature recovers to nothing. A `JsonRpcAccount` (a browser wallet)
cannot sign a raw digest at all — both are refused with an error rather than producing a
transaction the chain silently rejects.
argument, so the signature recovers to nothing. A plain `JsonRpcAccount` does not expose
raw-digest signing, but `toFrameAccount` and `toFramePayerAccount` accept a `sign({ hash })`
override for an injected, hardware, HSM, or remote account whose backend really can sign
that digest. Do not implement the override with ordinary `personal_sign` or a prefixed
`eth_sign` method; those produce a different signature.

The same restriction applies to P-256 signers. WebCrypto's ECDSA API hashes its input
internally, so passing the frame sig-hash to it signs `SHA-256(frameSigHash)`, not the
Expand All @@ -123,14 +125,36 @@ as `r || s || qx || qy`.
```ts
import {
privateKeyToP256Account,
toP256FrameAccount,
toFrameAccount,
} from '@jaw.id/frametx-kit'

const owner = privateKeyToP256Account(p256PrivateKey)
const account = await toP256FrameAccount({
const account = await toFrameAccount({
client,
address: delegatedEoa,
owner,
delegateAddress: p256Implementation,
})
```

The same constructor handles an ordinary local EOA:

```ts
const account = await toFrameAccount({
client,
owner: privateKeyToAccount(privateKey),
})
```

For an injected or remote account, supply its viem account object and a raw-digest
adapter. The adapter is explicit because standard browser-wallet signing methods do not
guarantee the semantics required by a frame signature:

```ts
const account = await toFrameAccount({
client,
owner: walletClient.account,
sign: ({ hash }) => rawSigner.signDigest(hash),
})
```

Expand All @@ -140,6 +164,7 @@ To exercise a PEM-backed delegated account end to end against the Ethrex testnet
RPC_URL=https://rpc1.privacy.ethrex.xyz \
SENDER=0x... \
RECIPIENT=0x... \
P256_IMPLEMENTATION_ADDRESS=0x... \
P256_PRIVATE_KEY_PATH=/absolute/path/to/p256-private.pem \
bun run scripts/send-eth-p256.ts
```
Expand All @@ -159,15 +184,15 @@ const receipt = await client.waitForFrameTransactionReceipt({ hash })

Passing every check here does not guarantee admission. The VERIFY prefix must `APPROVE`
payment, and the payer must hold the transaction's `maxCost`, or the frame reverts. A
code-less EOA gets that from the protocol's default code, which `toEoaFrameAccount` targets;
anything else needs its own validating contract. Dry-run with
code-less EOA gets that from the protocol's default code, which `toFrameAccount` uses by
default; anything else needs its own validating contract. Dry-run with
`client.simulateFrameTransaction({ raw })` before spending.

To use a separate codeless EOA payer, resolve it from a raw-digest-capable account and pass
it through preparation and signing:

```ts
const payer = await toEoaFramePayerAccount({
const payer = await toFramePayerAccount({
owner: privateKeyToAccount(payerPrivateKey),
})

Expand All @@ -182,11 +207,11 @@ const signed = await signFrameTransaction(account, transaction, { payer })
```

This changes the validation prefix from `self_verify` to `only_verify -> pay`. The
sender signs outer signature index `0`; the EOA payer signs the protocol-reserved payment
signature at index `1`. Both entries are installed during preparation, before either
signature is produced, so both signatures commit to the same transaction hash. Custom
contract payers can be defined with `toFramePayerAccount` to provide their PAY calldata,
signature entries, and signing behavior.
sender and EOA payer sign their respective prepared entries; the payer entry carries its
address explicitly and is not selected by a fixed array index. Both entries are installed
during preparation, before either signature is produced, so both signatures commit to the
same transaction hash. Contract payers use the same `toFramePayerAccount` constructor and
can override their PAY calldata, signature entries, and signing behavior.

## Nonces are keyed

Expand Down
52 changes: 34 additions & 18 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ commit of [`lambdaclass/ethrex`](https://github.com/lambdaclass/ethrex/tree/bdfc

A TypeScript client for frame transactions: build, validate, encode, decode, hash, sign,
price, simulate, broadcast, and read them back with their frame receipts. It ships a small
account abstraction (`toFrameAccount`, `toEoaFrameAccount`, `toFramePayerAccount`,
`toEoaFramePayerAccount`), `prepareFrameTransaction`, and a viem extension
account abstraction (`toFrameAccount`, `toFramePayerAccount`),
`prepareFrameTransaction`, and a viem extension
(`client.extend(frameActions)`). It is not a fork of viem core.

Not supported:
Expand Down Expand Up @@ -141,24 +141,40 @@ from a sender that does not exist yet. These are budgeting figures for choosing
A `FrameAccountImplementation` supplies `getAddress()`, `getNonce({ key?, blockTag? })` (the
current sequence of one nonce key), `getValidationData({ calls, chainId, nonceKeys,
nonceSeq })`, `getSignatureEntries(tx)`, `signFrameTransaction(tx)` and an execution
strategy; `toFrameAccount` resolves it into a `FrameAccount`.

`toEoaFrameAccount` targets a code-less EOA through the protocol's default code: key `0`
from `eth_getTransactionCount`, other keys from `NONCE_MANAGER` via `eth_getStorageAt`, empty
validation data, and one empty-`msg`, empty-`signer` SECP256K1 entry.

`toP256FrameAccount` targets an EIP-7702 delegated EOA whose validation code authorizes a
P-256 signer. It emits one empty-`msg` P256 entry with the explicit signer identity
`last20Bytes(keccak256(qx || qy))` at index `0`. Its signer signs the already-computed
frame sig-hash directly, without applying SHA-256 a second time, normalizes `s` low, and
encodes `r || s || qx || qy`.
strategy. `toFrameAccount` is the single constructor for both codeless and delegated
senders. It provides overridable defaults: the sender is `owner.address`, nonce key `0`
comes from `eth_getTransactionCount`, other keys come from `NONCE_MANAGER`, validation
data is empty, and the signature scheme is inferred from the owner.

A viem owner produces a SECP256K1 entry. A P-256 owner produces a P256 entry with the
explicit identity `last20Bytes(keccak256(qx || qy))`; its signer signs the frame sig-hash
directly, normalizes `s` low, and encodes `r || s || qx || qy`. Passing `address` separately
represents a delegated EOA controlled by a different signer. When `delegateAddress` is
provided, construction verifies that `eth_getCode(address)` is exactly
`0xef0100 || delegateAddress`. A delegated sender always uses explicit signer metadata,
even when its secp256k1 owner has the same address as the sender. `address` and
`getAddress` are mutually exclusive. Account-specific validation or signature policies
override `getValidationData`, `getSignatureEntries`, or `signFrameTransaction` on the
same constructor. A fully custom account may omit `owner` when it supplies an address and
all four account-policy callbacks directly.

A plain JSON-RPC/injected account has no raw-digest signing method. The optional
`sign({ hash })` adapter supports an injected, hardware, HSM, or remote backend only when
that backend actually signs the supplied digest. EIP-191 `signMessage`, `personal_sign`,
and prefixed `eth_sign` behavior are not substitutes.

A `FramePayerAccountImplementation` supplies `getAddress()`, `getPayData(tx)`,
`getSignatureEntries(tx)` and `signFrameTransaction(tx)`; `toFramePayerAccount` resolves
it into a `FramePayerAccount`. `toEoaFramePayerAccount` implements the protocol's codeless
EOA payer path: empty PAY calldata and an empty-`msg` SECP256K1 entry at index `1` whose
explicit signer is the payer. The explicit address is required because an empty signer
resolves to `tx.sender`.
`getSignatureEntries(tx)` and `signFrameTransaction(tx)`. `toFramePayerAccount` is the
single payer constructor. Its defaults cover a codeless EOA payer: empty PAY calldata and
an empty-`msg` signature entry whose explicit signer is the payer, since an empty signer
would resolve to `tx.sender`. The default policy refuses a payer that is also `tx.sender`;
self-paying EOAs use sender validation rather than a separate PAY frame. It otherwise
applies no sponsorship policy: it does not cap transaction cost or restrict frame contents,
so a sponsor must override `signFrameTransaction` to enforce those rules. A contract payer
with no signing owner supplies `address` (or `getAddress`) and all three policy callbacks
directly; `address` and `getAddress` are mutually exclusive. Delegated or contract payers
override the same callbacks. Signing locates the payer-owned entry by scheme and explicit
signer rather than assuming a fixed signature-array index.

`prepareFrameTransaction(account, calls, limits, { nonceKeys?, blockTag?, paymaster? })`
builds one SENDER frame per call and reads fees from the node (`2 * baseFee + priorityFee`,
Expand Down
4 changes: 2 additions & 2 deletions scripts/send-eth-eoa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
prepareFrameTransaction,
simulateFrameTransaction,
signFrameTransaction,
toEoaFrameAccount,
toFrameAccount,
type FrameRpcClient,
type RpcFrameReceiptJson,
} from '../src/index.js'
Expand Down Expand Up @@ -85,7 +85,7 @@ async function main(): Promise<void> {
chain: hegotaTestnet,
transport: http(rpcUrl),
})
const account = await toEoaFrameAccount({ client, owner })
const account = await toFrameAccount({ client, owner })

const unsigned = await prepareFrameTransaction(
account,
Expand Down
15 changes: 9 additions & 6 deletions scripts/send-eth-p256.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Required:
* SENDER=0x...
* RECIPIENT=0x...
* P256_IMPLEMENTATION_ADDRESS=0x...
* P256_PRIVATE_KEY_PATH=/absolute/path/to/p256-private.pem
*
* Optional:
Expand All @@ -31,7 +32,7 @@ import {
parseRpcFrameReceipt,
privateKeyToP256Account,
sendFrameTransaction,
toP256FrameAccount,
toFrameAccount,
type FrameRpcClient,
type RpcFrameReceiptJson,
} from '../src/index.js'
Expand Down Expand Up @@ -91,6 +92,7 @@ async function main(): Promise<void> {
const rpcUrl = process.env.RPC_URL ?? 'https://rpc1.privacy.ethrex.xyz'
const sender = getAddress(requiredEnv('SENDER'))
const recipient = getAddress(requiredEnv('RECIPIENT'))
const delegateAddress = getAddress(requiredEnv('P256_IMPLEMENTATION_ADDRESS'))
const amount = parseEther(process.env.AMOUNT_ETH ?? '0.000001')
const owner = privateKeyToP256Account(
loadP256PrivateKey(requiredEnv('P256_PRIVATE_KEY_PATH')),
Expand All @@ -109,10 +111,6 @@ async function main(): Promise<void> {
chain: hegotaTestnet,
transport: http(rpcUrl),
})
const code = await client.getCode({ address: sender })
if (code === undefined || !code.toLowerCase().startsWith('0xef0100'))
throw new Error(`sender ${sender} does not have an EIP-7702 delegation`)

const signerCall = await client.call({
to: sender,
data: encodeFunctionData({ abi: P256_ACCOUNT_ABI, functionName: 'p256Signer' }),
Expand All @@ -129,7 +127,12 @@ async function main(): Promise<void> {
`delegated account authorizes ${configuredSigner}, but the PEM identity is ${owner.address}`,
)

const account = await toP256FrameAccount({ client, address: sender, owner })
const account = await toFrameAccount({
client,
address: sender,
owner,
delegateAddress,
})
const [senderBalanceBefore, recipientBalanceBefore] = await Promise.all([
client.getBalance({ address: sender }),
client.getBalance({ address: recipient }),
Expand Down
8 changes: 4 additions & 4 deletions scripts/send-eth-paymaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ import {
prepareFrameTransaction,
simulateFrameTransaction,
signFrameTransaction,
toEoaFrameAccount,
toEoaFramePayerAccount,
toFrameAccount,
toFramePayerAccount,
type FrameRpcClient,
type RpcFrameReceiptJson,
} from '../src/index.js'
Expand Down Expand Up @@ -132,8 +132,8 @@ async function main(): Promise<void> {
chain: hegotaTestnet,
transport: http(rpcUrl),
})
const account = await toEoaFrameAccount({ client, owner })
const payer = await toEoaFramePayerAccount({ owner: payerOwner })
const account = await toFrameAccount({ client, owner })
const payer = await toFramePayerAccount({ owner: payerOwner })

const unsigned = await prepareFrameTransaction(
account,
Expand Down
4 changes: 2 additions & 2 deletions scripts/send-frame-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { privateKeyToAccount } from 'viem/accounts'
import {
hegotaTestnet,
sendFrameTransaction,
toEoaFrameAccount,
toFrameAccount,
} from '../src/index.js'
import { frameActions } from '../src/viem.js'

Expand All @@ -51,7 +51,7 @@ async function main(): Promise<void> {
chain: hegotaTestnet,
transport: http(rpcUrl),
}).extend(frameActions)
const account = await toEoaFrameAccount({ client, owner })
const account = await toFrameAccount({ client, owner })

console.log(`sender: ${account.address}`)
console.log(`recipient: ${recipient}`)
Expand Down
75 changes: 75 additions & 0 deletions src/accounts/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
type Account,
type Address,
type Hex,
isAddressEqual,
recoverAddress,
} from 'viem'
import { sameAddress } from '../envelope.js'
import { FrameEncodeError } from '../errors.js'
import type { FrameSignature, FrameTransaction } from '../types.js'
import type { FrameAccountOwner } from './types.js'

export type RawFrameSigner = (parameters: { hash: Hex }) => Promise<Hex>

export function ownerWithSigner(
owner: FrameAccountOwner,
sign?: RawFrameSigner | undefined,
): FrameAccountOwner {
if (sign === undefined) return owner
if (owner.type === 'p256') return { ...owner, sign }
return {
...owner,
async sign({ hash }: { hash: Hex }) {
const signature = await sign({ hash })
let recovered: Address
try {
recovered = await recoverAddress({ hash, signature })
} catch {
throw new FrameEncodeError(
`raw signer for ${owner.address} returned an invalid secp256k1 signature`,
)
}
if (!isAddressEqual(recovered, owner.address))
throw new FrameEncodeError(
`raw signer recovered ${recovered}, expected owner ${owner.address}`,
)
return signature
},
} as Account
}

export function ownerSignatureScheme(owner: FrameAccountOwner): 1 | 2 {
return owner.type === 'p256' ? 2 : 1
}

export function ownerSignatureEntry(
owner: FrameAccountOwner,
signer: Address | null,
): FrameSignature {
return {
scheme: ownerSignatureScheme(owner),
signer,
msg: '0x',
signature: '0x',
}
}

export function findOwnerSignatureIndex(
transaction: FrameTransaction,
owner: FrameAccountOwner,
direction: 'first' | 'last',
options: { explicitSigner?: boolean } = {},
): number {
const scheme = ownerSignatureScheme(owner)
const matches = (signature: FrameSignature): boolean =>
(!options.explicitSigner || signature.signer !== null) &&
signature.scheme === scheme &&
signature.msg === '0x' &&
sameAddress(signature.signer ?? transaction.sender, owner.address)

if (direction === 'first') return transaction.signatures.findIndex(matches)
for (let index = transaction.signatures.length - 1; index >= 0; index--)
if (matches(transaction.signatures[index]!)) return index
return -1
}
Loading
Loading