From ed99cd6a4e367ab3f4099a2ef7024e2b628fa5da Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 6 Oct 2026 13:50:26 -0400 Subject: [PATCH] feat(vibenet): track ERC-8168 update (payer authorizes before the user signs) - payer_signTransaction is called on the unsigned tx and returns payerAuth; the sender signs once with it in place, then the page broadcasts - co-sign param renamed to `transaction`; getTerms gasLimit excludes phase 0 - COST_EXCEEDS_LIMIT message; tolerate an absent paymentAmount - rebuild vendor/aa from viem feat/aa-tx-split (37aa9cf1f); refresh ERC-8168 types --- .../account/components/TransactionModal.tsx | 42 +++++++---- .../demos/account/useAccountEngine.tsx | 9 +++ vendor/aa/entry.mjs | 3 + vendor/aa/index.d.ts | 74 ++++++++++++------- vendor/aa/index.js | 13 ++++ 5 files changed, 103 insertions(+), 38 deletions(-) diff --git a/app/vibenet/demos/account/components/TransactionModal.tsx b/app/vibenet/demos/account/components/TransactionModal.tsx index 74f5d8a..b2f48f7 100644 --- a/app/vibenet/demos/account/components/TransactionModal.tsx +++ b/app/vibenet/demos/account/components/TransactionModal.tsx @@ -111,6 +111,8 @@ function payerRejectionMessage(rejected: PayerRejectedData): string { : 'The payer’s simulation says this transaction would revert.'; case 'GAS_TOO_LOW': return 'The payer needs a higher gas limit for these calls. Retry to re-sign with more gas.'; + case 'COST_EXCEEDS_LIMIT': + return 'This transaction costs more gas than the payer covers per transaction. Retry when gas is cheaper, or pay with ETH.'; default: return rejected.reason ?? `Payer rejected the transaction (${rejected.code}).`; } @@ -264,15 +266,24 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) { try { const payerClient = createPayerClient({ url: PAYER_URL }); const rpcCalls = buildCalls(calls, acct.address).map((c) => ({ to: c.to, value: toHex(c.value), data: c.data })); + // ERC-8168: the intent's `gasLimit` excludes phase 0; the payer adds `paymentGas`. + const callGas = estimateTxGas({ + calls: calls.length, + valueCalls: valueBearingCallCount(calls, acct.address), + nonceless, + payer: true, + tokenPayment: false, + }); + const context = { flow: 'transact' }; const terms = await payerClient.getTerms({ chainId: toHex(chain.id || 84538453), from: acct.address, calls: rpcCalls, - gasLimit: toHex(BigInt(gasEstimate || 200_000)), - context: { flow: 'transact' }, + gasLimit: toHex(BigInt(callGas || 200_000)), + context, }); - let selToken: Address | undefined; + let selToken: Address | 'native' | undefined; if (mode === 'usdv') { const tokenOffer = terms.options.find(isTokenOffer); selToken = tokenOffer?.tokens?.[0]?.token; @@ -281,11 +292,18 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) { const declinedFree = mode === 'free' ? terms.options.find(isDeclinedOffer) : undefined; const { option, tokenChoice } = selectPaymentOption(terms, selToken ? { token: selToken } : {}); + if (option.kind === 'token' && tokenChoice?.token === 'native') + throw new Error('This payer only offers native-token payment, which this dialog does not build.'); + // A payer that co-signs without submitting authorizes before the user signs. + const authorize = option.methods?.includes('payer_signTransaction') + ? async (unsigned: Hex) => (await payerClient.signTransaction({ transaction: unsigned, context })).payerAuth + : undefined; let payer: ComposePayer; - if (option.kind === 'token' && tokenChoice) { - const quoted = BigInt(tokenChoice.paymentAmount); + if (option.kind === 'token' && tokenChoice && tokenChoice.token !== 'native') { + const quoted = tokenChoice.paymentAmount ? BigInt(tokenChoice.paymentAmount) : 0n; payer = { address: option.payer, + authorize, tokenPayment: { token: tokenChoice.token, to: tokenChoice.feeRecipient ?? option.payer, @@ -294,7 +312,7 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) { }, }; } else { - payer = { address: option.payer }; + payer = { address: option.payer, authorize }; } // A payer requires an expiry; stay inside its `maxExpiry` (the engine @@ -326,15 +344,13 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) { let txHash: Hex; let pending = false; - let finalTx = serialized; - if (option.methods?.includes('payer_signTransaction')) { - const cosigned = await payerClient.signTransaction({ signedTransaction: serialized, context: { flow: 'transact' } }); - finalTx = cosigned.signedTransaction; - ({ txHash, pending } = await broadcastOrPending(finalTx)); + if (authorize) { + // Already carries the payer's `payerAuth`. + ({ txHash, pending } = await broadcastOrPending(serialized)); } else { setSubmitStatus('submitting'); const anchor = latestAnchor(); - const sent = await payerClient.sendTransaction({ signedTransaction: serialized, context: { flow: 'transact' } }); + const sent = await payerClient.sendTransaction({ transaction: serialized, context }); setSubmitStatus('confirming'); txHash = sent.transactionHash; try { @@ -344,7 +360,7 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) { pending = true; } } - recordResult(acct, finalTx, txHash, pending, activeSigner, gasNote); + recordResult(acct, serialized, txHash, pending, activeSigner, gasNote); } catch (err) { const rejected = parsePayerError(err); if (rejected) { diff --git a/app/vibenet/demos/account/useAccountEngine.tsx b/app/vibenet/demos/account/useAccountEngine.tsx index 1ec041b..9f9bd5d 100644 --- a/app/vibenet/demos/account/useAccountEngine.tsx +++ b/app/vibenet/demos/account/useAccountEngine.tsx @@ -28,6 +28,7 @@ import { k1Authenticator, parseReceiptFields, privateKeyToAccount, + serializeTransaction, type Signer, toEoaAccount, toHex, @@ -166,6 +167,12 @@ export type ComposePayer = { * `payerAuth` for a hosted payer service to co-sign out of band. */ localSigner?: Signer; + /** + * Gets `payerAuth` from a hosted payer (`payer_signTransaction`) for the + * transaction serialized without `sender_auth`, before the sender signs, so + * a payer rejection never costs the user a signature. + */ + authorize?: (unsigned: Hex) => Promise; }; export type ComposeOptions = { @@ -575,6 +582,8 @@ function useAccountEngineCore() { ...(metadata ? { metadata } : {}), ...(payer ? { payer: payer.address } : {}), }; + if (payer?.authorize) + transaction.payerAuth = await payer.authorize(serializeTransaction({ ...transaction, senderAuth: '0x' })); const serialized = await account.signTransaction( transaction, payer?.localSigner ? { payer: { account: payer.localSigner, address: payer.address } } : undefined, diff --git a/vendor/aa/entry.mjs b/vendor/aa/entry.mjs index e64ca71..8ab9c07 100644 --- a/vendor/aa/entry.mjs +++ b/vendor/aa/entry.mjs @@ -43,6 +43,7 @@ export { parseReceiptFields, prepareTransactionRequest, sendTransaction, + serializeTransaction, toEoaAccount, txValueCost, } from '../../../viem/src/eip8130/index.ts' @@ -50,9 +51,11 @@ export { // ERC-8168 (payer / sponsorship) export { createPayerClient, + encodePayment, encodeTokenTransfer, isDeclinedOffer, isTokenOffer, parsePayerError, + requiredPaymentAmount, selectPaymentOption, } from '../../../viem/src/eip8168/index.ts' diff --git a/vendor/aa/index.d.ts b/vendor/aa/index.d.ts index d861fc1..70d2f4d 100644 --- a/vendor/aa/index.d.ts +++ b/vendor/aa/index.d.ts @@ -223,6 +223,9 @@ export type SendTransactionParameters = Omit +/** RLP-serializes an EIP-8130 transaction; an empty `senderAuth` gives the bytes a payer can authorize before the sender signs. */ +export function serializeTransaction(transaction: TransactionSerializable8130): TransactionSerialized8130 + export type ReceiptFields = { payer?: Address | undefined phaseStatuses?: readonly Hex[] | undefined @@ -254,13 +257,15 @@ export type BalanceLimit = { decimals?: number | undefined } +export type PayerProvider = { name: string; icon?: string | undefined } + export type PayerBalance = { kind: 'sponsorship' | 'credit' limits: readonly BalanceLimit[] validFor?: number | undefined payer?: Address | undefined endpoint?: string | undefined - name?: string | undefined + provider?: PayerProvider | undefined } export type PayerGasEstimate = { gasLimit: Hex; maxFeePerGas: Hex; maxPriorityFeePerGas: Hex } @@ -272,13 +277,16 @@ export type PayerConditions = { maxCost?: Hex | undefined } -export type PayerProvider = { name: string; icon?: string | undefined } +export type PayerCostDiagnostic = { estimated: Hex; limit: Hex } export type BaseOffer = { + /** 20-byte payer address, or `0x00` for an open payer. */ payer: Address endpoint?: string | undefined - /** Optional methods beyond `payer_getTerms` / `payer_sendTransaction` (e.g. `payer_signTransaction`). */ - methods?: readonly string[] | undefined + /** Co-sign methods for this offer; absent means `["payer_sendTransaction"]`. */ + methods?: readonly ('payer_sendTransaction' | 'payer_signTransaction')[] | undefined + /** Preauthorized `payer_auth`, when the payer signed ahead of time. */ + payerAuth?: Hex | undefined ttl: number conditions?: PayerConditions | undefined provider?: PayerProvider | undefined @@ -294,38 +302,44 @@ export type SponsoredOfferDeclined = { code: string reason?: string | undefined balance?: PayerBalance | undefined - gas?: { estimatedCost: Hex; maxCost: Hex } | undefined + /** Only for `COST_EXCEEDS_LIMIT`. */ + cost?: PayerCostDiagnostic | undefined provider?: PayerProvider | undefined } export type SponsoredOffer = SponsoredOfferSelectable | SponsoredOfferDeclined export type TokenChoice = { - token: Address + /** ERC-20 address, or `"native"` for a value-transfer payment. */ + token: Address | 'native' symbol: string decimals: number - /** Phase-0 amount, quoted against the terms' `gasEstimate`. */ - paymentAmount: Hex + /** The required amount at the terms' `gasEstimate`; present exactly when `gasEstimate` is. */ + paymentAmount?: Hex | undefined feeRecipient?: Address | undefined - rate: { numerator: Hex; denominator: Hex } + /** WAD: token atomic units per 1e18 wei. Phase 0 covers `ceil(max_cost × rate / 1e18)`. */ + rate: Hex + paymentGas?: Hex | undefined + /** One whole token's fiat price, as a WAD. */ fiatRate?: Hex | undefined refund?: { window: number } | undefined } -export type TokenPaymentOffer = BaseOffer & { +export type TokenOffer = BaseOffer & { kind: 'token' tokens: readonly TokenChoice[] paymentMode?: 'transfer' | 'any' | undefined } -export type PaymentOption = SponsoredOffer | TokenPaymentOffer +export type PaymentOption = SponsoredOffer | TokenOffer export type GetTermsParameters = { chainId: Hex from: Address calls: readonly PayerRpcCall[] + /** Gas for the user calls only; excludes phase 0. */ gasLimit?: Hex | undefined - preferredTokens?: readonly Address[] | undefined + preferredTokens?: readonly (Address | 'native')[] | undefined fiatCurrency?: string | undefined context?: Record | undefined } @@ -336,25 +350,25 @@ export type GetTermsReturnType = { fiatCurrency?: string | undefined } -export type TokenCharged = { token: Address; amount: Hex } - export type PayerSendTransactionParameters = { - signedTransaction: Hex + /** The sender-signed transaction, `payer_auth` empty. */ + transaction: Hex context?: Record | undefined } -export type PayerSendTransactionReturnType = { transactionHash: Hex; tokenCharged?: TokenCharged | undefined } +export type PayerSendTransactionReturnType = { transactionHash: Hex } export type PayerSignTransactionParameters = { - signedTransaction: Hex + /** The built transaction, with or without `sender_auth`. */ + transaction: Hex context?: Record | undefined } -export type PayerSignTransactionReturnType = { signedTransaction: Hex; tokenCharged?: TokenCharged | undefined } +export type PayerSignTransactionReturnType = { payerAuth: Hex } export type GetSponsorshipBalanceParameters = { from: Address chainId?: Hex | undefined payer?: Address | undefined endpoint?: string | undefined - kind?: readonly ('sponsorship' | 'credit')[] | undefined + kinds?: readonly ('sponsorship' | 'credit')[] | undefined context?: Record | undefined } export type GetSponsorshipBalanceReturnType = { balances: readonly PayerBalance[]; ttl: number } @@ -370,25 +384,29 @@ export type PayerClient = { export function createPayerClient(parameters: { url?: string | undefined; transport?: any }): PayerClient export type SelectPaymentOptionReturnType = { - option: SponsoredOfferSelectable | TokenPaymentOffer + option: SponsoredOfferSelectable | TokenOffer tokenChoice?: TokenChoice | undefined } /** Picks one selectable offer: the matching token offer when `token` is set, else sponsorship first. */ export function selectPaymentOption( terms: GetTermsReturnType, - parameters?: { token?: Address | undefined }, + parameters?: { token?: Address | 'native' | undefined }, ): SelectPaymentOptionReturnType -export function isTokenOffer(option: PaymentOption): option is TokenPaymentOffer +export function isTokenOffer(option: PaymentOption): option is TokenOffer export function isDeclinedOffer(option: PaymentOption): option is SponsoredOfferDeclined /** Builds the phase-0 `IERC20.transfer(to, amount)` call. */ export function encodeTokenTransfer(parameters: { token: Address; to: Address; amount: bigint }): AaCall +/** Phase-0 payment: an ERC-20 transfer, or a value transfer for `"native"`. */ +export function encodePayment(parameters: { token: Address | 'native'; to: Address; amount: bigint }): AaCall +/** `ceil(maxCost × rate / 1e18)`. */ +export function requiredPaymentAmount(parameters: { maxCost: bigint; rate: bigint }): bigint export type PayerRequote = { - token: Address + token: Address | 'native' paymentAmount: Hex feeRecipient?: Address | undefined - rate?: { numerator: Hex; denominator: Hex } | undefined + rate?: Hex | undefined ttl?: number | undefined } @@ -397,11 +415,17 @@ export type PayerRejectedData = { code: string reason?: string | undefined balance?: PayerBalance | undefined - gas?: { estimatedCost: Hex; maxCost: Hex } | undefined + /** `COST_EXCEEDS_LIMIT`: the payer's estimate and per-tx ceiling. */ + cost?: PayerCostDiagnostic | undefined /** `GAS_TOO_LOW`: the smallest gas limit the calls need. */ minGasLimit?: Hex | undefined /** `PAYMENT_INSUFFICIENT`: the corrected phase-0 transfer. */ requote?: PayerRequote | undefined + /** `EXECUTION_REVERTED`: which phase reverted, and why. */ + revert?: { phase: number; reason?: string | undefined; data?: Hex | undefined } | undefined + /** `SENDER_BALANCE_INSUFFICIENT`. */ + shortfall?: { token: Address | 'native'; required: Hex; available: Hex } | undefined + options?: readonly PaymentOption[] | undefined } /** Extracts {@link PayerRejectedData} from a thrown payer error; `undefined` for other errors. */ diff --git a/vendor/aa/index.js b/vendor/aa/index.js index 0ee1f3b..0a89df3 100644 --- a/vendor/aa/index.js +++ b/vendor/aa/index.js @@ -18776,6 +18776,16 @@ function createPayerClient(parameters) { init_abis(); init_base(); init_encodeFunctionData(); +function requiredPaymentAmount(parameters) { + const { maxCost, rate } = parameters; + return (maxCost * rate + 10n ** 18n - 1n) / 10n ** 18n; +} +function encodePayment(parameters) { + const { token, to, amount } = parameters; + if (token === "native") + return { to, value: amount, data: "0x" }; + return encodeTokenTransfer({ token, to, amount }); +} function encodeTokenTransfer(parameters) { return { to: parameters.token, @@ -18847,8 +18857,10 @@ export { txValueCost, toHex, toEoaAccount, + serializeTransaction4 as serializeTransaction, sendTransaction, selectPaymentOption, + requiredPaymentAmount, privateKeyToAccount, prepareTransactionRequest2 as prepareTransactionRequest, parseUnits2 as parseUnits, @@ -18868,6 +18880,7 @@ export { generatePrivateKey, estimateGas2 as estimateGas, encodeTokenTransfer, + encodePayment, encodeFunctionData, encodeAbiParameters, decodeAbiParameters,