// ELYX SDK (single file). Generated from sdk/index.ts and types/domain.ts. Do not edit. // Usage: curl -o elyx.ts https:///sdk/elyx.ts /** * ELYX domain model. Elysium mainnet (EVM, chain id 99901). * Addresses are checksummed 0x hex. "signature" fields hold transaction hashes. * Framework-agnostic types shared by engines, server, SDK and UI. * USD values are integer cents (`UsdCents`). Token amounts are base-unit strings. */ export type Id = string; export type UsdCents = number; export type IsoDate = string; /* ------------------------------------------------------------------ */ /* Organizations */ /* ------------------------------------------------------------------ */ export type Role = "OWNER" | "ADMIN" | "FINANCE" | "OPERATOR" | "DEVELOPER" | "VIEWER"; export type PlanId = "FREE" | "PRO" | "BUILDER" | "ENTERPRISE"; export interface Organization { id: Id; name: string; plan: PlanId; executionsDisabled: boolean; createdAt: IsoDate; } export interface OrganizationMember { id: Id; organizationId: Id; name: string; role: Role; wallet: string; createdAt: IsoDate; } /* ------------------------------------------------------------------ */ /* Assets */ /* ------------------------------------------------------------------ */ export type AssetSymbol = "HYPE" | "WHYPE" | "USDC" | "KNTQ"; export interface TokenBalance { asset: AssetSymbol; /** Token contract address, or the native sentinel 0xEeee...EEeE for HYPE. */ token: string; /** Base units as decimal string. */ amount: string; decimals: number; usdValue: UsdCents; } /* ------------------------------------------------------------------ */ /* Accounts, vaults, agents */ /* ------------------------------------------------------------------ */ export type AccountType = "PERSONAL" | "AGENT" | "TEAM" | "TREASURY" | "DEVELOPER"; export type AccountStatus = "PENDING" | "ACTIVE" | "RESTRICTED" | "FROZEN"; /** An ELYX Account is a group of owners and a threshold. Each of its vaults is a Safe (1.3.0) owned by them. */ export interface Account { id: Id; organizationId: Id; name: string; type: AccountType; ownerMemberId: Id; /** Address of the account's first vault Safe (the primary vault). */ primaryVaultAddress: string; threshold: number; /** Safe owners. */ ownerWallets: string[]; status: AccountStatus; createSignature?: string; createdAt: IsoDate; } export type VaultType = "TRADING" | "PAYMENT" | "TREASURY" | "OPERATING" | "STRATEGY" | "CUSTOM"; export type VaultStatus = "ACTIVE" | "FROZEN"; /** A vault is a Safe on Elysium owned by the account's owners. Funds live here. */ export interface Vault { id: Id; organizationId: Id; accountId: Id; name: string; type: VaultType; description: string; /** Position within the account (0 = primary). */ vaultIndex: number; /** Safe address. */ address: string; /** Salt nonce used to create the Safe proxy (deterministic address). */ saltNonce: string; status: VaultStatus; authorizedAgentIds: Id[]; createdAt: IsoDate; } export interface VaultWithBalances extends Vault { balances: TokenBalance[]; totalUsd: UsdCents; } export type AgentKind = "TRADING" | "PAYMENT" | "RESEARCH" | "TREASURY" | "CUSTOM"; export type AgentStatus = "ACTIVE" | "PAUSED" | "OFFLINE" | "RESTRICTED"; export type AgentGlyph = "alpha" | "atlas" | "scout" | "orbit" | "prism"; /** State of the agent's executor key on the vault Safes (module limits enabled / removed). */ export type AgentChainStatus = "PENDING" | "ACTIVE" | "REVOKED"; export interface AgentObjective { id: Id; task: string; parameters: Record; constraints: string[]; } export interface Agent { id: Id; organizationId: Id; accountId: Id; name: string; kind: AgentKind; description: string; status: AgentStatus; chainStatus: AgentChainStatus; glyph: AgentGlyph; /** Address of the executor key derived for this agent. The secret is never stored. */ executor: string; primaryVaultId: Id; objectives: AgentObjective[]; lastExecutionAt?: IsoDate; createdAt: IsoDate; } export type SpendingPeriod = "DAY" | "WEEK" | "MONTH" | "ONE_TIME"; export type SpendingLimitStatus = "PENDING" | "ACTIVE" | "REMOVED"; /** On-chain limit in the ELYX spending limit module on the vault Safe. The hard ceiling under ELYX's policy engine. */ export interface SpendingLimit { id: Id; organizationId: Id; agentId: Id; vaultId: Id; asset: AssetSymbol; token: string; amount: string; decimals: number; period: SpendingPeriod; /** Safe address the limit lives on. */ address: string; destinations: string[]; status: SpendingLimitStatus; createdAt: IsoDate; } /* ------------------------------------------------------------------ */ /* Counterparties */ /* ------------------------------------------------------------------ */ export type CounterpartyType = "WALLET" | "BUSINESS" | "AGENT" | "PROTOCOL" | "API_PROVIDER" | "INTERNAL"; /** Organizational trust state. Never a universal safety claim. */ export type TrustState = "VERIFIED" | "APPROVED" | "UNVERIFIED" | "BLOCKED"; export interface Counterparty { id: Id; organizationId: Id; name: string; type: CounterpartyType; address: string; trust: TrustState; dailyLimit?: UsdCents; notes?: string; approvedFor: ActionType[]; createdAt: IsoDate; } /* ------------------------------------------------------------------ */ /* Intents */ /* ------------------------------------------------------------------ */ /** * SWAP swap from the agent's working capital via KyberSwap * PAYMENT pay a counterparty from a vault * TRANSFER move value from a vault to a counterparty or internal account * ALLOCATE move capital from a vault to the agent's working wallet * RETURN return capital from the agent's working wallet to its vault */ export type ActionType = "SWAP" | "PAYMENT" | "TRANSFER" | "ALLOCATE" | "RETURN"; export interface AgentIntent { id: Id; organizationId: Id; agentId: Id; vaultId: Id; action: ActionType; asset: AssetSymbol; outputAsset?: AssetSymbol; /** Token amount in base units (string, bigint-safe). */ amount: string; /** USD notional in cents, priced server-side at intent time. */ amountUsd: UsdCents; protocol?: string; counterpartyId?: Id; maxSlippageBps?: number; memo?: string; justification?: string; idempotencyKey: string; requestedAt: IsoDate; } /* ------------------------------------------------------------------ */ /* Policies */ /* ------------------------------------------------------------------ */ export type PolicyRule = | { kind: "DAILY_LIMIT"; amountUsd: UsdCents } | { kind: "MAX_TRANSACTION"; amountUsd: UsdCents } | { kind: "APPROVAL_THRESHOLD"; amountUsd: UsdCents } | { kind: "ALLOWED_ASSETS"; assets: AssetSymbol[] } | { kind: "BLOCKED_ASSETS"; assets: AssetSymbol[] } | { kind: "ALLOWED_PROTOCOLS"; protocols: string[] } | { kind: "BLOCKED_PROTOCOLS"; protocols: string[] } | { kind: "ALLOWED_COUNTERPARTIES"; counterpartyIds: Id[] } | { kind: "BLOCKED_COUNTERPARTIES"; counterpartyIds: Id[] } | { kind: "ALLOWED_ACTIONS"; actions: ActionType[] } | { kind: "TIME_WINDOW"; startHourUtc: number; endHourUtc: number; days: number[] } | { kind: "EXECUTION_FREQUENCY"; maxPerHour: number } | { kind: "MAX_SLIPPAGE"; bps: number } | { kind: "COUNTERPARTY_DAILY_LIMIT"; asset: AssetSymbol; amountUsd: UsdCents }; export type PolicyRuleKind = PolicyRule["kind"]; export interface PolicyScope { /** Empty arrays mean "applies to all" within the organization. */ agentIds: Id[]; vaultIds: Id[]; } export interface Policy { id: Id; organizationId: Id; name: string; rule: PolicyRule; scope: PolicyScope; enabled: boolean; priority: number; createdAt: IsoDate; updatedAt: IsoDate; } export type PolicyDecision = "ALLOW" | "BLOCK" | "REQUIRE_APPROVAL"; export type PolicyReason = | "EXECUTIONS_DISABLED" | "AGENT_NOT_ACTIVE" | "AGENT_NOT_ONCHAIN" | "AGENT_NOT_AUTHORIZED_FOR_VAULT" | "VAULT_FROZEN" | "INSUFFICIENT_BALANCE" | "DAILY_LIMIT_EXCEEDED" | "MAX_TX_AMOUNT" | "TRANSACTION_ABOVE_THRESHOLD" | "ASSET_NOT_ALLOWED" | "ASSET_BLOCKED" | "PROTOCOL_NOT_ALLOWED" | "PROTOCOL_BLOCKED" | "COUNTERPARTY_NOT_ALLOWED" | "COUNTERPARTY_BLOCKED" | "COUNTERPARTY_UNVERIFIED" | "ACTION_NOT_ALLOWED" | "OUTSIDE_TIME_WINDOW" | "FREQUENCY_EXCEEDED" | "SLIPPAGE_TOO_HIGH" | "COUNTERPARTY_DAILY_LIMIT_EXCEEDED" | "ONCHAIN_LIMIT_EXCEEDED" | "DESTINATION_NOT_ONCHAIN" | "COUNTERPARTY_ACTION_NOT_APPROVED"; export interface TriggeredPolicy { policyId: Id | null; reason: PolicyReason; outcome: Exclude; message: string; } export interface PolicyEvaluation { decision: PolicyDecision; triggeredPolicies: TriggeredPolicy[]; approvalRequirement: ApprovalRequirement | null; evaluatedPolicyIds: Id[]; evaluatedAt: IsoDate; } /* ------------------------------------------------------------------ */ /* Approvals */ /* ------------------------------------------------------------------ */ export type ApprovalType = | "TRANSACTION" | "POLICY_CHANGE" | "COUNTERPARTY" | "PROTOCOL" | "VAULT_WITHDRAWAL" | "AGENT_PERMISSION" | "LIMIT_INCREASE"; export type ApprovalStatus = "PENDING" | "APPROVED" | "REJECTED" | "EXPIRED" | "CANCELLED"; export type ApprovalRule = | { kind: "THRESHOLD"; required: number; of: Role[] } | { kind: "ALL_OF"; rules: ApprovalRule[] } | { kind: "ANY_OF"; rules: ApprovalRule[] }; export interface ApprovalRequirement { rule: ApprovalRule; approvalsRequired: number; } export interface ApprovalVote { memberId: Id; role: Role; wallet: string; decision: "APPROVE" | "REJECT"; /** Wallet signature (0x hex) over the canonical approval message, or the EIP-712 Safe transaction signature. */ signature: string; at: IsoDate; } export type RiskLevel = "LOW" | "MEDIUM" | "HIGH"; /** What a Safe configuration transaction (module limits) means for ELYX once it confirms. */ export type ConfigEffect = | { kind: "AGENT_ONBOARD"; agentId: Id } | { kind: "LIMITS_SYNC"; agentId: Id; addLimitIds: Id[]; removeLimitIds: Id[] } | { kind: "AGENT_REVOKE"; agentId: Id }; /** A Safe transaction awaiting owner signatures (EIP-712), executed once the threshold is met. */ export interface SafeTxDraft { safe: string; to: string; value: string; data: string; operation: 0 | 1; nonce: string; /** EIP-712 SafeTx hash the owners sign. */ safeTxHash: string; } /** How the approved action is carried out. */ export type ApprovalSettlement = | { kind: "OFFCHAIN" } | { kind: "SAFE_TX"; tx: SafeTxDraft; payload?: Record } | { kind: "SAFE_CONFIG"; tx: SafeTxDraft; spendingLimits: string[]; effect: ConfigEffect; payload?: Record }; export interface ApprovalRequest { id: Id; organizationId: Id; type: ApprovalType; agentId: Id; title: string; currentRule?: string; requestedChange: string; justification: string; executionId?: Id; requirement: ApprovalRequirement; votes: ApprovalVote[]; status: ApprovalStatus; risk: RiskLevel; riskSignals: RiskSignal[]; settlement: ApprovalSettlement; /** For policy/limit changes: the change to apply when approved. */ change?: PolicyChange; createdAt: IsoDate; expiresAt: IsoDate; resolvedAt?: IsoDate; } export type PolicyChange = | { kind: "UPDATE_RULE"; policyId: Id; rule: PolicyRule } | { kind: "CREATE_POLICY"; name: string; rule: PolicyRule; scope: PolicyScope } | { kind: "SET_COUNTERPARTY_TRUST"; counterpartyId: Id; trust: TrustState }; /* ------------------------------------------------------------------ */ /* Execution */ /* ------------------------------------------------------------------ */ export type ExecutionState = | "CREATED" | "VALIDATING" | "POLICY_CHECK" | "AWAITING_APPROVAL" | "APPROVED" | "SIMULATING" | "READY" | "SUBMITTED" | "CONFIRMED" | "SETTLED" | "BLOCKED" | "FAILED" | "CANCELLED"; export type TimelineStage = "REQUEST" | "POLICY" | "APPROVAL" | "EXECUTION" | "SETTLEMENT"; export type TimelineStatus = "PENDING" | "PASSED" | "BLOCKED" | "APPROVAL_REQUIRED" | "EXECUTING" | "SETTLED" | "FAILED" | "SKIPPED"; export interface ExecutionStep { state: ExecutionState; at: IsoDate; note?: string; } export interface Execution { id: Id; organizationId: Id; agentId: Id; vaultId: Id; intent: AgentIntent; state: ExecutionState; steps: ExecutionStep[]; policy?: PolicyEvaluation; approvalId?: Id; simulation?: SimulationResult; feeQuote?: SerializedFeeQuote; route?: ExecutionRoute; /** Transaction hash. */ signature?: string; blockNumber?: number; error?: string; /** True when funds may have moved (post-submission failure). */ fundsMayHaveMoved?: boolean; createdAt: IsoDate; updatedAt: IsoDate; } export type ExecutionRoute = "SPENDING_LIMIT" | "SAFE_TX" | "AGENT_WALLET" | "KYBER"; export interface SimulationResult { ok: boolean; /** Contracts called at the top level (names where known). */ programs: string[]; /** Token deltas for the source wallet, base units as strings. */ balanceChanges: { token: string; asset?: AssetSymbol; delta: string }[]; estimatedSlippageBps: number; /** Gas used in simulation. */ computeUnits: number; matchesIntent: boolean; mismatchReason?: string; logs: string[]; } export interface Settlement { id: Id; organizationId: Id; executionId: Id; agentId: Id; vaultId: Id; action: ActionType; asset: AssetSymbol; amount: string; amountUsd: UsdCents; feeMicroUsd: string; /** Transaction hash. */ signature: string; blockNumber: number; settledAt: IsoDate; } /* ------------------------------------------------------------------ */ /* Payments */ /* ------------------------------------------------------------------ */ export type PaymentStatus = "DRAFT" | "PENDING_APPROVAL" | "EXECUTING" | "SETTLED" | "FAILED" | "BLOCKED"; export interface Payment { id: Id; organizationId: Id; fromVaultId: Id; toCounterpartyId: Id; agentId: Id; asset: AssetSymbol; amount: string; amountUsd: UsdCents; memo?: string; reference?: string; invoice?: string; executionId?: Id; status: PaymentStatus; createdAt: IsoDate; } /* ------------------------------------------------------------------ */ /* Fees */ /* ------------------------------------------------------------------ */ export interface FeeQuote { networkFee: bigint; executionFee: bigint; providerFee: bigint; totalFee: bigint; } /** JSON-safe fee quote, USD micro-units (1e-6 USD). */ export interface SerializedFeeQuote { networkFee: string; executionFee: string; providerFee: string; totalFee: string; unit: "USD_MICRO"; scheduleId: string; } /* ------------------------------------------------------------------ */ /* Sessions, risk, activity */ /* ------------------------------------------------------------------ */ export interface AgentSession { id: Id; organizationId: Id; agentId: Id; vaultIds: Id[]; allowedActions: ActionType[]; spendingAllowanceUsd: UsdCents; spentUsd: UsdCents; rateLimitPerMinute: number; tokenPrefix: string; expiresAt: IsoDate; revokedAt?: IsoDate; createdAt: IsoDate; } export type RiskSignalKind = | "UNKNOWN_COUNTERPARTY" | "NEW_TOKEN" | "LARGE_AMOUNT" | "UNAPPROVED_PROTOCOL" | "HIGH_SLIPPAGE" | "RAPID_FREQUENCY" | "RECENT_POLICY_CHANGE" | "NEW_DESTINATION" | "FAILED_SIMULATION"; export interface RiskSignal { kind: RiskSignalKind; weight: number; detail: string; } export type ActorType = "USER" | "AGENT" | "SYSTEM"; export type ActivityEventType = | "org.created" | "member.added" | "agent.created" | "agent.activated" | "agent.paused" | "agent.resumed" | "agent.revoked" | "agent.funded" | "account.created" | "vault.created" | "vault.funded" | "vault.frozen" | "vault.unfrozen" | "vault.withdrawn" | "limits.synced" | "policy.created" | "policy.changed" | "policy.disabled" | "approval.requested" | "approval.granted" | "approval.denied" | "approval.expired" | "approval.executed" | "execution.submitted" | "execution.blocked" | "execution.settled" | "execution.failed" | "payment.created" | "payment.settled" | "capital.allocated" | "capital.returned" | "swap.completed" | "counterparty.added" | "counterparty.changed" | "apikey.created" | "apikey.revoked" | "session.created" | "session.revoked" | "circuit.tripped" | "executions.disabled" | "executions.enabled" | "webhook.created"; export interface ActivityEvent { id: Id; organizationId: Id; actorType: ActorType; actorId: string; eventType: ActivityEventType; resourceType: string; resourceId: string; metadata: Record; createdAt: IsoDate; prevHash: string; hash: string; } /* ------------------------------------------------------------------ */ /* Developers */ /* ------------------------------------------------------------------ */ export interface ApiKey { id: Id; organizationId: Id; name: string; prefix: string; createdAt: IsoDate; lastUsedAt?: IsoDate; revokedAt?: IsoDate; } export type WebhookEvent = | "agent.execution.requested" | "agent.execution.approved" | "agent.execution.blocked" | "agent.execution.settled" | "payment.created" | "payment.settled" | "approval.required" | "approval.completed" | "policy.changed" | "vault.funded" | "vault.frozen"; export interface WebhookEndpoint { id: Id; organizationId: Id; url: string; events: WebhookEvent[]; enabled: boolean; secretPrefix: string; createdAt: IsoDate; } export interface WebhookDelivery { id: Id; webhookId: Id; event: WebhookEvent; statusCode: number | null; ok: boolean; attempts: number; error?: string; createdAt: IsoDate; } export type BillableEvent = | "AGENT_EXECUTION" | "PAYMENT" | "SWAP" | "API_REQUEST" | "ACTIVE_AGENT" | "WEBHOOK_DELIVERY" | "PREMIUM_FEATURE"; /** * @elyx/sdk * * Small, dependency-free TypeScript client for the ELYX HTTP API on Elysium (Hyperliquid L2, chain id 99901). * Works in Node 18+, Deno, Bun, browsers and edge runtimes (fetch + Web Crypto). * * Authentication: * ek_live_... API key, organization scope (create in the developer dashboard) * eas_... agent session token, scoped to one agent, its vaults and allowed actions * eot_... owner token, issued to an owner key by signInWithKey (needed for on-chain owner actions) */ /* ------------------------------------------------------------------ */ /* Network constants (Elysium mainnet) */ /* ------------------------------------------------------------------ */ export const ELYSIUM_CHAIN_ID = 99901; /** Public contract addresses ELYX relies on. Pinned here so the SDK never has to trust the server for them. */ export const CONTRACTS = { safeSingletonL2: "0x3E5c63644E683549055b9Be8653de26E0B4CD36E", safeProxyFactory: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", safeFallbackHandler: "0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4", multiSendCallOnly: "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", usdc: "0xb47c4C8F73F009CC0C0d3f92B388d13E293F0B65", } as const; /** Token decimals for the payable assets. */ export const DECIMALS: Record<"USDC" | "HYPE", number> = { USDC: 6, HYPE: 18 }; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ export interface ElyxOptions { /** `ek_live_...` API key, `eas_...` agent session token or `eot_...` owner token. */ apiKey: string; /** Origin of your ELYX deployment, e.g. "https://elyx.app". */ baseUrl: string; /** Custom fetch implementation. Defaults to the global fetch. */ fetch?: typeof fetch; /** * Address of the ELYX spending limit module. Pin it from the deployment's documentation; when omitted the * SDK reads it once from GET /api/v1/context (which trusts the server for this one address). */ moduleAddress?: string; } /** Body of POST /api/v1/intents and POST /api/v1/intents/preview. */ export interface IntentInput { agentId: string; /** Defaults to the agent's primary vault. */ vaultId?: string; action: ActionType; asset: AssetSymbol; /** Required for SWAP. */ outputAsset?: AssetSymbol; /** Decimal token amount, e.g. "125.5". */ amount: string; /** Required for PAYMENT and TRANSFER. */ counterpartyId?: string; /** Required for SWAP. 0 to 5000. */ maxSlippageBps?: number; memo?: string; justification?: string; } export interface SubmitResult { execution: Execution; /** True when the idempotency key matched an earlier request; the original execution is returned. */ replayed: boolean; } export interface IntentPreview { /** USD notional in cents, priced server-side. */ amountUsd: number; evaluation: PolicyEvaluation; /** Fee quote in USD micro-units (1e-6 USD), as decimal strings. */ fee: { networkFee: string; executionFee: string; providerFee: string; totalFee: string }; route: ExecutionRoute; estimatedTime: string; } /** Body of POST /api/v1/payments. Requires an API key (agent tokens submit PAYMENT intents instead). */ export interface PaymentInput { agentId: string; vaultId?: string; counterpartyId: string; asset: "USDC" | "HYPE"; /** Decimal token amount. */ amount: string; memo?: string; reference?: string; invoice?: string; justification?: string; } export interface PaymentResult { paymentId: string; execution: Execution; } export interface AgentDetail { agent: Agent; limits: (SpendingLimit & { remaining: string | null; display: string })[]; wallet: { balances: TokenBalance[]; totalUsd: number }; /** Executor HYPE balance in wei, as a decimal string. */ gasWei: string; } /** Body of POST /api/v1/agents/:id/requests. Creates a pending approval for owners. */ export interface ChangeRequestInput { type: "POLICY_CHANGE" | "COUNTERPARTY" | "PROTOCOL" | "LIMIT_INCREASE" | "AGENT_PERMISSION"; title: string; requestedChange: string; currentRule?: string; justification: string; change?: PolicyChange; } export interface RequestOptions { /** Sent as the `idempotency-key` header. 8 to 128 characters. Generated when omitted. */ idempotencyKey?: string; signal?: AbortSignal; } export type WebhookVerifyResult = { ok: true } | { ok: false; reason: "MALFORMED" | "TIMESTAMP_OUT_OF_TOLERANCE" | "SIGNATURE_MISMATCH" }; /* ------------------------------------------------------------------ */ /* Errors */ /* ------------------------------------------------------------------ */ /** Thrown for every non-2xx response. `code` mirrors the API error code (e.g. FORBIDDEN, RATE_LIMITED). */ export class ElyxError extends Error { readonly code: string; readonly status: number; constructor(code: string, status: number, message: string) { super(message); this.name = "ElyxError"; this.code = code; this.status = status; } } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ export const TERMINAL_STATES: readonly ExecutionState[] = ["SETTLED", "BLOCKED", "FAILED", "CANCELLED"]; export const WEBHOOK_SIGNATURE_HEADER = "elyx-signature"; async function webCrypto(): Promise { if (globalThis.crypto?.subtle) return globalThis.crypto; // Node 18 without the global: fall back to node:crypto's Web Crypto implementation. const specifier = "node:crypto"; const mod = (await import(/* webpackIgnore: true */ specifier)) as { webcrypto: Crypto }; return mod.webcrypto; } function randomKey(): string { const c = globalThis.crypto; if (c?.randomUUID) return `sdk-${c.randomUUID()}`; return `sdk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; } function toHex(buf: ArrayBuffer): string { return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""); } function timingSafeEqualHex(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } /** * Verify an ELYX webhook. * Header format: `elyx-signature: t=,v1=.")>`. * Pass the raw request body exactly as received. */ export async function verifyWebhook(body: string, header: string | null | undefined, secret: string, opts: { toleranceS?: number; nowS?: number } = {}): Promise { if (!header) return { ok: false, reason: "MALFORMED" }; const parts: Record = {}; for (const p of header.split(",")) { const i = p.indexOf("="); if (i > 0) parts[p.slice(0, i).trim()] = p.slice(i + 1).trim(); } const t = parts.t && /^\d+$/.test(parts.t) ? Number(parts.t) : NaN; const v1 = parts.v1; if (!Number.isFinite(t) || !v1) return { ok: false, reason: "MALFORMED" }; const now = opts.nowS ?? Math.floor(Date.now() / 1000); if (Math.abs(now - t) > (opts.toleranceS ?? 300)) return { ok: false, reason: "TIMESTAMP_OUT_OF_TOLERANCE" }; const c = await webCrypto(); const enc = new TextEncoder(); const key = await c.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const mac = await c.subtle.sign("HMAC", key, enc.encode(`${t}.${body}`)); return timingSafeEqualHex(toHex(mac), v1.toLowerCase()) ? { ok: true } : { ok: false, reason: "SIGNATURE_MISMATCH" }; } /* ------------------------------------------------------------------ */ /* Owner keys: autonomous sign-in and transaction signing */ /* ------------------------------------------------------------------ */ /** Unsigned EIP-1559 transaction as ELYX prepares it (bigints as decimal strings). */ export interface WireTx { chainId: number; from: string; to: string; data: string; value: string; gas: string; maxFeePerGas: string; maxPriorityFeePerGas: string; nonce: number; } /** EIP-712 typed data as returned by GET /api/v1/approvals/:id/safe-typed-data. */ export interface TypedData { domain: { chainId: number; verifyingContract: string }; types: Record; primaryType: string; message: Record; } /** * A secp256k1 owner key held by the agent runtime. ELYX never sees it. * `viemSigner(privateKeyToAccount(key))` adapts a viem local account; any library works. */ export interface OwnerSigner { /** 0x address of the key. */ address: string; /** EIP-191 personal_sign over the UTF-8 message. Returns the 65-byte 0x signature. */ signMessage(message: string): Promise; /** EIP-712 signature (eth_signTypedData_v4 semantics). Returns the 65-byte 0x signature. */ signTypedData(typedData: TypedData): Promise; /** Signs an EIP-1559 transaction and returns the RLP-serialized signed transaction (0x02...). */ signTransaction(tx: WireTx): Promise; } /** Shape of a viem `LocalAccount` (structural, so viem is not a dependency of the SDK). */ type Hex0x = `0x${string}`; export interface ViemLikeAccount { address: string; signMessage(args: { message: string }): Promise; signTypedData(args: { domain: { chainId: number; verifyingContract: Hex0x }; types: Record; primaryType: string; message: Record }): Promise; signTransaction(tx: { chainId: number; to: Hex0x; data: Hex0x; value: bigint; gas: bigint; maxFeePerGas: bigint; maxPriorityFeePerGas: bigint; nonce: number; type: "eip1559" }): Promise; } /** Adapt a viem local account (privateKeyToAccount, mnemonicToAccount, ...) to an OwnerSigner. */ export function viemSigner(account: ViemLikeAccount): OwnerSigner { return { address: account.address, signMessage: (message) => account.signMessage({ message }), signTypedData: (td) => { // viem rejects EIP712Domain in `types` and wants bigints for uint fields. const { EIP712Domain: _d, ...types } = td.types; const uintFields = new Set((td.types[td.primaryType] ?? []).filter((f) => /^u?int\d*$/.test(f.type)).map((f) => f.name)); const message = Object.fromEntries(Object.entries(td.message).map(([k, v]) => [k, uintFields.has(k) ? BigInt(v as string) : v])); return account.signTypedData({ domain: { chainId: td.domain.chainId, verifyingContract: td.domain.verifyingContract as Hex0x }, types, primaryType: td.primaryType, message }); }, signTransaction: (tx) => account.signTransaction({ type: "eip1559", chainId: tx.chainId, to: tx.to as Hex0x, data: tx.data as Hex0x, value: BigInt(tx.value), gas: BigInt(tx.gas), maxFeePerGas: BigInt(tx.maxFeePerGas), maxPriorityFeePerGas: BigInt(tx.maxPriorityFeePerGas), nonce: tx.nonce, }), }; } /** A transaction ELYX prepared and simulated on Elysium, waiting for the owner signature. */ export interface PreparedTransaction { id: string; kind: string; transaction: WireTx; description: string; simulation: { ok: boolean; error?: string; logs?: string[] }; } interface AwaitOwners { kind: "AWAIT_OWNERS"; approvalId: string; safeTxHash: string; required: number; } const isAwait = (x: PreparedTransaction | AwaitOwners): x is AwaitOwners => "kind" in x && x.kind === "AWAIT_OWNERS"; /** Result of an owner action. Threshold-1 vaults confirm at once; others wait for co-owner signatures. */ export type OwnerResult = | { status: "CONFIRMED"; hash: string; result: T } | { status: "AWAITING_OWNERS"; approvalId: string; safeTxHash: string; required: number }; /* ---- Minimal ABI decoding (no dependencies) ---------------------- */ const eq = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; class Calldata { constructor(private readonly hex: string) { if (!/^0x([0-9a-fA-F]{2})*$/.test(hex)) throw new ElyxError("BAD_CALLDATA", 0, "Calldata is not hex."); } get selector() { return this.hex.slice(0, 10).toLowerCase(); } get length() { return (this.hex.length - 2) / 2; } /** Word i (32 bytes) of the arguments, as a bigint. */ word(i: number, base = 10): bigint { const start = base + i * 64; const w = this.hex.slice(start, start + 64); if (w.length !== 64) throw new ElyxError("BAD_CALLDATA", 0, "Calldata is truncated."); return BigInt(`0x${w}`); } address(i: number, base = 10): string { const w = this.word(i, base); if (w >> 160n !== 0n) throw new ElyxError("BAD_CALLDATA", 0, "Address word has high bits set."); return `0x${w.toString(16).padStart(40, "0")}`; } /** Dynamic `bytes` argument whose offset is in word i. */ bytes(i: number, base = 10): string { const off = Number(this.word(i, base)) * 2 + base; const len = Number(BigInt(`0x${this.hex.slice(off, off + 64)}`)); const data = this.hex.slice(off + 64, off + 64 + len * 2); if (data.length !== len * 2) throw new ElyxError("BAD_CALLDATA", 0, "Bytes argument is truncated."); return `0x${data}`; } /** Dynamic `address[]` argument whose offset is in word i. */ addresses(i: number, base = 10): string[] { const off = Number(this.word(i, base)) * 2 + base; const len = Number(BigInt(`0x${this.hex.slice(off, off + 64)}`)); const out: string[] = []; for (let k = 0; k < len; k++) out.push(this.address(k, off + 64)); return out; } } const SEL = { createProxyWithNonce: "0x1688f0b9", setup: "0xb63e800d", execTransaction: "0x6a761202", enableModule: "0x610b5925", disableModule: "0xe009cfde", multiSend: "0x8d80ff0a", erc20Transfer: "0xa9059cbb", erc20Approve: "0x095ea7b3", moduleSetLimit: "0xf0627a69", moduleRemoveLimit: "0xf0cc4eb9", moduleSetDestinations: "0x47f46b3a", } as const; export type InnerCall = | { kind: "NATIVE_TRANSFER"; to: string; value: bigint } | { kind: "ERC20_TRANSFER"; token: string; to: string; amount: bigint } | { kind: "ENABLE_MODULE"; safe: string; module: string } | { kind: "DISABLE_MODULE"; safe: string; module: string } | { kind: "MODULE_SET_LIMIT"; module: string; delegate: string; token: string; amount: bigint; period: number; allowed: string[] } | { kind: "MODULE_REMOVE_LIMIT"; module: string; delegate: string; token: string } | { kind: "MODULE_SET_DESTINATIONS"; module: string; delegate: string; add: string[]; remove: string[] } | { kind: "UNKNOWN"; to: string; value: bigint; selector: string }; export interface TransactionInspection { chainId: number; from: string; to: string; /** HYPE (wei) leaving the owner wallet as the transaction value. */ valueWei: bigint; selector: string; /** Safe creation through the proxy factory. */ createSafe?: { singleton: string; owners: string[]; threshold: number; setupTo: string; setupData: string; fallbackHandler: string; paymentToken: string; payment: bigint; paymentReceiver: string; saltNonce: bigint }; /** Safe.execTransaction from the owner: the inner calls after unpacking MultiSendCallOnly. */ safeExec?: { safe: string; to: string; value: bigint; data: string; operation: number; safeTxGas: bigint; baseGas: bigint; gasPrice: bigint; gasToken: string; refundReceiver: string; signatures: string; inner: InnerCall[] }; /** Direct ERC-20 transfer from the owner (deposit). */ tokenTransfer?: { token: string; to: string; amount: bigint }; /** Direct HYPE transfer from the owner (deposit). */ nativeTransfer?: { to: string; value: bigint }; problems: string[]; } function decodeInner(to: string, value: bigint, data: string, moduleAddress: string | undefined, safe: string): InnerCall { if (data === "0x" || data === "") return { kind: "NATIVE_TRANSFER", to, value }; const c = new Calldata(data); const sel = c.selector; if (value !== 0n) return { kind: "UNKNOWN", to, value, selector: sel }; if (sel === SEL.erc20Transfer && c.length === 68) return { kind: "ERC20_TRANSFER", token: to, to: c.address(0), amount: c.word(1) }; if (eq(to, safe) && sel === SEL.enableModule && c.length === 36) return { kind: "ENABLE_MODULE", safe, module: c.address(0) }; if (eq(to, safe) && sel === SEL.disableModule && c.length === 68) return { kind: "DISABLE_MODULE", safe, module: c.address(1) }; if (moduleAddress && eq(to, moduleAddress)) { if (sel === SEL.moduleSetLimit) return { kind: "MODULE_SET_LIMIT", module: to, delegate: c.address(0), token: c.address(1), amount: c.word(2), period: Number(c.word(3)), allowed: c.addresses(4) }; if (sel === SEL.moduleRemoveLimit && c.length === 68) return { kind: "MODULE_REMOVE_LIMIT", module: to, delegate: c.address(0), token: c.address(1) }; if (sel === SEL.moduleSetDestinations) return { kind: "MODULE_SET_DESTINATIONS", module: to, delegate: c.address(0), add: c.addresses(1), remove: c.addresses(2) }; } return { kind: "UNKNOWN", to, value, selector: sel }; } /** Unpack MultiSendCallOnly.multiSend(bytes): repeated (uint8 op, address to, uint256 value, uint256 len, bytes data). */ function decodeMultiSend(data: string, problems: string[]): { to: string; value: bigint; data: string }[] { const c = new Calldata(data); if (c.selector !== SEL.multiSend) { problems.push("delegatecall target is not multiSend"); return []; } const packed = c.bytes(0).slice(2); const out: { to: string; value: bigint; data: string }[] = []; let i = 0; while (i < packed.length) { const op = parseInt(packed.slice(i, i + 2), 16); const to = `0x${packed.slice(i + 2, i + 42)}`; const value = BigInt(`0x${packed.slice(i + 42, i + 106)}`); const len = Number(BigInt(`0x${packed.slice(i + 106, i + 170)}`)); const d = packed.slice(i + 170, i + 170 + len * 2); if (d.length !== len * 2) { problems.push("multiSend payload is truncated"); break; } if (op !== 0) problems.push("multiSend contains a delegatecall"); out.push({ to, value, data: `0x${d}` }); i += 170 + len * 2; } return out; } /** Decode a prepared transaction locally. Never trusts the server's description. */ export function inspectTransaction(tx: WireTx, moduleAddress?: string): TransactionInspection { const problems: string[] = []; const out: TransactionInspection = { chainId: tx.chainId, from: tx.from, to: tx.to, valueWei: BigInt(tx.value), selector: tx.data.slice(0, 10).toLowerCase(), problems }; if (tx.chainId !== ELYSIUM_CHAIN_ID) problems.push(`chain id ${tx.chainId} is not Elysium (${ELYSIUM_CHAIN_ID})`); if (!/^0x[0-9a-fA-F]{40}$/.test(tx.to)) problems.push("destination is not an address"); if (BigInt(tx.maxFeePerGas) > 500n * 10n ** 9n) problems.push("maxFeePerGas above 500 gwei"); if (BigInt(tx.gas) > 5_000_000n) problems.push("gas limit above 5,000,000"); if (tx.data === "0x" || tx.data === "") { out.nativeTransfer = { to: tx.to, value: out.valueWei }; return out; } try { const c = new Calldata(tx.data); if (eq(tx.to, CONTRACTS.safeProxyFactory) && c.selector === SEL.createProxyWithNonce) { const init = new Calldata(c.bytes(1)); if (init.selector !== SEL.setup) problems.push("initializer is not Safe.setup"); out.createSafe = { singleton: c.address(0), saltNonce: c.word(2), owners: init.addresses(0), threshold: Number(init.word(1)), setupTo: init.address(2), setupData: init.bytes(3), fallbackHandler: init.address(4), paymentToken: init.address(5), payment: init.word(6), paymentReceiver: init.address(7), }; } else if (c.selector === SEL.execTransaction) { const to = c.address(0); const value = c.word(1); const data = c.bytes(2); const operation = Number(c.word(3)); const inner: InnerCall[] = []; if (operation === 0) inner.push(decodeInner(to, value, data, moduleAddress, tx.to)); else if (operation === 1 && eq(to, CONTRACTS.multiSendCallOnly)) for (const m of decodeMultiSend(data, problems)) inner.push(decodeInner(m.to, m.value, m.data, moduleAddress, tx.to)); else problems.push("Safe transaction uses delegatecall to an unexpected target"); out.safeExec = { safe: tx.to, to, value, data, operation, safeTxGas: c.word(4), baseGas: c.word(5), gasPrice: c.word(6), gasToken: c.address(7), refundReceiver: c.address(8), signatures: c.bytes(9), inner }; } else if (c.selector === SEL.erc20Transfer && c.length === 68) { out.tokenTransfer = { token: tx.to, to: c.address(0), amount: c.word(1) }; } else { problems.push(`unknown call ${c.selector} to ${tx.to}`); } } catch (e) { problems.push(e instanceof Error ? e.message : "undecodable calldata"); } return out; } export interface SignGuard { /** Account creation: the exact owners and threshold requested. */ createAccount?: { owners: string[]; threshold: number }; /** A deposit from the owner wallet: destination vault, token (ZERO_ADDRESS for HYPE) and exact base-unit amount. */ deposit?: { vault: string; token: string; amount: bigint }; /** The vault Safe this transaction may call execTransaction on. */ safe?: string; /** The agent executor whose limits may be set and who may receive gas. */ delegate?: string; /** Max HYPE (wei) that may leave the vault to the delegate as gas. Default 0. */ maxGasWei?: bigint; /** Destination a vault withdrawal may pay. */ withdrawTo?: string; /** Exact Safe transaction being executed (co-owner flow): inner call must match it byte for byte. */ expectedSafeTx?: Pick; /** Allow disabling the module (agent revocation). */ allowDisableModule?: boolean; } /** Throws unless the inspected transaction stays within the guard. Called before every owner signature. */ export function assertSafeToSign(ins: TransactionInspection, owner: string, guard: SignGuard = {}, moduleAddress?: string): void { const fail = (why: string): never => { throw new ElyxError("UNSAFE_TRANSACTION", 0, `Refusing to sign: ${why}. Nothing was signed.`); }; if (ins.problems.length) fail(ins.problems.join("; ")); if (!eq(ins.from, owner)) fail("transaction is prepared for a different sender"); if (ins.createSafe) { const c = ins.createSafe; const want = guard.createAccount ?? fail("creates an account that was not requested"); if (!eq(c.singleton, CONTRACTS.safeSingletonL2)) fail("Safe singleton is not the canonical L2 singleton"); const got = c.owners.map((o) => o.toLowerCase()).sort(); const expected = [...new Set(want.owners.map((o) => o.toLowerCase()))].sort(); if (got.join() !== expected.join()) fail("account owners differ from the request"); if (c.threshold !== want.threshold) fail("account threshold differs from the request"); if (!eq(c.setupTo, ZERO_ADDRESS) || c.setupData !== "0x") fail("Safe setup runs an extra delegatecall"); if (!eq(c.fallbackHandler, CONTRACTS.safeFallbackHandler)) fail("unexpected fallback handler"); if (c.payment !== 0n || !eq(c.paymentToken, ZERO_ADDRESS) || !eq(c.paymentReceiver, ZERO_ADDRESS)) fail("Safe setup charges a payment"); if (ins.valueWei !== 0n) fail("account creation sends HYPE"); return; } if (guard.createAccount) fail("expected an account creation"); if (ins.nativeTransfer || ins.tokenTransfer) { const d = guard.deposit ?? fail("moves funds out of the owner wallet without a requested deposit"); if (ins.nativeTransfer) { if (!eq(d.token, ZERO_ADDRESS)) fail("expected a token deposit, got HYPE"); if (!eq(ins.nativeTransfer.to, d.vault)) fail(`HYPE would go to ${ins.nativeTransfer.to}, not the vault`); if (ins.nativeTransfer.value !== d.amount) fail(`HYPE amount ${ins.nativeTransfer.value} differs from the requested ${d.amount}`); } else if (ins.tokenTransfer) { if (!eq(ins.tokenTransfer.token, d.token)) fail("deposit uses a different token"); if (!eq(ins.tokenTransfer.to, d.vault)) fail(`tokens would go to ${ins.tokenTransfer.to}, not the vault`); if (ins.tokenTransfer.amount !== d.amount) fail(`token amount ${ins.tokenTransfer.amount} differs from the requested ${d.amount}`); if (ins.valueWei !== 0n) fail("token deposit also sends HYPE"); } return; } if (guard.deposit) fail("expected a deposit"); if (ins.safeExec) { const x = ins.safeExec; if (!guard.safe || !eq(x.safe, guard.safe)) fail(`calls Safe ${x.safe}, not the expected vault`); if (ins.valueWei !== 0n) fail("execTransaction sends HYPE from the owner wallet"); if (x.safeTxGas !== 0n || x.baseGas !== 0n || x.gasPrice !== 0n || !eq(x.gasToken, ZERO_ADDRESS) || !eq(x.refundReceiver, ZERO_ADDRESS)) fail("Safe transaction has gas refund parameters set"); if (guard.expectedSafeTx) { const e = guard.expectedSafeTx; if (!eq(e.safe, x.safe) || !eq(e.to, x.to) || BigInt(e.value) !== x.value || e.data.toLowerCase() !== x.data.toLowerCase() || e.operation !== x.operation) fail("Safe transaction differs from the approved one"); return; } // Threshold-1 flow: the only signature must be the owner's own sender approval (v = 1). const sig = x.signatures.toLowerCase(); const expectedSig = `0x${owner.slice(2).toLowerCase().padStart(64, "0")}${"0".repeat(64)}01`; if (sig !== expectedSig) fail("signatures are not the owner's sender approval"); let gas = 0n; for (const call of x.inner) { switch (call.kind) { case "ENABLE_MODULE": if (!moduleAddress || !eq(call.module, moduleAddress)) fail(`enables unknown module ${call.module}`); break; case "DISABLE_MODULE": if (!guard.allowDisableModule || !moduleAddress || !eq(call.module, moduleAddress)) fail("disables a module"); break; case "MODULE_SET_LIMIT": case "MODULE_REMOVE_LIMIT": case "MODULE_SET_DESTINATIONS": if (!guard.delegate || !eq(call.delegate, guard.delegate)) fail(`changes limits for ${call.delegate}, not the expected agent`); break; case "NATIVE_TRANSFER": if (guard.withdrawTo && eq(call.to, guard.withdrawTo)) break; if (!guard.delegate || !eq(call.to, guard.delegate)) fail(`vault sends HYPE to ${call.to}`); gas += call.value; if (gas > (guard.maxGasWei ?? 0n)) fail(`gas funding ${gas} wei exceeds the limit ${guard.maxGasWei ?? 0n}`); break; case "ERC20_TRANSFER": if (!guard.withdrawTo || !eq(call.to, guard.withdrawTo)) fail(`vault sends tokens to ${call.to}`); break; default: fail(`unexpected inner call ${call.selector} to ${call.to}`); } } return; } fail("unrecognized transaction"); } function assertHttps(base: string) { const u = new URL(base); if (u.protocol !== "https:" && u.hostname !== "localhost" && u.hostname !== "127.0.0.1") throw new ElyxError("CONFIG", 0, "baseUrl must use https."); } /** * Autonomous sign-in (EIP-4361): proves control of the owner key and returns an owner token (eot_...). * Creates the organization on first use. No browser, wallet extension or human needed. */ export async function signInWithKey(baseUrl: string, signer: OwnerSigner, opts: { name?: string; organizationName?: string; fetch?: typeof fetch } = {}): Promise<{ token: string; expiresAt: string; wallet: string; organization: { id: string; name: string } }> { const f = opts.fetch ?? ((...a: Parameters) => fetch(...a)); const base = baseUrl.replace(/\/+$/, ""); assertHttps(base); const post = async (path: string, body: unknown) => { const res = await f(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const data = (await res.json().catch(() => null)) as { error?: { code: string; message: string } } | null; if (!res.ok) throw new ElyxError(data?.error?.code ?? "HTTP_ERROR", res.status, data?.error?.message ?? `HTTP ${res.status}`); return data as Record; }; const ch = (await post("/api/v1/auth/challenge", { address: signer.address })) as { message: string; nonceToken: string }; // Only ever sign a plain sign-in message for this exact host and key, never transaction bytes. const host = new URL(base).host; const lines = ch.message.split("\n"); if (lines[0] !== `${host} wants you to sign in with your Ethereum account:` || !eq(lines[1] ?? "", signer.address) || !ch.message.includes(`Chain ID: ${ELYSIUM_CHAIN_ID}`)) { throw new ElyxError("UNSAFE_MESSAGE", 0, "Sign-in message does not match this host, key and chain. Nothing was signed."); } const signature = await signer.signMessage(ch.message); return (await post("/api/v1/auth/token", { address: signer.address, message: ch.message, signature, nonceToken: ch.nonceToken, name: opts.name, organizationName: opts.organizationName })) as never; } /** Decimal amount to base units (bigint). */ export function toBaseUnits(amount: string, decimals: number): bigint { const [w = "0", f = ""] = amount.trim().split("."); if (!/^\d*$/.test(w) || !/^\d*$/.test(f) || f.length > decimals) throw new ElyxError("BAD_AMOUNT", 0, `Invalid amount ${amount}.`); return BigInt(w || "0") * 10n ** BigInt(decimals) + BigInt((f + "0".repeat(decimals)).slice(0, decimals) || "0"); } /* ------------------------------------------------------------------ */ /* Client */ /* ------------------------------------------------------------------ */ export class Elyx { private readonly key: string; private readonly base: string; private readonly fetcher: typeof fetch; private module: string | undefined; constructor(opts: ElyxOptions) { if (!opts.apiKey) throw new ElyxError("CONFIG", 0, "apiKey is required."); if (!opts.baseUrl) throw new ElyxError("CONFIG", 0, "baseUrl is required."); this.key = opts.apiKey; this.base = opts.baseUrl.replace(/\/+$/, ""); assertHttps(this.base); this.fetcher = opts.fetch ?? ((...args) => fetch(...args)); this.module = opts.moduleAddress; } private async request(method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown, extra?: { headers?: Record; signal?: AbortSignal }): Promise { const res = await this.fetcher(`${this.base}${path}`, { method, headers: { authorization: `Bearer ${this.key}`, accept: "application/json", ...(body !== undefined ? { "content-type": "application/json" } : {}), ...extra?.headers, }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: extra?.signal, }); const text = await res.text(); let data: unknown = null; if (text) { try { data = JSON.parse(text); } catch { data = null; } } if (!res.ok) { const err = (data as { error?: { code?: string; message?: string } } | null)?.error; throw new ElyxError(err?.code ?? "HTTP_ERROR", res.status, err?.message ?? `Request failed (${res.status})`); } return data as T; } /** The spending limit module address (pinned in options, or read once from /context). */ private async moduleAddress(): Promise { if (this.module) return this.module; const ctx = await this.request<{ contracts?: { spendingLimitModule?: string | null } }>("GET", "/api/v1/context"); const m = ctx.contracts?.spendingLimitModule; if (!m) throw new ElyxError("CONFIG", 0, "This ELYX deployment has no spending limit module configured."); this.module = m; return m; } private async assetToken(asset: AssetSymbol): Promise<{ token: string; decimals: number }> { if (asset === "HYPE") return { token: ZERO_ADDRESS, decimals: 18 }; if (asset === "USDC") return { token: CONTRACTS.usdc, decimals: 6 }; const ctx = await this.request<{ assets: { symbol: string; token: string; decimals: number; native: boolean }[] }>("GET", "/api/v1/context"); const a = ctx.assets.find((x) => x.symbol === asset); if (!a) throw new ElyxError("BAD_ASSET", 0, `Unknown asset ${asset}.`); return { token: a.native ? ZERO_ADDRESS : a.token, decimals: a.decimals }; } readonly intents = { /** POST /api/v1/intents. Returns immediately (202); processing continues server-side. */ submit: (body: IntentInput, opts: RequestOptions = {}) => this.request("POST", "/api/v1/intents", body, { headers: { "idempotency-key": opts.idempotencyKey ?? randomKey() }, signal: opts.signal }), /** POST /api/v1/intents/preview. Policy decision and fee quote. Nothing is created or signed. */ preview: (body: IntentInput, opts: { signal?: AbortSignal } = {}) => this.request("POST", "/api/v1/intents/preview", body, { signal: opts.signal }), }; readonly executions = { /** GET /api/v1/executions/:id */ get: (id: string) => this.request("GET", `/api/v1/executions/${encodeURIComponent(id)}`), /** GET /api/v1/executions?agentId=&state=&limit= */ list: (params: { agentId?: string; state?: ExecutionState; limit?: number } = {}) => { const q = new URLSearchParams(); if (params.agentId) q.set("agentId", params.agentId); if (params.state) q.set("state", params.state); if (params.limit) q.set("limit", String(params.limit)); const qs = q.toString(); return this.request("GET", `/api/v1/executions${qs ? `?${qs}` : ""}`); }, /** * Poll until the execution reaches a terminal state (SETTLED, BLOCKED, FAILED, CANCELLED) * or AWAITING_APPROVAL. Throws ElyxError("TIMEOUT") after `timeoutMs`. */ wait: async (id: string, opts: { timeoutMs?: number; intervalMs?: number } = {}): Promise => { const deadline = Date.now() + (opts.timeoutMs ?? 120_000); const interval = Math.max(500, opts.intervalMs ?? 1_500); for (;;) { const e = await this.executions.get(id); if (TERMINAL_STATES.includes(e.state) || e.state === "AWAITING_APPROVAL") return e; if (Date.now() + interval > deadline) throw new ElyxError("TIMEOUT", 0, `Execution ${id} is still ${e.state}.`); await new Promise((r) => setTimeout(r, interval)); } }, }; /** * Owner actions that end in an on-chain transaction (create account, deposit, authorize agent, * execute approved Safe transactions, withdraw). Requires an owner token (eot_...) and the owner key to sign. */ readonly owner = { /** POST a preparing endpoint, then sign and submit the returned transaction (or report that co-owners must sign). */ run: async (path: string, body: unknown, signer: OwnerSigner, guard: SignGuard): Promise> => { const out = await this.request("POST", path, body); if (isAwait(out)) return { status: "AWAITING_OWNERS", approvalId: out.approvalId, safeTxHash: out.safeTxHash, required: out.required }; return this.owner.submit(out, signer, guard); }, /** * Sign and submit an already prepared transaction. Before signing, the transaction is decoded * locally and refused if it does anything beyond the guard (unexpected contracts, owner outflows, * limits for other delegates, vault transfers to other destinations). Also refuses if simulation failed. */ submit: async (prepared: PreparedTransaction, signer: OwnerSigner, guard: SignGuard): Promise> => { if (!prepared.simulation.ok) throw new ElyxError("SIMULATION_FAILED", 0, prepared.simulation.error ?? "Elysium simulation failed. Nothing was signed."); const module = this.module ?? (prepared.transaction.data.startsWith(SEL.execTransaction) ? await this.moduleAddress() : undefined); assertSafeToSign(inspectTransaction(prepared.transaction, module), signer.address, guard, module); const raw = await signer.signTransaction(prepared.transaction); if (!/^0x02[0-9a-fA-F]+$/.test(raw)) throw new ElyxError("BAD_SIGNATURE", 0, "signTransaction must return a serialized EIP-1559 transaction (0x02...)."); const out = await this.request<{ signature: string; result: T; pending?: boolean; message?: string }>("POST", `/api/v1/tx/${prepared.id}/submit`, { transaction: raw }); // Broadcast but not final yet: never resubmit or re-prepare; ELYX finishes it automatically. if (out.pending) throw new ElyxError("TX_PENDING", 202, `${out.message ?? "Transaction pending."} Hash: ${out.signature}`); return { status: "CONFIRMED", hash: out.signature, result: out.result }; }, /** Deploy a new Safe account (its first vault) owned by the signer and any co-owners. Deposit afterwards with `deposit`. */ createAccount: async (body: { name: string; type?: string; coOwners?: string[]; threshold?: number; vaults?: { name: string; type: string; description?: string }[] }, signer: OwnerSigner) => { const owners = [signer.address, ...(body.coOwners ?? [])]; const threshold = body.threshold ?? 1; const r = await this.owner.run<{ accountId: string; vaultId: string; vaultAddress: string }>( "/api/v1/accounts", { type: "AGENT", coOwners: [], threshold: 1, vaults: [{ name: "Operating Vault", type: "OPERATING", description: "" }], ...body, deposit: undefined }, signer, { createAccount: { owners, threshold } }, ); if (r.status !== "CONFIRMED") throw new ElyxError("UNEXPECTED", 0, "Account creation never waits for co-owners."); return r; }, /** * Deposit into a vault from the owner wallet. Pass `vaultAddress` (from your own records) to have the SDK verify * the destination locally; without it the destination is the vault address ELYX reports. */ deposit: async (vaultId: string, body: { asset: AssetSymbol; amount: string }, signer: OwnerSigner, opts: { vaultAddress?: string } = {}) => { const vaultAddress = opts.vaultAddress ?? (await this.request<{ address: string }>("GET", `/api/v1/vaults/${encodeURIComponent(vaultId)}`)).address; const { token, decimals } = await this.assetToken(body.asset); return this.owner.run(`/api/v1/vaults/${encodeURIComponent(vaultId)}/deposit`, body, signer, { deposit: { vault: vaultAddress, token, amount: toBaseUnits(body.amount, decimals) } }); }, createCounterparty: (body: { name: string; type: string; address: string; trust?: string; approvedFor?: ActionType[] }) => this.request<{ id: string }>("POST", "/api/v1/counterparties", { trust: "VERIFIED", approvedFor: ["PAYMENT"], ...body }), /** * Authorize an agent on a vault: enables the ELYX module on the vault Safe if needed, sets the agent's * limits and destinations and funds its executor with `gasHype`. Pass `vaultAddress` to pin the Safe locally. */ createAgent: async (body: Record & { vaultId: string; gasHype?: string }, signer: OwnerSigner, opts: { vaultAddress?: string } = {}) => { const vaultAddress = opts.vaultAddress ?? (await this.request<{ address: string }>("GET", `/api/v1/vaults/${encodeURIComponent(body.vaultId)}`)).address; const prepared = await this.request("POST", "/api/v1/agents", body); if (isAwait(prepared)) return { status: "AWAITING_OWNERS" as const, approvalId: prepared.approvalId, safeTxHash: prepared.safeTxHash, required: prepared.required } as OwnerResult<{ executed: boolean; agentId: string }>; // The executor address is the only inner-call target this transaction may fund or configure. const module = await this.moduleAddress(); const ins = inspectTransaction(prepared.transaction, module); const delegates = new Set(); for (const c of ins.safeExec?.inner ?? []) if (c.kind === "MODULE_SET_LIMIT" || c.kind === "MODULE_SET_DESTINATIONS" || c.kind === "MODULE_REMOVE_LIMIT") delegates.add(c.delegate.toLowerCase()); if (delegates.size !== 1) throw new ElyxError("UNSAFE_TRANSACTION", 0, "Refusing to sign: agent onboarding must configure exactly one executor. Nothing was signed."); const [delegate] = [...delegates]; return this.owner.submit<{ executed: boolean; agentId: string }>(prepared, signer, { safe: vaultAddress, delegate, maxGasWei: toBaseUnits(body.gasHype ?? "0.01", 18) }); }, /** Send HYPE from the vault to an agent's executor for network fees. Pass `executor` to verify the destination locally. */ topUp: async (agentId: string, hype: string, signer: OwnerSigner, opts: { executor?: string; vaultAddress?: string } = {}) => { const detail = opts.executor && opts.vaultAddress ? null : await this.request<{ agent: { executor: string; primaryVaultId: string } }>("GET", `/api/v1/agents/${encodeURIComponent(agentId)}`); const executor = opts.executor ?? detail!.agent.executor; const vaultAddress = opts.vaultAddress ?? (await this.request<{ address: string }>("GET", `/api/v1/vaults/${encodeURIComponent(detail!.agent.primaryVaultId)}`)).address; return this.owner.run(`/api/v1/agents/${encodeURIComponent(agentId)}/topup`, { hype }, signer, { safe: vaultAddress, delegate: executor, maxGasWei: toBaseUnits(hype, 18) }); }, /** Revoke an agent: removes its limits (and disables the module when no agent remains). */ revokeAgent: async (agentId: string, signer: OwnerSigner, opts: { executor?: string; vaultAddress?: string } = {}) => { const detail = await this.request<{ agent: { executor: string; primaryVaultId: string } }>("GET", `/api/v1/agents/${encodeURIComponent(agentId)}`); const vaultAddress = opts.vaultAddress ?? (await this.request<{ address: string }>("GET", `/api/v1/vaults/${encodeURIComponent(detail.agent.primaryVaultId)}`)).address; return this.owner.run(`/api/v1/agents/${encodeURIComponent(agentId)}/revoke`, {}, signer, { safe: vaultAddress, delegate: opts.executor ?? detail.agent.executor, allowDisableModule: true }); }, issueSession: (body: { agentId: string; allowedActions: ActionType[]; spendingAllowanceUsd: number; rateLimitPerMinute?: number; ttlHours?: number }) => this.request<{ id: string; token: string }>("POST", "/api/v1/developers/sessions", body), /** * Sign an approval as an owner. Off-chain approvals sign a plain message; Safe transactions sign EIP-712 * typed data (no gas). Always check the approval details (amount, destination) first. */ approve: async (approvalId: string, signer: OwnerSigner, decision: "APPROVE" | "REJECT" = "APPROVE") => { const a = await this.request("GET", `/api/v1/approvals/${encodeURIComponent(approvalId)}`); if (a.settlement.kind === "OFFCHAIN" || decision === "REJECT") { const { message } = await this.request<{ message: string }>("POST", `/api/v1/approvals/${encodeURIComponent(approvalId)}/message`, { decision }); if (!message.includes(approvalId)) throw new ElyxError("UNSAFE_MESSAGE", 0, "Approval message does not reference this approval. Nothing was signed."); const signature = await signer.signMessage(message); return this.request("POST", `/api/v1/approvals/${encodeURIComponent(approvalId)}/${a.settlement.kind === "OFFCHAIN" ? "vote" : "safe-vote"}`, { decision, signature }); } const { typedData } = await this.request<{ typedData: TypedData; safeTxHash: string }>("GET", `/api/v1/approvals/${encodeURIComponent(approvalId)}/safe-typed-data`); const d = a.settlement.tx; if (typedData.domain.chainId !== ELYSIUM_CHAIN_ID || !eq(typedData.domain.verifyingContract, d.safe) || typedData.primaryType !== "SafeTx") throw new ElyxError("UNSAFE_MESSAGE", 0, "Typed data is not a SafeTx for this vault. Nothing was signed."); const m = typedData.message as { to: string; value: string; data: string; operation: number; nonce: string; gasToken: string; refundReceiver: string; safeTxGas: string; baseGas: string; gasPrice: string }; if (!eq(m.to, d.to) || m.value !== d.value || m.data.toLowerCase() !== d.data.toLowerCase() || m.operation !== d.operation || m.nonce !== d.nonce) throw new ElyxError("UNSAFE_MESSAGE", 0, "Typed data differs from the approval's Safe transaction. Nothing was signed."); if (m.safeTxGas !== "0" || m.baseGas !== "0" || m.gasPrice !== "0" || !eq(m.gasToken, ZERO_ADDRESS) || !eq(m.refundReceiver, ZERO_ADDRESS)) throw new ElyxError("UNSAFE_MESSAGE", 0, "Typed data sets gas refund parameters. Nothing was signed."); const signature = await signer.signTypedData(typedData); return this.request("POST", `/api/v1/approvals/${encodeURIComponent(approvalId)}/safe-vote`, { decision, signature }); }, /** Execute an approved Safe transaction (owner actions on vaults with threshold above 1). */ execute: async (approvalId: string, signer: OwnerSigner) => { const a = await this.request("GET", `/api/v1/approvals/${encodeURIComponent(approvalId)}`); if (a.settlement.kind === "OFFCHAIN") throw new ElyxError("BAD_REQUEST", 0, "This approval has no Safe transaction."); const d = a.settlement.tx; return this.owner.run(`/api/v1/approvals/${encodeURIComponent(approvalId)}/execute`, {}, signer, { safe: d.safe, expectedSafeTx: d }); }, /** Withdraw from a vault to the owner (or `destination`). Threshold-1 vaults settle at once. */ withdraw: async (vaultId: string, body: { asset: AssetSymbol; amount: string; destination?: string }, signer: OwnerSigner, opts: { vaultAddress?: string } = {}) => { const vaultAddress = opts.vaultAddress ?? (await this.request<{ address: string }>("GET", `/api/v1/vaults/${encodeURIComponent(vaultId)}`)).address; return this.owner.run<{ executed: boolean }>(`/api/v1/vaults/${encodeURIComponent(vaultId)}/withdraw`, body, signer, { safe: vaultAddress, withdrawTo: body.destination ?? signer.address }); }, }; /** GET /api/v1/context: ids, scope, on-chain limits, units, contracts and rate limits for this credential. */ context = () => this.request>("GET", "/api/v1/context"); readonly payments = { /** POST /api/v1/payments. API keys only. */ create: (body: PaymentInput, opts: RequestOptions = {}) => this.request("POST", "/api/v1/payments", body, { headers: { "idempotency-key": opts.idempotencyKey ?? randomKey() }, signal: opts.signal }), }; readonly approvals = { /** GET /api/v1/approvals?status= . Agent tokens only see their own agent's approvals. */ list: (status?: ApprovalStatus) => this.request("GET", `/api/v1/approvals${status ? `?status=${status}` : ""}`), /** GET /api/v1/approvals/:id */ get: (id: string) => this.request("GET", `/api/v1/approvals/${encodeURIComponent(id)}`), }; readonly agents = { /** GET /api/v1/agents */ list: () => this.request("GET", "/api/v1/agents"), /** GET /api/v1/agents/:id . Agent tokens can only read their own agent. */ get: (id: string) => this.request("GET", `/api/v1/agents/${encodeURIComponent(id)}`), /** POST /api/v1/agents/:id/requests . Creates a pending approval for owners. */ requestChange: (agentId: string, body: ChangeRequestInput) => this.request("POST", `/api/v1/agents/${encodeURIComponent(agentId)}/requests`, body), }; readonly webhooks = { /** Verify the `elyx-signature` header of an incoming webhook (HMAC-SHA256, 300s tolerance). */ verify: (body: string, header: string | null | undefined, secret: string, opts?: { toleranceS?: number; nowS?: number }) => verifyWebhook(body, header, secret, opts), }; /** Convenience wrapper that binds intents to one agent. */ agent(agentId: string) { const submit = (body: Omit, opts?: RequestOptions) => this.intents.submit({ ...body, agentId }, opts); return { id: agentId, /** SWAP from the agent's working wallet, routed through KyberSwap. */ swap: (p: { from: AssetSymbol; to: AssetSymbol; amount: string; maxSlippageBps: number; vaultId?: string; justification?: string }, opts?: RequestOptions) => submit({ action: "SWAP", asset: p.from, outputAsset: p.to, amount: p.amount, maxSlippageBps: p.maxSlippageBps, vaultId: p.vaultId, justification: p.justification }, opts), /** PAYMENT intent from the vault to a registered counterparty. */ pay: (p: { counterpartyId: string; asset: AssetSymbol; amount: string; memo?: string; vaultId?: string; justification?: string }, opts?: RequestOptions) => submit({ action: "PAYMENT", asset: p.asset, amount: p.amount, counterpartyId: p.counterpartyId, memo: p.memo, vaultId: p.vaultId, justification: p.justification }, opts), /** ALLOCATE capital from the vault to the agent's working wallet. */ allocate: (p: { asset: AssetSymbol; amount: string; vaultId?: string; justification?: string }, opts?: RequestOptions) => submit({ action: "ALLOCATE", asset: p.asset, amount: p.amount, vaultId: p.vaultId, justification: p.justification }, opts), /** RETURN capital from the agent's working wallet to its vault. */ returnCapital: (p: { asset: AssetSymbol; amount: string; vaultId?: string; justification?: string }, opts?: RequestOptions) => submit({ action: "RETURN", asset: p.asset, amount: p.amount, vaultId: p.vaultId, justification: p.justification }, opts), /** Ask owners for a change (policy, counterparty, protocol, limit, permission). */ request: (body: ChangeRequestInput) => this.agents.requestChange(agentId, body), get: () => this.agents.get(agentId), executions: (params: { state?: ExecutionState; limit?: number } = {}) => this.executions.list({ ...params, agentId }), }; } } export default Elyx;