Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
async start(
onConnected?: () => void,
streams: SupportedStreams[] = DEFAULT_STREAMS
): Promise<void> {
if (this.started) {
throw new Error("Agent already started. Call stop() first.");
}
Expand All @@ -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();
}
Expand Down
34 changes: 34 additions & 0 deletions src/core/agentAuth.ts
Original file line number Diff line number Diff line change
@@ -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),
},
};
}
91 changes: 91 additions & 0 deletions src/core/approvalGate.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | null;
};

const pending = new Map<string, Pending>();

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<T>(
approvalId: string,
opts: { timeoutMs?: number } = {}
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;

return new Promise<T>((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;
}
5 changes: 5 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
16 changes: 13 additions & 3 deletions src/events/acpHttpClient.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -32,17 +34,25 @@ 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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
walletAddress: this.ctx.agentAddress,
signature,
message,
issuedAt,
chainId,
}),
});
Expand Down
Loading