Skip to content

feat: add P-256 frame account support - #50

Merged
Ghadi8 merged 12 commits into
JustaLab-co:mainfrom
nikhilkumar1612:main
Oct 8, 2026
Merged

Ghadi8 merged 12 commits into
JustaLab-co:mainfrom
nikhilkumar1612:main

Conversation

@nikhilkumar1612

Copy link
Copy Markdown
Contributor

Summary

Adds P-256 signature support for EIP-8141 frame transactions and introduces a FrameAccount implementation for accounts authorized by a P-256 signer.

Closes #34.

Changes

  • Add P256FrameSigner and P256PublicKey types.
  • Add P-256 signer identity derivation using keccak256(qx || qy)[12:].
  • Add a helper for creating a P-256 signer from a private scalar.
  • Sign the canonical frame transaction hash without hashing it again.
  • Encode scheme-2 signatures as r || s || qx || qy.
  • Normalize signatures to canonical low-s form.
  • Verify generated signatures locally before returning them.
  • Add toP256FrameAccount() for P-256-authorized frame accounts.
  • Integrate P-256 accounts with sendFrameTransaction().
  • Add a complete P-256 ETH transfer example.
  • Document the P-256 account and signing flow.
  • Add ox for P-256 cryptographic operations.

Example

const signer = privateKeyToP256FrameSigner(p256PrivateKey)

const account = await toP256FrameAccount({
  client,
  address: delegatedEoa,
  signer,
})

const hash = await sendFrameTransaction(
  account,
  [{ to: recipient, value }],
  {
    limits: {
      validation: { execution: 100_000n, state: 0n },
      calls: [{ execution: 30_000n, state: 200_000n }],
    },
  },
)

nikhilkumar1612 and others added 9 commits September 18, 2026 21:02
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 Ghadi8 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 above

Returning 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.type

What 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.

nikhilkumar1612 and others added 2 commits October 8, 2026 01:38
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.
@Ghadi8
Ghadi8 merged commit c80b884 into JustaLab-co:main Oct 8, 2026
4 checks passed
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 0.5.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(signatures): P256 signing

2 participants