@megaeth-labs/wallet-intent
v1.0.1
Published
Shared V0.6 Intent primitives for MegaETH wallet, merchant, and SDKs.
Maintainers
Keywords
Readme
@megaeth-labs/wallet-intent
Shared primitives for building, signing, and submitting the MegaETH V0.6
Intent payload that the relay's wallet_sendCalls consumes.
Used by:
@megaeth-labs/wallet— callssendCallsdirectly for non-sponsored sends, and points it at a merchant URL for sponsored sends.@megaeth-labs/wallet-merchant— the sponsor backend receives the unsigned intent and signingkeyTypeover HTTP, setspayer = merchant address, fillspaymentSignature, re-runsprepareIntentwith thatkeyType, and returns the re-derived{ intent, digest }for the wallet to sign.
The package exposes a tiny surface: a single high-level sendCalls action
that wraps orchestrator lookups, nonce reads, payment-rate fetches, the
optional merchant round-trip, signing, and the JSON-RPC envelope; a sibling
upgradeAccount action that builds the EIP-7702 + multichain preCall
envelope; signMessage / signTypedData / verifyMessage for personal-sign
and typed-data flows (with ERC-8010 wrapping when the EOA is not yet
delegated); and the low-level prepareIntent / IntentV06 /
getPaymentPerGas / pickPaymentPerGasFor / ESTIMATED_GAS_BUDGET
building blocks for callers that need them.
paymentPerGas is quoted by the relay as a 1e18 fixed-point scalar —
the orchestrator computes the actual fee as
gasUsed × paymentPerGas / 10^18, so multiplying gas by the raw
paymentPerGas overshoots by 1e18. Divide by
PAYMENT_PER_GAS_FIXED_POINT_SCALAR to land at the correct fee-token
base-unit amount, regardless of the token's decimals.
Install
pnpm add @megaeth-labs/wallet-intentDuring local development the consumer repos use a file: dependency:
{
"dependencies": {
"@megaeth-labs/wallet-intent": "*"
}
}Run pnpm build in this repo first so dist/ exists.
Surface
import {
sendCalls,
prepareIntent,
getPaymentPerGas,
pickPaymentPerGasFor,
ESTIMATED_GAS_BUDGET,
erc1271SignPayload,
executeAs,
findSubAccountsByLogs,
keyHashForParent,
PAYMENT_PER_GAS_FIXED_POINT_SCALAR,
PAYMENT_PER_GAS_PRECISION,
upgradeAccount,
verifySubAccounts,
getKeys,
signMessage,
signTypedData,
verifyMessage,
type IntentSigningKeyType,
type IntentV06,
type UpgradeAccount,
} from '@megaeth-labs/wallet-intent';Most callers only need sendCalls(client, input). Pass merchantUrl to
route the unsigned intent through a sponsor; omit it for self-paid sends.
When paymentMaxAmount is omitted, sendCalls derives a raw token cap as
ceil((paymentGasBudget ?? ESTIMATED_GAS_BUDGET) × paymentPerGas / 1e18).
Pass an explicit paymentMaxAmount only when you want to override that
computed cap.
When the supplied signing key is the EOA's own root signer (a secp256k1
key whose publicKey matches account.address), sendCalls signs with a
raw 65-byte secp256k1 signature; for sub-keys (other secp256k1, p256,
webauthn) it produces a wrapped signature so the orchestrator can
recover the matching keyHash from keyStorage.
Sub-accounts (address keys)
An address-type key authorizes another account to act on this one. It
holds no private key of its own — the signature it stands for is an ERC-1271
signature produced by the account named in its publicKey. Pass it as key
along with an externalAdminKey that account can actually sign with:
const mainAccount = Key.from({
type: 'address',
publicKey: MAIN_ACCOUNT_ADDRESS,
role: 'admin',
});
// Authorize the main account as the sub-account's only admin.
const upgrade = await upgradeAccount(client, {
account: subEoa,
authorizeKeys: [mainAccount],
});
// ...then send from the sub-account, signed by the main account's passkey.
await sendCalls(client, {
account: Account.from({ address: subEoa.address, keys: [mainAccount] }),
key: mainAccount,
externalAdminKey: passkey,
calls,
authorization: upgrade,
});sendCalls nests the two signatures: the passkey signs the payload the main
account reconstructs on isValidSignature, and that blob is wrapped again
with the address key's keyHash so the sub-account routes the check to the
main account. The passkey is never added to the sub-account, so recovery and
rotation stay a property of the main account alone.
The externalAdminKey must be a super admin on that account, or hold an
explicit setSignatureCheckerApproval for this EOA — isValidSignature
refuses non-admin keys for third-party callers.
Paying for a sub-account
A sub-account need not hold gas. Set payer to the account footing the bill
and payerKey to a key on it; sendCalls signs the PaymentAuthorization
locally, the same one a merchant backend would sign:
await sendCalls(client, {
account: Account.from({ address: subEoa.address, keys: [mainAccount] }),
key: mainAccount,
externalAdminKey: passkey,
payer: MAIN_ACCOUNT_ADDRESS,
payerKey: passkey,
calls,
});Both signatures come from the same passkey here, but they are different things: the intent signature is nested through the main account's ERC-1271, while the payment signature is signed plainly, because the passkey is a key on the payer's own account and needs no indirection to reach it.
payer without payerKey or merchantUrl is rejected — the orchestrator
charges a third-party payer only against a paymentSignature, so such an
intent would revert on-chain.
Signing in as a sub-account (SIWE)
signMessage and signTypedData take externalAdminKey too, so a
sub-account can sign an EIP-4361 message with the main account's passkey:
const signature = await signMessage(client, {
account: Account.from({ address: subEoa.address, keys: [mainAccount] }),
key: mainAccount,
externalAdminKey: passkey,
message: siweMessage,
});
// Verifies against the sub-account's address, as any SIWE backend would.
await verifyMessage(publicClient, { address: subEoa.address, message: siweMessage, signature });This signature nests one layer deeper than an intent's. A verifier calls
isValidSignature on the sub-account, which rehashes the message under its own
domain before delegating to the main account — whereas the orchestrator calls
unwrapAndValidateSignature directly and skips that layer. Both paths go
through signAsExternalAdmin, so callers do not have to track the difference.
A sub-account can sign in before it exists on-chain, too — pass the
authorization from upgradeAccount and the signature comes back wrapped in
an ERC-8010 envelope:
const upgrade = await upgradeAccount(client, {
account: subEoa,
authorizeKeys: [mainAccount],
});
const signature = await signMessage(client, {
account: Account.from({ address: subEoa.address, keys: [mainAccount] }),
key: mainAccount,
externalAdminKey: passkey,
message: siweMessage,
authorization: upgrade,
});The verifier replays the 7702 authorization and executePreCalls in a
simulated call before checking the signature, so the account stays
undelegated and nothing is spent. Once it is delegated the envelope is
dropped automatically, so callers may pass authorization unconditionally.
erc1271SignPayload(...) builds that inner payload if you need it directly
(to pre-check a signature with an eth_call, say). Reach for it rather than
Key.sign(key, { address }): the latter builds a verifying-contract-only
payload, matching porto's account rather than the one deployed here, and
signatures made that way come back 0xffffffff.
Spending a balance split across accounts
An intent has one eoa, and every call in it runs as that account — so a
single intent cannot move funds from two accounts by itself. executeAs
bridges that: it builds a call that makes another account act, authorized by
an address key rather than by msg.sender.
// The sub-account holds 80, the main account 20, and the user wants to send 100.
const subSends = await executeAs(publicClient, {
account: subEoa.address,
key: mainAccount, // the address key registered on the sub-account
adminKey: passkey, // a super-admin key on the main account
calls: [transfer(recipient, 100n)],
});
await sendCalls(client, {
account: mainAccount,
key: passkey,
calls: [transfer(subEoa.address, 20n), subSends],
});Both calls sit in one executionData, so either both happen or neither does,
and the recipient sees a single transfer of 100 from the sub-account. Note this
is not the orchestrator's batch execute(bytes[]), which collects per-intent
errors instead of reverting and would let the top-up land while the payment
failed.
Two signatures are produced — the intent's and the inner opData — so with a
real passkey this prompts twice. See the note on session keys above.
Finding a parent's sub-accounts
An account can list its own keys, but no account can be asked which accounts have authorized it. The wallet API is the primary index for that direction; on-chain, the fallback is a log scan:
// No deployment needed, but depends on eth_getLogs behaviour.
const found = await findSubAccountsByLogs(client, { parent });
// Or verify a list the API already gave you — one eth_call per address.
const checked = await verifySubAccounts(client, { parent, addresses });Both verify each candidate against the account's live key state before
returning it, because a historical log does not prove the relationship still
holds — a sub-account can revoke its parent without telling anyone. Pass
activeOnly: false to see stale entries with active: false.
Note what the log scan can and cannot tell you: the key hash is the same for
an address key and a secp256k1 key on the same address, so it finds every
account that authorized the parent in any form, and anyone may authorize any
address. Treat its results as candidates to reconcile against the API's list,
not as a user's account list.
Upgrading an EOA (EIP-7702)
upgradeAccount(client, { account, authorizeKeys }) is pure: it signs the
EIP-7702 authorization for the orchestrator's account proxy and builds the
multichain authorize(key) preCall, then returns
{
account: Address;
status: 'pending';
authorization: SignedAuthorization;
encodedPreCall: Hex;
}Persist that blob yourself (e.g. in localStorage or your wallet's state)
until the chain has consumed it. Pass it back as the authorization field
on sendCalls, signMessage, and signTypedData; each call checks whether
the EOA is already delegated on the target chain and:
- attaches the 7702 authorization +
encodedPreCalltowallet_sendCalls(and surfacesdelegated: truein the receipt) when the EOA is still pending, or - wraps the user signature in an ERC-8010 envelope on
signMessage/signTypedDataso a verifier can lazily runexecutePreCallsbefore checking the signature.
When the chain is already delegated, the supplied authorization is
ignored — callers may pass it unconditionally and advance their own
"delegated chains" tracking off the receipt's delegated flag.
const upgrade = await upgradeAccount(client, {
account,
authorizeKeys: [passkey, sessionKey],
});
await sendCalls(client, { account, calls, authorization: upgrade });