Repository navigation
feat: add P-256 frame account support - #50
Conversation
Align the type 0x06 envelope, RPC parsing, and gas accounting with the current EIP-8141 format. Add FrameAccount, EOA preparation and signing helpers, an ETH transfer example, tests, and updated documentation. BREAKING CHANGE: FrameTransaction now uses a scalar nonce. The EIP-8250 nonceKeys/nonceSeq and EIP-8272 recentRootReferences fields and related APIs have been removed.
feat!: support scalar-nonce frame transactions and EOA accounts
send-frame-transaction.ts documented PRIVATE_KEY and RECIPIENT as its only inputs and said the sender pays gas, but it also required PAYMASTER_PRIVATE_KEY and attached a paymaster. The sponsored flow already lives in send-eth-paymaster.ts, so drop the paymaster here. Co-Authored-By: Claude Opus 5.5 <[email protected]>
src/p256.ts imports ox/P256 at runtime, but ox was only a devDependency, so tsup inlined ox and its @noble/curves and @noble/hashes code into dist (dist/index.js 11.4 KB -> 109.7 KB). Declaring it as a dependency keeps it external; ^0.14.45 matches the version viem already resolves, so installs dedupe to a single copy.
Ghadi8
left a comment
There was a problem hiding this comment.
Thanks for this, the signing itself looks solid: no double hashing of the sig-hash, low-s normalization, the local verify before returning, and the identity check against the public key are all things we want to keep.
I pushed 288a7ce to your branch: ox moves from devDependencies to dependencies (^0.14.45, the version viem resolves). As a devDependency, tsup was bundling ox and its noble code into dist (dist/index.js went from 11 KB to 110 KB). Please pull before your next push.
Before merging, I'd like to agree on the public API shape, because merging to main publishes a release and the names are hard to change afterwards. Right now the PR adds a P-256 copy of each layer next to the secp256k1 one (P256FrameSigner, privateKeyToP256FrameSigner, signP256FrameSignature, toP256FrameAccount). That works, but every future key type would need its own set, and paths like signFrameTx and the payer accounts stay secp256k1-only.
I'd suggest a different structure: one constructor per key type that all return the same owner shape with a type tag, and functions that accept any owner and branch on that tag. Here, that would look like:
1. Owners: one constructor per curve, one shared type
type P256FrameSigner = {
type: 'p256'
address: Address // last20Bytes(keccak256(qx || qy))
publicKey: Hex // 0x04 || qx || qy
sign: (p: { hash: Hash }) => Promise<Hex> // r || s, 64 bytes
}
privateKeyToAccount(key) // secp256k1, unchanged
privateKeyToP256Account(key) // your privateKeyToP256FrameSigner, renamed, returning the shape aboveReturning hex from sign and using a hex publicKey matches our existing FrameSigner, and the type tag keeps a P-256 key from being passed into the secp256k1 path by accident.
2. Accounts: split by account kind rather than curve
toEoaFrameAccount({ client, owner }) // codeless EOA, secp256k1 only (fixed by the protocol)
toDelegatedFrameAccount({ client, address, owner }) // 7702-delegated, signature entry built from owner.typeWhat really separates these two is the account model, not the curve: a delegated account has its own address and puts the owner's identity in signer. That same account also works with a secp256k1 key the delegation authorizes. Passing the key as owner (instead of signer plus owner: undefined) means account.owner is set the same way it is for EOA accounts. This needs owner in FrameAccountImplementation widened from viem's Account to Account | P256FrameSigner.
3. Signing: the existing functions learn scheme 2
signFrameSignature and signFrameTx would branch on the entry's scheme: 1 stays exactly as it is today, and 2 runs your P-256 code. Then signFrameTx and the payer accounts get P-256 for free, and signP256FrameSignature no longer needs to be exported. Since both functions are already released, please add tests showing scheme-1 signing is unchanged, e.g. the existing secp256k1 vectors still produce the same bytes, and a P-256 owner on a scheme-1 entry (or the reverse) throws.
Also worth documenting: sign({ hash }) has to sign the 32-byte digest as is, so it works with raw private keys and HSMs that sign raw digests, but not with WebCrypto keys or passkeys, which always SHA-256 the payload first.
signFrameTx sent every empty-msg P256 entry to the owner, so signing a secp256k1 payer after the P-256 sender slot was signed threw "P256 entry cannot be signed by a SECP256K1 owner". Entries of another scheme belong to another owner and are now skipped; signFrameSignature still rejects a mismatched slot because the caller names it.
|
🎉 This PR is included in version 0.5.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
Adds P-256 signature support for EIP-8141 frame transactions and introduces a
FrameAccountimplementation for accounts authorized by a P-256 signer.Closes #34.
Changes
P256FrameSignerandP256PublicKeytypes.keccak256(qx || qy)[12:].r || s || qx || qy.sform.toP256FrameAccount()for P-256-authorized frame accounts.sendFrameTransaction().oxfor P-256 cryptographic operations.Example