diff --git a/src/acpAgent.ts b/src/acpAgent.ts index a5428d5..c3c3326 100644 --- a/src/acpAgent.ts +++ b/src/acpAgent.ts @@ -46,9 +46,10 @@ import type { AgentRole, BrowseAgentParams, JobRoomEntry, + SupportedStreams, TransportContext, } from "./events/types.js"; -import { SseTransport } from "./events/sseTransport.js"; +import { DEFAULT_STREAMS, SseTransport } from "./events/sseTransport.js"; export type EntryHandler = ( session: JobSession, @@ -250,16 +251,19 @@ export class AcpAgent { contractAddresses: this.client.getContractAddresses(), providerSupportedChainIds: providerChainIds, client: this.client, - signMessage: (chainId: number, msg: string) => { + signTypedData: (chainId: number, typedData: unknown) => { if (this.client instanceof EvmAcpClient) { - return this.client.getProvider().signMessage(chainId, msg); + return this.client.getProvider().signTypedData(chainId, typedData); } - throw new Error("signMessage is not supported for this provider"); + throw new Error("signTypedData is not supported for this provider"); }, }; } - async start(onConnected?: () => void): Promise { + async start( + onConnected?: () => void, + streams: SupportedStreams[] = DEFAULT_STREAMS + ): Promise { if (this.started) { throw new Error("Agent already started. Call stop() first."); } @@ -269,7 +273,7 @@ export class AcpAgent { this.transport.onEntry((entry) => this.dispatch(entry).catch(console.error) ); - await this.transport.connect(onConnected); + await this.transport.connect(onConnected, streams); await this.hydrateSessions(); } diff --git a/src/core/agentAuth.ts b/src/core/agentAuth.ts new file mode 100644 index 0000000..4b6dc23 --- /dev/null +++ b/src/core/agentAuth.ts @@ -0,0 +1,34 @@ +import type { TypedDataDefinition } from "viem"; + +export const AGENT_AUTH_DOMAIN_NAME = "ACP"; +export const AGENT_AUTH_DOMAIN_VERSION = "1"; +export const AGENT_AUTH_PRIMARY_TYPE = "AgentAuth" as const; + +export const AGENT_AUTH_TYPES = { + AgentAuth: [ + { name: "wallet", type: "address" }, + { name: "chainId", type: "uint256" }, + { name: "issuedAt", type: "uint256" }, + ], +} as const; + +export function buildAgentAuthTypedData(params: { + wallet: string; + chainId: number; + issuedAt: number; +}): TypedDataDefinition { + return { + domain: { + name: AGENT_AUTH_DOMAIN_NAME, + version: AGENT_AUTH_DOMAIN_VERSION, + chainId: params.chainId, + }, + types: AGENT_AUTH_TYPES, + primaryType: AGENT_AUTH_PRIMARY_TYPE, + message: { + wallet: params.wallet, + chainId: BigInt(params.chainId), + issuedAt: BigInt(params.issuedAt), + }, + }; +} diff --git a/src/core/approvalGate.ts b/src/core/approvalGate.ts new file mode 100644 index 0000000..237a2a0 --- /dev/null +++ b/src/core/approvalGate.ts @@ -0,0 +1,91 @@ +import { BaseError } from "viem"; +import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./constants.js"; + +export type ApprovalEvent = { + kind: "approval"; + approvalId: string; + status: "approved" | "rejected"; + result?: unknown; + reason?: string; + timestamp: number; +}; + +type Pending = { + resolve: (result: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType | null; +}; + +const pending = new Map(); + +export class ApprovalRequiredError extends BaseError { + readonly code = "APPROVAL_REQUIRED"; + constructor( + readonly approvalId: string, + readonly approvalUrl: string, + detail: string + ) { + super(detail, { + details: `approvalId=${approvalId} approvalUrl=${approvalUrl}`, + }); + this.name = "ApprovalRequiredError"; + } +} + +export class ApprovalRejectedError extends Error { + constructor(readonly approvalId: string, reason?: string) { + super(reason ?? `Approval ${approvalId} was rejected`); + this.name = "ApprovalRejectedError"; + } +} + +export class ApprovalTimeoutError extends Error { + constructor(readonly approvalId: string, timeoutMs: number) { + super(`Approval ${approvalId} timed out after ${timeoutMs}ms`); + this.name = "ApprovalTimeoutError"; + } +} + +export function awaitApproval( + approvalId: string, + opts: { timeoutMs?: number } = {} +): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + const existing = pending.get(approvalId); + if (existing) { + reject(new Error(`Approval ${approvalId} is already being awaited`)); + return; + } + + const timer = setTimeout(() => { + pending.delete(approvalId); + reject(new ApprovalTimeoutError(approvalId, timeoutMs)); + }, timeoutMs); + + pending.set(approvalId, { + resolve: (result) => resolve(result as T), + reject, + timer, + }); + }); +} + +export function resolveApproval( + approvalId: string, + status: "approved" | "rejected", + result?: unknown, + reason?: string +): boolean { + const entry = pending.get(approvalId); + if (!entry) return false; + pending.delete(approvalId); + if (entry.timer) clearTimeout(entry.timer); + if (status === "approved") { + entry.resolve(result); + } else { + entry.reject(new ApprovalRejectedError(approvalId, reason)); + } + return true; +} diff --git a/src/core/constants.ts b/src/core/constants.ts index 2f4fd7d..efeebec 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -95,3 +95,8 @@ export const SUPPORTED_CHAINS = [ export const MIN_SLA_MINS = 5; export const BUFFER_SECONDS = 30; + +export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60_000; + +export const ALCHEMY_SIGNING_CONTRACT = + "0x69007702764179f14F51cdce752f4f775d74E139"; diff --git a/src/events/acpHttpClient.ts b/src/events/acpHttpClient.ts index 58a4f74..3b3e479 100644 --- a/src/events/acpHttpClient.ts +++ b/src/events/acpHttpClient.ts @@ -1,5 +1,7 @@ +import type { Address } from "viem"; import type { TransportContext } from "./types.js"; import { ACP_SERVER_URL } from "../core/constants.js"; +import { buildAgentAuthTypedData } from "../core/agentAuth.js"; export type AcpHttpClientOptions = { serverUrl?: string; @@ -32,9 +34,17 @@ export class AcpHttpClient { if (!this.ctx) throw new Error("Transport context not set"); const chainId = this.ctx.providerSupportedChainIds[0]; + if (chainId == null) { + throw new Error("No provider-supported chain available for auth"); + } - const message = `acp-auth:${Date.now()}`; - const signature = await this.ctx.signMessage(chainId!, message); + const issuedAt = Date.now(); + const typedData = buildAgentAuthTypedData({ + wallet: this.ctx.agentAddress as Address, + chainId, + issuedAt, + }); + const signature = await this.ctx.signTypedData(chainId, typedData); const res = await fetch(`${this.serverUrl}/auth/agent`, { method: "POST", @@ -42,7 +52,7 @@ export class AcpHttpClient { body: JSON.stringify({ walletAddress: this.ctx.agentAddress, signature, - message, + issuedAt, chainId, }), }); diff --git a/src/events/sseTransport.ts b/src/events/sseTransport.ts index 1989394..6867f7e 100644 --- a/src/events/sseTransport.ts +++ b/src/events/sseTransport.ts @@ -1,11 +1,32 @@ import { EventSource } from "eventsource"; -import type { AcpChatTransport, JobRoomEntry } from "./types.js"; +import type { + AcpChatTransport, + SupportedStreams, + JobRoomEntry, +} from "./types.js"; import { AcpHttpClient, type AcpHttpClientOptions } from "./acpHttpClient.js"; +import { resolveApproval, type ApprovalEvent } from "../core/approvalGate.js"; + +export const STREAMS = { + CHAT: "chat", + WALLET: "wallet", +} as const; + +export const DEFAULT_STREAMS: SupportedStreams[] = [ + STREAMS.CHAT, + STREAMS.WALLET, +]; + +const STREAM_PATHS: Record = { + [STREAMS.CHAT]: "/chats/stream", + [STREAMS.WALLET]: "/wallets/stream", +}; export type SseTransportOptions = AcpHttpClientOptions; export class SseTransport extends AcpHttpClient implements AcpChatTransport { private eventSource: EventSource | null = null; + private walletEventSource: EventSource | null = null; private entryHandler: ((entry: JobRoomEntry) => void) | null = null; private lastEventTimestamp: number | null = null; private seenEntries = new Set(); @@ -18,60 +39,88 @@ export class SseTransport extends AcpHttpClient implements AcpChatTransport { // Lifecycle // ------------------------------------------------------------------------- - async connect(onConnected?: () => void): Promise { + async connect( + onConnected?: () => void, + streams: SupportedStreams[] = DEFAULT_STREAMS + ): Promise { await this.ensureAuthenticated(); - this.eventSource = new EventSource(`${this.serverUrl}/chats/stream`, { - fetch: async (url, init) => { - await this.refreshTokenIfNeeded(); - return fetch(url, { - ...init, - headers: { - ...(init?.headers as Record), - Authorization: `Bearer ${this.token}`, - "x-supported-chains": JSON.stringify( - this.ctx?.providerSupportedChainIds ?? [] - ), - }, - }); - }, - }); - - await new Promise((resolve, reject) => { - this.eventSource!.onopen = () => resolve(); - this.eventSource!.onerror = (err) => { - if (this.eventSource!.readyState === EventSource.CONNECTING) return; - reject(err); - }; - }); - - onConnected?.(); + const invalidStreams = streams.filter( + (stream) => !DEFAULT_STREAMS.includes(stream) + ); + if (invalidStreams.length > 0) { + throw new Error(`Unsupported stream: ${invalidStreams}`); + } - this.eventSource.onmessage = (event) => { - if (!event.data) return; + let chatStream: EventSource | null = null; + let walletStream: EventSource | null = null; + try { + [chatStream, walletStream] = await Promise.all([ + streams.includes(STREAMS.CHAT) + ? this.openStream(STREAM_PATHS[STREAMS.CHAT]) + : Promise.resolve(null), + streams.includes(STREAMS.WALLET) + ? this.openStream(STREAM_PATHS[STREAMS.WALLET]) + : Promise.resolve(null), + ]); + } catch (err) { + chatStream?.close(); + walletStream?.close(); + throw err; + } - let entry: JobRoomEntry; - try { - entry = JSON.parse(event.data); - } catch { - return; - } + this.eventSource = chatStream; + this.walletEventSource = walletStream; - const key = `${entry.timestamp}:${entry.kind}:${ - "from" in entry ? entry.from : "" - }:${"content" in entry ? entry.content : (entry as any).event?.type}`; - if (this.seenEntries.has(key)) return; - this.seenEntries.add(key); + onConnected?.(); - this.lastEventTimestamp = Math.max( - this.lastEventTimestamp ?? 0, - entry.timestamp - ); + if (this.eventSource) { + this.eventSource.onmessage = (event) => { + if (!event.data) return; + + let entry: JobRoomEntry; + try { + entry = JSON.parse(event.data); + } catch { + return; + } + + const key = `${entry.timestamp}:${entry.kind}:${ + "from" in entry ? entry.from : "" + }:${"content" in entry ? entry.content : (entry as any).event?.type}`; + if (this.seenEntries.has(key)) return; + this.seenEntries.add(key); + + this.lastEventTimestamp = Math.max( + this.lastEventTimestamp ?? 0, + entry.timestamp + ); + + if (this.entryHandler) { + this.entryHandler(entry); + } + }; + } - if (this.entryHandler) { - this.entryHandler(entry); - } - }; + if (this.walletEventSource) { + this.walletEventSource.onmessage = (event) => { + if (!event.data) return; + + let entry: ApprovalEvent; + try { + entry = JSON.parse(event.data); + } catch { + return; + } + + resolveApproval( + entry.approvalId, + entry.status, + entry.result, + entry.reason + ); + }; + } } async disconnect(): Promise { @@ -79,6 +128,10 @@ export class SseTransport extends AcpHttpClient implements AcpChatTransport { this.eventSource.close(); this.eventSource = null; } + if (this.walletEventSource) { + this.walletEventSource.close(); + this.walletEventSource = null; + } this.ctx = null; this.entryHandler = null; this.lastEventTimestamp = null; @@ -143,4 +196,31 @@ export class SseTransport extends AcpHttpClient implements AcpChatTransport { const data = (await res.json()) as { entries: JobRoomEntry[] }; return data.entries; } + + private openStream(path: string): Promise { + const source = new EventSource(`${this.serverUrl}${path}`, { + fetch: async (url, init) => { + await this.refreshTokenIfNeeded(); + return fetch(url, { + ...init, + headers: { + ...(init?.headers as Record), + Authorization: `Bearer ${this.token}`, + "x-supported-chains": JSON.stringify( + this.ctx?.providerSupportedChainIds ?? [] + ), + }, + }); + }, + }); + + return new Promise((resolve, reject) => { + source.onopen = () => resolve(source); + source.onerror = (err) => { + if (source.readyState === EventSource.CONNECTING) return; + source.close(); + reject(err); + }; + }); + } } diff --git a/src/events/types.ts b/src/events/types.ts index 5fffcd3..6a50536 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -137,7 +137,7 @@ export type TransportContext = { contractAddresses: Record; providerSupportedChainIds: number[]; client: AcpClient; - signMessage: (chainId: number, message: string) => Promise; + signTypedData: (chainId: number, typedData: unknown) => Promise; }; // --------------------------------------------------------------------------- @@ -201,8 +201,13 @@ export type OffChainSubscription = { // Transport interfaces // --------------------------------------------------------------------------- +export type SupportedStreams = "chat" | "wallet"; + export interface AcpChatTransport { - connect(onConnected?: () => void): Promise; + connect( + onConnected?: () => void, + streams?: SupportedStreams[] + ): Promise; disconnect(): Promise; onEntry(handler: (entry: JobRoomEntry) => void): void; diff --git a/src/index.ts b/src/index.ts index f017167..7c800c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export * from "./core/acpAbi.js"; export * from "./core/chains.js"; export * from "./core/constants.js"; export * from "./core/assetToken.js"; +export * from "./core/approvalGate.js"; // Provider interfaces & adapters export * from "./providers/types.js"; @@ -24,7 +25,7 @@ export * from "./providers/solana/solanaProviderAdapter.js"; // Transport & API export { AcpHttpClient } from "./events/acpHttpClient.js"; export { AcpApiClient } from "./events/acpApiClient.js"; -export { SseTransport } from "./events/sseTransport.js"; +export { SseTransport, STREAMS } from "./events/sseTransport.js"; // Public enums export { AcpJobStatus } from "./events/types.js"; @@ -57,6 +58,7 @@ export type { AcpAgentSubscription, BrowseAgentParams, FundIntent, + SupportedStreams, } from "./events/types.js"; // Utilities diff --git a/src/providers/evm/privyAlchemyEvmProviderAdapter.ts b/src/providers/evm/privyAlchemyEvmProviderAdapter.ts index 440b780..e346916 100644 --- a/src/providers/evm/privyAlchemyEvmProviderAdapter.ts +++ b/src/providers/evm/privyAlchemyEvmProviderAdapter.ts @@ -1,8 +1,10 @@ import { + BaseError, concatHex, createWalletClient, http, - LocalAccount, + type LocalAccount, + numberToHex, pad, toHex, TypedDataDefinition, @@ -22,12 +24,16 @@ import { getLogs, getBlockNumber, } from "viem/actions"; -import { createEvmNetworkContext, EVM_MAINNET_CHAINS } from "../../core/chains.js"; +import { + createEvmNetworkContext, + EVM_MAINNET_CHAINS, +} from "../../core/chains.js"; import type { GetLogsParams, IEvmProviderAdapter, ReadContractParams, } from "../types.js"; +import type { RemoteSigner, SignUserOperationParams } from "./types.js"; import { formatRequestForAuthorizationSignature, generateAuthorizationSignature, @@ -38,8 +44,16 @@ import { type SmartWalletClient, alchemyWalletTransport, } from "@alchemy/wallet-apis"; -import { ACP_SERVER_URL, PRIVY_APP_ID } from "../../core/constants.js"; +import { + ACP_SERVER_URL, + ALCHEMY_SIGNING_CONTRACT, + PRIVY_APP_ID, +} from "../../core/constants.js"; import { ProviderAuthClient } from "../providerAuthClient.js"; +import { + ApprovalRequiredError, + awaitApproval, +} from "../../core/approvalGate.js"; export type SignFn = (payload: Uint8Array) => Promise; @@ -97,9 +111,29 @@ async function serverPost( }); const data = await res.json(); if (!res.ok) { - throw new Error( - data?.detail ?? data?.error ?? `Server error ${res.status}` - ); + const payload = data?.code ? data : data?.message; + if (res.status === 403 && payload?.code === "APPROVAL_REQUIRED") { + const approvalId = payload.details?.approvalId ?? ""; + const approvalUrl = payload.details?.approvalUrl ?? ""; + const detail = payload.detail ?? "Manual approval required"; + console.error( + `[PrivyAlchemy] Manual approval required.\n` + + ` Approve at: ${approvalUrl}\n` + + ` Approval ID: ${approvalId}\n` + + ` Reason: ${detail}` + ); + throw new ApprovalRequiredError(approvalId, approvalUrl, detail); + } + + const msg = + data?.detail ?? + data?.error ?? + data?.shortMessage ?? + `Server error ${res.status}`; + + throw new BaseError(msg, { + details: data?.details, + }); } return data as T; } @@ -130,11 +164,24 @@ async function signedServerCall( ); } - return serverPost( - executePath, - { ...payload, authorizationSignature }, - serverUrl - ); + try { + return await serverPost( + executePath, + { ...payload, authorizationSignature }, + serverUrl + ); + } catch (err) { + if (err instanceof ApprovalRequiredError) { + const result = await awaitApproval(err.approvalId); + if (result === undefined) { + throw new Error( + `Approval ${err.approvalId} resolved as approved but no result payload was provided` + ); + } + return result; + } + throw err; + } } function replaceBigInts(obj: T, replacer: (v: bigint) => unknown): T { @@ -155,7 +202,7 @@ function createRemoteSigner(params: { signFn: SignFn | undefined; serverUrl: string; privyAppId: string; -}): LocalAccount<"privy-remote"> { +}): RemoteSigner { const { address, walletId, signerPrivateKey, signFn, serverUrl, privyAppId } = params; @@ -189,7 +236,7 @@ function createRemoteSigner(params: { const TTypedData extends | Record | Record, - TPrimaryType extends keyof TTypedData | "EIP712Domain" = keyof TTypedData, + TPrimaryType extends keyof TTypedData | "EIP712Domain" = keyof TTypedData >( typedDataDef: TypedDataDefinition ) => { @@ -226,7 +273,11 @@ function createRemoteSigner(params: { // Map viem tx fields to Privy's snake_case format const TX_TYPE_MAP: Record = { - legacy: 0, eip2930: 1, eip1559: 2, eip4844: 3, eip7702: 4, + legacy: 0, + eip2930: 1, + eip1559: 2, + eip4844: 3, + eip7702: 4, }; const privyTx: Record = { ...(raw.to != null ? { to: raw.to } : {}), @@ -236,14 +287,19 @@ function createRemoteSigner(params: { ...(raw.nonce != null ? { nonce: raw.nonce } : {}), ...(raw.gas != null ? { gas_limit: raw.gas } : {}), ...(raw.gasPrice != null ? { gas_price: raw.gasPrice } : {}), - ...(raw.maxFeePerGas != null ? { max_fee_per_gas: raw.maxFeePerGas } : {}), - ...(raw.maxPriorityFeePerGas != null ? { max_priority_fee_per_gas: raw.maxPriorityFeePerGas } : {}), + ...(raw.maxFeePerGas != null + ? { max_fee_per_gas: raw.maxFeePerGas } + : {}), + ...(raw.maxPriorityFeePerGas != null + ? { max_priority_fee_per_gas: raw.maxPriorityFeePerGas } + : {}), ...(raw.chainId != null ? { chain_id: raw.chainId } : {}), }; if (raw.type != null) { - privyTx.type = typeof raw.type === "string" - ? (TX_TYPE_MAP[raw.type] ?? Number(raw.type)) - : raw.type; + privyTx.type = + typeof raw.type === "string" + ? TX_TYPE_MAP[raw.type] ?? Number(raw.type) + : raw.type; } const rpcBody = { @@ -264,6 +320,65 @@ function createRemoteSigner(params: { return result.signedTransaction; }, + signUserOperation: async ({ + chainId, + contract, + userOperation, + }: SignUserOperationParams) => { + const rpcBody = { + method: "eth_signUserOperation" as const, + chain_type: "ethereum" as const, + params: { + chain_id: chainId, + contract, + user_operation: { + sender: userOperation.sender, + nonce: numberToHex(userOperation.nonce), + call_data: userOperation.callData, + call_gas_limit: numberToHex(userOperation.callGasLimit), + verification_gas_limit: numberToHex( + userOperation.verificationGasLimit + ), + pre_verification_gas: numberToHex(userOperation.preVerificationGas), + max_fee_per_gas: numberToHex(userOperation.maxFeePerGas), + max_priority_fee_per_gas: numberToHex( + userOperation.maxPriorityFeePerGas + ), + paymaster: userOperation.paymaster, + paymaster_data: userOperation.paymasterData, + paymaster_verification_gas_limit: + userOperation.paymasterVerificationGasLimit != null + ? numberToHex(userOperation.paymasterVerificationGasLimit) + : undefined, + paymaster_post_op_gas_limit: + userOperation.paymasterPostOpGasLimit != null + ? numberToHex(userOperation.paymasterPostOpGasLimit) + : undefined, + }, + }, + }; + const result = await signedServerCall<{ + signature: Hex; + encoding: "hex"; + }>( + "/wallets/sign-user-operation", + walletId, + rpcBody, + { + walletAddress: address, + walletId, + chainId, + contract, + userOperation: replaceBigInts(userOperation, toHex), + }, + signerPrivateKey, + serverUrl, + privyAppId, + signFn + ); + return result.signature; + }, + signAuthorization: async (unsignedAuth) => { const contract = (unsignedAuth as any).contractAddress ?? (unsignedAuth as any).address; @@ -329,13 +444,13 @@ export class PrivyAlchemyEvmProviderAdapter implements IEvmProviderAdapter { public readonly providerName: string = "Privy Alchemy"; public readonly address: Address; private readonly chainClients: Map; - private readonly signer: LocalAccount<"privy-remote">; + private readonly signer: RemoteSigner; private readonly builderCodeSuffix: Hex | undefined; private constructor( address: Address, chainClients: Map, - signer: LocalAccount<"privy-remote">, + signer: RemoteSigner, builderCode?: string ) { this.address = address; @@ -372,7 +487,7 @@ export class PrivyAlchemyEvmProviderAdapter implements IEvmProviderAdapter { const authClient = new ProviderAuthClient({ serverUrl, walletAddress: params.walletAddress, - signMessage: (msg) => signer.signMessage({ message: msg }), + signTypedData: (typedData) => signer.signTypedData(typedData as any), chainId: chains[0]!.id, }); @@ -401,7 +516,7 @@ export class PrivyAlchemyEvmProviderAdapter implements IEvmProviderAdapter { }); const walletClient = createWalletClient({ - account: signer, + account: signer as LocalAccount<"privy-remote">, chain, transport: http(`${serverUrl}/wallets/alchemy-rpc/${chain.id}`, { fetchFn: authedFetch, @@ -468,7 +583,8 @@ export class PrivyAlchemyEvmProviderAdapter implements IEvmProviderAdapter { ): Promise
{ const { smartWalletClient } = this.getClients(chainId); const suffix = this.builderCodeSuffix; - const { id } = await smartWalletClient.sendCalls({ + + const preparedCall = await smartWalletClient.prepareCalls({ calls: _calls.map((call) => { const value = call.value ?? 0n; return { @@ -486,6 +602,19 @@ export class PrivyAlchemyEvmProviderAdapter implements IEvmProviderAdapter { }, }); + const signature = await this.signer.signUserOperation({ + chainId, + contract: ALCHEMY_SIGNING_CONTRACT, + userOperation: preparedCall.data, + }); + + const { id } = await smartWalletClient.sendPreparedCalls({ + type: preparedCall.type, + data: preparedCall.data, + chainId: preparedCall.chainId, + signature: { type: "secp256k1", data: signature }, + }); + const status = await smartWalletClient.waitForCallsStatus({ id }); if (!status.receipts?.[0]?.transactionHash) { diff --git a/src/providers/evm/types.ts b/src/providers/evm/types.ts new file mode 100644 index 0000000..2d2b71e --- /dev/null +++ b/src/providers/evm/types.ts @@ -0,0 +1,26 @@ +import type { Address, Hex, LocalAccount } from "viem"; + +export type UserOperationV07 = { + sender: Address; + nonce: bigint; + callData: Hex; + callGasLimit: bigint; + verificationGasLimit: bigint; + preVerificationGas: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + paymaster?: Address; + paymasterData?: Hex; + paymasterVerificationGasLimit?: bigint; + paymasterPostOpGasLimit?: bigint; +}; + +export type SignUserOperationParams = { + chainId: number; + contract: Address; + userOperation: UserOperationV07; +}; + +export type RemoteSigner = LocalAccount<"privy-remote"> & { + signUserOperation(params: SignUserOperationParams): Promise; +}; diff --git a/src/providers/providerAuthClient.ts b/src/providers/providerAuthClient.ts index 6668f11..bd3a474 100644 --- a/src/providers/providerAuthClient.ts +++ b/src/providers/providerAuthClient.ts @@ -1,9 +1,11 @@ +import type { Address } from "viem"; import { ACP_SERVER_URL } from "../core/constants.js"; +import { buildAgentAuthTypedData } from "../core/agentAuth.js"; export interface ProviderAuthClientOptions { serverUrl?: string; walletAddress: string; - signMessage: (message: string) => Promise; + signTypedData: (typedData: unknown) => Promise; chainId: number; } @@ -11,13 +13,13 @@ export class ProviderAuthClient { private token = ""; private readonly serverUrl: string; private readonly walletAddress: string; - private readonly _signMessage: (message: string) => Promise; + private readonly _signTypedData: (typedData: unknown) => Promise; private readonly chainId: number; constructor(opts: ProviderAuthClientOptions) { this.serverUrl = (opts.serverUrl ?? ACP_SERVER_URL).replace(/\/$/, ""); this.walletAddress = opts.walletAddress; - this._signMessage = opts.signMessage; + this._signTypedData = opts.signTypedData; this.chainId = opts.chainId; } @@ -29,8 +31,13 @@ export class ProviderAuthClient { } private async authenticate(): Promise { - const message = `acp-auth:${Date.now()}`; - const signature = await this._signMessage(message); + const issuedAt = Date.now(); + const typedData = buildAgentAuthTypedData({ + wallet: this.walletAddress, + chainId: this.chainId, + issuedAt, + }); + const signature = await this._signTypedData(typedData); const res = await fetch(`${this.serverUrl}/auth/agent`, { method: "POST", @@ -38,7 +45,7 @@ export class ProviderAuthClient { body: JSON.stringify({ walletAddress: this.walletAddress, signature, - message, + issuedAt, chainId: this.chainId, }), });