diff --git a/.agents/skills/near-intents-agent-api/SKILL.md b/.agents/skills/near-intents-agent-api/SKILL.md new file mode 100644 index 000000000..a680dbb9f --- /dev/null +++ b/.agents/skills/near-intents-agent-api/SKILL.md @@ -0,0 +1,131 @@ +--- +name: near-intents-agent-api +description: Build on the NEAR Intents Agent API (api.agentsonintents.com) in any language — TypeScript, Python, Rust, Go or plain HTTP. Use when integrating AI-agent wallets on NEAR Intents into an app or backend, when code mentions `naa_` API keys, `ngt_` grant tokens, `X-Grant-Token`, `generate-intent`/`submit-intent`, `@near-intents-agent-api/sdk`, owner-signed spending policies, agent swaps/transfers/withdrawals/deposits, or when someone wants an AI agent to hold and move funds under owner-set limits. +--- + +# NEAR Intents Agent API + +A backend HTTP API that gives each AI agent its own **agent account on NEAR Intents**. The end +user (the **owner**) signs the rules once with their own wallet; the agent then swaps, transfers +and withdraws on its own, inside those rules, with no further signatures. Your backend holds one +partner API key and never touches anyone's private keys. + +| Party | Credential | Does | +|---|---|---| +| **Partner** (your backend) | API key `naa_…` from the [partner dashboard](https://partners.near-intents.org/) | Calls every endpoint, prepares owner actions, stores grants | +| **Owner** (end user) | Their wallet: NEAR account, EVM key or passkey | Signs: create account, policy, grants, freeze, approve, delete | +| **Agent** (assistant, bot, session) | Grant token `ngt_…` (held by your backend) | Moves money inside the owner's policy | + +Hosted API: `https://api.agentsonintents.com` (mainnet, real funds), also the TypeScript SDK's +default `baseUrl`. Machine-readable contract: `/openapi.json`; compact LLM guide: `/llms.txt`. + +## Non-negotiable rules + +Follow these in every integration; most bugs and lost-fund incidents come from breaking one. + +1. **API key and grant tokens are backend-only.** Never ship `naa_…`/`ngt_…` to a browser, mobile + app, prompt, log or LLM context. Browsers only see `intent.payload` + `preview` and return the + wallet's output. +2. **The owner's wallet signs `intent.payload` exactly as returned.** Pick the method from + `intent.standard` (`nep413`, `nep366`, `eip712`, `webauthn`). Never rebuild, re-serialize, + hash or reorder it; submit `{...intent, }`. +3. **Amounts are atomic integer strings** (`"1000000"` = 1 USDC at 6 decimals). Get `decimals` + and `asset_id` from `GET /v1/tokens`. Never use floats. +4. **Every money-moving call carries an `Idempotency-Key`** (8–128 chars `[A-Za-z0-9._:-]`). + Create it, persist it with the request, *then* send. Retry only with the same key and body. +5. **`UNCERTAIN` means "maybe executed".** Keep polling the original `correlation_id`. Never + resubmit with a new key; `/recover` is only for provably-undispatched operations. An + execution the provider may have received answers `202 UNCERTAIN`. A server failure + (`internal_error`, `database_busy`, `transaction_unconfirmed`) carries `meta.retryable` and + may have taken effect: repeat it under the **same** key. A missing `meta.retryable` is not + proof that nothing was sent: use a new key only after a proven refusal (see + [errors.md](references/errors.md) retry rules). +6. **`NEEDS_REVIEW` means stop.** Do not poll or retry; inspect `details.reason`, resolve, then + `GET /v1/status?correlation_id=…&refresh=true`. +7. **Policies are always sent complete**, with `expected_revision` from `GET …/policy`. On + `policy_revision_conflict`, re-read and get a fresh signature. +8. **A policy is in force only when `status: "APPLIED"` and `provider_policy_synced: true`.** + Before that, executions fail with `policy_not_ready`. +9. **Branch on `errors[0].code`**, never on `title`/`detail`. Refusal codes name the layer that + refused (grant, policy action, destination, provider policy, USD budget, schedule); only changing that + layer lifts it. +10. **Funds go only to `details.deposit_address`.** A `correlation_id` (`op_…`/`intent_…`) is a + tracking id, never an address. +11. **One grant per session/assistant/bot.** Grants say *who* may act; the account policy says + *what*. Revoke a grant to cut off one agent; `agent_freeze` stops everyone. + +## Quick start (5 minutes, any language) + +1. Create a partner API key in the **[partner dashboard](https://partners.near-intents.org/)** (**API keys**). The `naa_…` token is + shown once — put it in your backend's secret store as `NEAR_INTENTS_AGENT_API_KEY`. +2. Smoke-test: + ```bash + curl -s https://api.agentsonintents.com/v1/network # public: service health + curl -s https://api.agentsonintents.com/v1/whoami -H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" + curl -s https://api.agentsonintents.com/v1/quotas -H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" + ``` + Or run [scripts/check_api.sh](scripts/check_api.sh). +3. Follow the lifecycle below. Full walkthrough: [references/getting-started.md](references/getting-started.md). + +## The lifecycle + +``` +owner signs ──► agent_create (policy) ──► grant_issue (token commitment) ──► fund (deposit) + │ + agent (with X-Grant-Token) ──► swap / transfer / withdraw ──► GET /v1/status +``` + +1. **Create the account** — `POST /v1/generate-intent {type:"agent_create", name, owner, policy}` + → owner signs → `POST /v1/submit-intent` → poll `GET /v1/status` to `SUCCESS`. +2. **Issue a grant** — your backend makes a random `ngt_…` token, sends only its SHA-256 hex as + `credential` in `grant_issue` with `label` and `expires_at` (≤365 days); owner signs; you store + the token encrypted. +3. **Fund** — `POST /v1/agents/{agent_id}/deposit {origin_asset}` (API key + Idempotency-Key, no + grant; `amount` optional) returns `details.deposit_address`, `min_amount`, `expires_at`, + optional `memo`, and `refund_to` (the agent's own balance). +4. **Act** — `POST /v1/agents/{agent_id}/swap|transfer|withdraw|shield|unshield` with + `X-API-Key`, `X-Grant-Token`, `Idempotency-Key`. Quote first with `dry: true` (swap/withdraw). +5. **Observe** — `GET /v1/status?correlation_id=…&wait_ms=30000` until it stops moving. + +Every owner action (policy edits, freeze, grant revoke, approval votes, cancel, delete) is the +same generate → sign → submit → status loop. + +## Pick your language path + +| Stack | Use | Reference | +|---|---|---| +| TypeScript / Node 24+ | `npm i @near-intents-agent-api/sdk` (typed, thin, no signers) | [references/typescript.md](references/typescript.md) | +| Python | Copy [assets/python/near_intents_agent_api.py](assets/python/near_intents_agent_api.py) (client + NEAR/EVM signers) | [references/python.md](references/python.md) | +| Rust | Copy [assets/rust/](assets/rust/) (async client + signers, `evm` feature) | [references/rust.md](references/rust.md) | +| Go, Java, Kotlin, C#, Ruby, PHP, … | Plain HTTP + generate types from `/openapi.json` | [references/other-languages.md](references/other-languages.md) | + +The Python and Rust signers are verified byte-for-byte against near-api-js and viem using +[assets/test-vectors.json](assets/test-vectors.json); check any new port against the same file. + +## Where to look + +| You need to… | Read | +|---|---| +| Understand accounts, policy vs grant, who enforces what | [references/concepts.md](references/concepts.md) | +| Every endpoint, header, shape, pagination | [references/http-api.md](references/http-api.md) | +| Run owner actions, previews, expiry, cooldowns, BFF split | [references/owner-intents.md](references/owner-intents.md) | +| Sign `nep413` / `nep366` / `eip712` / `webauthn` correctly | [references/signing.md](references/signing.md) | +| Write or edit a policy (limits, destinations, USD budget, timelock, schedule, approval) | [references/policy.md](references/policy.md) | +| Issue, use, store, revoke grants | [references/grants.md](references/grants.md) | +| Swap, transfer, withdraw, deposit, shield, quotes, chains | [references/executions.md](references/executions.md) | +| Statuses, polling, idempotency, `UNCERTAIN`, `/recover`, operation proofs | [references/status-and-recovery.md](references/status-and-recovery.md) | +| Handle an error code | [references/errors.md](references/errors.md) | +| Design the integration (data model, AI tools, key ownership models, security) | [references/architecture.md](references/architecture.md) | +| Find a runnable example for a flow | [references/examples.md](references/examples.md) | + +## When writing code for a user + +- Read `NEAR_INTENTS_AGENT_API_URL`/`NEAR_INTENTS_AGENT_API_KEY` from env; the base URL is `https://api.agentsonintents.com` + unless the user runs a self-hosted or local deployment (then only the base URL changes). +- Show `preview.summary` (and `preview.policy` / `preview.deletion`) to the owner before signing. +- Read `GET /v1/network` before acting and after `quote_unavailable`, `route_unavailable` or + `provider_unavailable`; if a component is `down`, wait rather than change the request. +- Writes against mainnet move real money: build in dry-run/quote mode first and gate live + execution behind an explicit flag, as the examples do (`AGENT_ALLOW_WRITES`, `AGENT_EXECUTE`). +- Do not invent endpoints or fields. If unsure, fetch `/openapi.json` or `/llms.txt` from the + deployment and use what it says; the server is the source of truth. diff --git a/.agents/skills/near-intents-agent-api/assets/python/near_intents_agent_api.py b/.agents/skills/near-intents-agent-api/assets/python/near_intents_agent_api.py new file mode 100644 index 000000000..16f8c99f0 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/assets/python/near_intents_agent_api.py @@ -0,0 +1,481 @@ +""" +Minimal Python client for the NEAR Intents Agent API, plus owner-side signers. + +Copy this file into your project. It mirrors the TypeScript SDK (`@near-intents-agent-api/sdk`): +one thin HTTP layer, JSON:API errors, explicit idempotency keys, per-grant clients. It contains no +retries and no hidden state. + + pip install httpx cryptography # client + NEAR signing + pip install eth-account # only for EVM (EIP-712) owners + +Backend only: the API key (`naa_...`) and grant tokens (`ngt_...`) never go to a browser. + +Signing helpers (`sign_nep413`, `sign_nep366`, `sign_eip712`) exist for scripts, tests and +server-held owner keys. In a real product the OWNER's wallet signs in the browser; your backend +only forwards `intent` to the frontend and the wallet output back to `submit_intent`. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +import secrets +import struct +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Iterable + +import httpx + +DEFAULT_BASE_URL = "https://api.agentsonintents.com" +API_KEY_PATTERN = re.compile(r"^naa_[A-Za-z0-9_-]{43}$") +GRANT_TOKEN_PATTERN = re.compile(r"^ngt_[A-Za-z0-9_-]{43}$") +SETTLEMENT_PATHS = ("/swap", "/withdraw", "/transfer", "/shield", "/unshield") +STILL_MOVING = {"PROCESSING", "QUEUED"} + + +# ------------------------------------------------------------------------------------- errors + + +class AgentApiError(Exception): + """A non-2xx response. Branch on `code`, never on `title`.""" + + def __init__(self, status: int, document: Any, idempotency_key: str | None): + errors = document.get("errors", []) if isinstance(document, dict) else [] + first = errors[0] if errors else {} + meta = first.get("meta") or {} + self.status = status + self.code: str = first.get("code", "http_error") + self.title: str = first.get("title", f"HTTP {status}") + self.detail: str | None = first.get("detail") + self.retryable: bool = bool(meta.get("retryable", False)) + self.available_at: str | None = meta.get("available_at") + self.request_id: str | None = (document.get("meta") or {}).get("request_id") if isinstance(document, dict) else None + self.errors: list[dict[str, Any]] = errors + self.idempotency_key = idempotency_key + super().__init__(f"{status} {self.code}: {self.detail or self.title}") + + +class AgentApiRequestError(Exception): + """Transport failure. A write may still have reached the API: reconcile before retrying, + and retry only with the same `idempotency_key`.""" + + def __init__(self, cause: Exception, idempotency_key: str | None): + super().__init__(str(cause)) + self.__cause__ = cause + self.idempotency_key = idempotency_key + + +# ------------------------------------------------------------------------------------ helpers + + +def create_idempotency_key() -> str: + """One key per logical request. Persist it with the request BEFORE sending.""" + return str(uuid.uuid4()) + + +def grant_commitment(token: str) -> str: + """SHA-256 hex of the grant token: the `credential` field of a `grant_issue` intent.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def create_grant_credential() -> dict[str, str]: + """New grant token (`ngt_` + 32 random bytes, unpadded base64url) and its commitment. + Store the token encrypted on your backend; the API never sees it.""" + token = "ngt_" + base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + return {"token": token, "commitment": grant_commitment(token)} + + +def to_atomic(amount: str, decimals: int) -> str: + """'1.5' with 6 decimals -> '1500000'. Never use floats for money.""" + whole, _, fraction = amount.partition(".") + if len(fraction) > decimals: + raise ValueError(f"amount has more than {decimals} decimals") + return str(int((whole or "0") + fraction.ljust(decimals, "0"))) + + +def to_decimal(amount_raw: str, decimals: int) -> str: + padded = amount_raw.rjust(decimals + 1, "0") + whole, fraction = padded[: len(padded) - decimals] or "0", padded[len(padded) - decimals :] + return whole if decimals == 0 else f"{whole}.{fraction}".rstrip("0").rstrip(".") + + +# ------------------------------------------------------------------------------------- client + + +@dataclass +class AgentApi: + api_key: str + base_url: str = DEFAULT_BASE_URL + grant_token: str | None = None + timeout_s: float = 65.0 + settlement_timeout_s: float = 150.0 + _http: httpx.Client = field(default=None, repr=False) # type: ignore[assignment] + + def __post_init__(self) -> None: + if not API_KEY_PATTERN.match(self.api_key): + raise ValueError("api_key is not a naa_ key") + if self.grant_token is not None and not GRANT_TOKEN_PATTERN.match(self.grant_token): + raise ValueError("grant_token is not an ngt_ token") + self.base_url = self.base_url.rstrip("/") + if self._http is None: + self._http = httpx.Client(follow_redirects=False) + + @classmethod + def from_env(cls) -> "AgentApi": + return cls( + api_key=os.environ["NEAR_INTENTS_AGENT_API_KEY"], + base_url=os.environ.get("NEAR_INTENTS_AGENT_API_URL", DEFAULT_BASE_URL), + ) + + def for_grant(self, grant_token: str) -> "AgentApi": + """A client that sends `X-Grant-Token` on delegated calls. One per session/assistant/bot.""" + return AgentApi(self.api_key, self.base_url, grant_token, self.timeout_s, self.settlement_timeout_s, self._http) + + # -- transport --------------------------------------------------------------------------- + + def request( + self, + method: str, + path: str, + *, + query: dict[str, Any] | None = None, + body: Any = None, + idempotency_key: str | None = None, + grant: bool = False, + ) -> Any: + headers = {"accept": "application/json", "x-api-key": self.api_key} + if body is not None: + headers["content-type"] = "application/json" + if idempotency_key is not None: + headers["idempotency-key"] = idempotency_key + if grant: + if not self.grant_token: + raise ValueError("this call needs a grant: use api.for_grant(token)") + headers["x-grant-token"] = self.grant_token + params = {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in (query or {}).items() if v is not None} + settlement = path.endswith(SETTLEMENT_PATHS) and not (isinstance(body, dict) and body.get("dry")) + try: + response = self._http.request( + method, + self.base_url + path, + params=params, + headers=headers, + content=None if body is None else json.dumps(body), + timeout=self.settlement_timeout_s if settlement else self.timeout_s, + ) + except httpx.HTTPError as error: + raise AgentApiRequestError(error, idempotency_key) from error + document = response.json() if response.content else None + if response.status_code >= 400: + raise AgentApiError(response.status_code, document, idempotency_key) + return document + + # -- owner intents ----------------------------------------------------------------------- + + def generate_intent(self, request: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]: + return self.request("POST", "/v1/generate-intent", body=request, idempotency_key=idempotency_key) + + def submit_intent(self, intent_type: str, correlation_id: str, signed_data: dict[str, Any]) -> dict[str, Any]: + body = {"type": intent_type, "correlation_id": correlation_id, "signed_data": signed_data} + return self.request("POST", "/v1/submit-intent", body=body) + + def get_status(self, correlation_id: str, wait_ms: int | None = None, refresh: bool | None = None) -> dict[str, Any]: + query = {"correlation_id": correlation_id, "wait_ms": wait_ms, "refresh": refresh} + return self.request("GET", "/v1/status", query=query) + + def wait_for_status(self, correlation_id: str, deadline_s: float = 300.0) -> dict[str, Any]: + """Long-polls until the operation stops moving (anything but PROCESSING/QUEUED). + UNCERTAIN: keep observing the SAME id later; never resubmit under a new key.""" + end = time.monotonic() + deadline_s + while True: + status = self.get_status(correlation_id, wait_ms=30_000) + if status["status"] not in STILL_MOVING or time.monotonic() > end: + return status + + # -- reads ------------------------------------------------------------------------------- + + def get_network(self) -> dict[str, Any]: + return self.request("GET", "/v1/network") + + def get_tokens(self) -> list[dict[str, Any]]: + return self.request("GET", "/v1/tokens")["data"] + + def whoami(self) -> dict[str, Any]: + return self.request("GET", "/v1/whoami") + + def get_quotas(self) -> dict[str, Any]: + return self.request("GET", "/v1/quotas") + + def list_agents(self, external_user_id: str | None = None, cursor: str | None = None) -> dict[str, Any]: + return self.request("GET", "/v1/agents", query={"external_user_id": external_user_id, "cursor": cursor}) + + def get_agent(self, agent_id: str) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}") + + def get_balances(self, agent_id: str, source: str = "public", asset: str | None = None) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}/balances", query={"source": source, "asset": asset}) + + def get_policy(self, agent_id: str) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}/policy") + + def list_grants(self, agent_id: str) -> list[dict[str, Any]]: + return self.request("GET", f"/v1/agents/{agent_id}/grants")["data"] + + def get_history( + self, agent_id: str, limit: int = 25, cursor: str | None = None, include_unpaid: bool | None = None + ) -> dict[str, Any]: + query = {"limit": limit, "cursor": cursor, "include_unpaid": include_unpaid} + return self.request("GET", f"/v1/agents/{agent_id}/history", query=query) + + def list_scheduled(self, agent_id: str, limit: int = 25, cursor: str | None = None) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}/executions/scheduled", query={"limit": limit, "cursor": cursor}) + + def list_approvals(self, agent_id: str) -> list[dict[str, Any]]: + return self.request("GET", f"/v1/agents/{agent_id}/approvals")["data"] + + def get_containment(self, agent_id: str) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}/containment") + + # -- executions (grant) ------------------------------------------------------------------ + + def quote_swap(self, agent_id: str, request: dict[str, Any]) -> dict[str, Any]: + """Dry run: no grant, no idempotency key, no budget charge.""" + return self.request("POST", f"/v1/agents/{agent_id}/swap", body={**request, "dry": True}) + + def quote_withdraw(self, agent_id: str, request: dict[str, Any]) -> dict[str, Any]: + return self.request("POST", f"/v1/agents/{agent_id}/withdraw", body={**request, "dry": True}) + + def _execute(self, agent_id: str, action: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self.request( + "POST", f"/v1/agents/{agent_id}/{action}", body=request, idempotency_key=idempotency_key, grant=True + ) + + def swap(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "swap", request, idempotency_key) + + def withdraw(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "withdraw", request, idempotency_key) + + def transfer(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "transfer", request, idempotency_key) + + def shield(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "shield", request, idempotency_key) + + def unshield(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "unshield", request, idempotency_key) + + def recover(self, agent_id: str, correlation_id: str, original: dict[str, Any], original_key: str) -> dict[str, Any]: + """Only for UNCERTAIN operations that provably never reached the provider. + `original` = the original body plus `type`; `original_key` = the original Idempotency-Key.""" + body = {"correlation_id": correlation_id, "request": original} + return self._execute(agent_id, "recover", body, original_key) + + def deposit(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + """Inbound address: API key + Idempotency-Key only, no grant.""" + return self.request("POST", f"/v1/agents/{agent_id}/deposit", body=request, idempotency_key=idempotency_key) + + +# ------------------------------------------------------------------------------ NEAR signing +# Exact byte formats; verified against near-api-js. Never rebuild or reorder a payload. + +_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +NEP413_TAG = 2**31 + 413 # NEP-413 prefix +NEP366_TAG = 2**30 + 366 # NEP-461 prefix for delegate actions + + +def b58decode(value: str) -> bytes: + number = 0 + for char in value: + number = number * 58 + _B58.index(char) + raw = number.to_bytes((number.bit_length() + 7) // 8, "big") + return b"\x00" * (len(value) - len(value.lstrip("1"))) + raw + + +def b58encode(data: bytes) -> str: + number = int.from_bytes(data, "big") + out = "" + while number: + number, rem = divmod(number, 58) + out = _B58[rem] + out + return "1" * (len(data) - len(data.lstrip(b"\x00"))) + out + + +@dataclass +class NearKey: + """A NEAR ed25519 key from `ed25519:` (near-cli / near-api-js format).""" + + secret: str + + def __post_init__(self) -> None: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + raw = b58decode(self.secret.removeprefix("ed25519:")) + self._key = Ed25519PrivateKey.from_private_bytes(raw[:32]) + self.public_bytes = self._key.public_key().public_bytes_raw() + self.public_key = "ed25519:" + b58encode(self.public_bytes) + + def sign(self, message: bytes) -> bytes: + return self._key.sign(message) + + +def _borsh_string(value: str) -> bytes: + data = value.encode("utf-8") + return struct.pack(" bytes: + return struct.pack(" bytes: + return value.to_bytes(16, "little") + + +def sign_nep413(key: NearKey, payload: dict[str, str]) -> dict[str, str]: + """NEP-413 message signature (used for off-chain owner consent: grants, local policy edits, + approval votes, ...). Returns the extra `signed_data` fields.""" + nonce = base64.b64decode(payload["nonce"]) + assert len(nonce) == 32 + body = ( + struct.pack(" bytes: + """Byte-identical to JavaScript `JSON.stringify(value)` for API payloads (key order kept).""" + return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def encode_delegate_action( + sender_id: str, payload: dict[str, Any], public_key: bytes, nonce: int, max_block_height: int +) -> bytes: + """Borsh `DelegateAction` (without the NEP-461 prefix).""" + actions = b"" + for action in payload["actions"]: + params = action["params"] + actions += ( + b"\x02" # Action::FunctionCall + + _borsh_string(params["methodName"]) + + _borsh_bytes(js_json(params["args"])) + + struct.pack(" str: + """Base64 borsh `SignedDelegate` for an `nep366` payload, given the owner's access-key state.""" + action = encode_delegate_action(sender_id, payload, key.public_bytes, access_key_nonce + 1, block_height + 120) + signature = key.sign(hashlib.sha256(struct.pack(" dict[str, str]: + """NEP-366 gasless delegate action (on-chain owner changes; the API's sponsor pays gas).""" + rpc = httpx.post( + rpc_url, + json={ + "jsonrpc": "2.0", + "id": "access-key", + "method": "query", + "params": { + "request_type": "view_access_key", + "finality": "final", + "account_id": account_id, + "public_key": key.public_key, + }, + }, + timeout=20, + ).json() + if "error" in rpc or "error" in rpc.get("result", {}): + raise RuntimeError(f"view_access_key failed: {rpc}") + result = rpc["result"] + signed = sign_delegate(key, account_id, payload, int(result["nonce"]), int(result["block_height"])) + return {"signed_delegate": signed} + + +def sign_eip712(private_key: str, payload: dict[str, Any]) -> dict[str, str]: + """EIP-712 typed data (EVM owners). `payload` already contains `EIP712Domain`.""" + from eth_account import Account + from eth_account.messages import encode_typed_data + + signed = Account.sign_message(encode_typed_data(full_message=payload), private_key=private_key) + return {"signature": "0x" + signed.signature.hex().removeprefix("0x")} + + +def evm_owner(private_key: str, chain_id: int = 1) -> dict[str, Any]: + """Public `OwnerWallet` for an EVM key: lowercase address + 64-byte uncompressed pubkey (no 04).""" + from eth_account import Account + from eth_keys import keys + + account = Account.from_key(private_key) + public = keys.PrivateKey(bytes.fromhex(private_key.removeprefix("0x"))).public_key.to_hex() + return {"type": "evm", "address": account.address.lower(), "chain_id": chain_id, "public_key": public} + + +def near_owner(account_id: str, key: NearKey) -> dict[str, str]: + return {"type": "near", "account_id": account_id, "public_key": key.public_key} + + +def sign_intent( + generated: dict[str, Any], + *, + near_key: NearKey | None = None, + rpc_url: str = "https://free.rpc.fastnear.com", + evm_private_key: str | None = None, +) -> dict[str, Any]: + """Turns a `generate-intent` response into `signed_data`, picking the method from + `intent.standard`. The payload is echoed back unchanged.""" + intent = generated["intent"] + standard, payload = intent["standard"], intent["payload"] + if standard == "nep413": + assert near_key, "nep413 needs the NEAR owner key" + return {**intent, **sign_nep413(near_key, payload)} + if standard == "nep366": + assert near_key, "nep366 needs the NEAR owner key" + return {**intent, **sign_nep366(near_key, generated["signer"]["account_id"], payload, rpc_url)} + if standard == "eip712": + assert evm_private_key, "eip712 needs the EVM owner key" + return {**intent, **sign_eip712(evm_private_key, payload)} + raise ValueError(f"{standard} must be signed by the owner's authenticator in the browser") + + +def run_owner_intent(api: AgentApi, request: dict[str, Any], sign: Any, idempotency_key: str | None = None) -> dict[str, Any]: + """generate -> sign -> submit -> wait. `sign(generated) -> signed_data`.""" + generated = api.generate_intent(request, idempotency_key or create_idempotency_key()) + api.submit_intent(generated["type"], generated["correlation_id"], sign(generated)) + return api.wait_for_status(generated["correlation_id"]) + + +def iterate_pages(fetch: Any) -> Iterable[dict[str, Any]]: + """`for agent in iterate_pages(lambda c: api.list_agents(cursor=c))`.""" + cursor = None + while True: + page = fetch(cursor) + yield from page["data"] + cursor = page.get("next_cursor") + if not cursor: + return diff --git a/.agents/skills/near-intents-agent-api/assets/python/test_vectors.py b/.agents/skills/near-intents-agent-api/assets/python/test_vectors.py new file mode 100644 index 000000000..e5631856a --- /dev/null +++ b/.agents/skills/near-intents-agent-api/assets/python/test_vectors.py @@ -0,0 +1,44 @@ +""" +Checks the Python signers against ../test-vectors.json (generated with near-api-js and viem). + + python test_vectors.py # NEAR + grant vectors; EIP-712 too if eth-account is installed + python test_vectors.py path/to/vectors.json +""" + +import json +import struct +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import near_intents_agent_api as n # noqa: E402 + +path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent.parent / "test-vectors.json" +v = json.loads(path.read_text()) + +key = n.NearKey(v["near"]["secret"]) +assert key.public_key == v["near"]["public_key"], "public key" + +assert n.sign_nep413(key, v["nep413"]["payload"])["signature"] == v["nep413"]["signature_b64"], "nep413" + +d = v["nep366"] +nonce, height = int(d["access_key_nonce"]), int(d["block_height"]) +encoded = n.encode_delegate_action(v["near"]["account_id"], d["payload"], key.public_bytes, nonce + 1, height + 120) +assert (struct.pack(", + retryable: bool, + available_at: Option, + request_id: Option, + errors: Vec, + idempotency_key: Option, + }, + /// Transport failure. A write may still have reached the API: reconcile the original + /// operation, and retry only with the same idempotency key. + #[error("transport: {source}")] + Transport { + #[source] + source: reqwest::Error, + idempotency_key: Option, + }, + #[error("{0}")] + Invalid(String), +} + +impl Error { + pub fn code(&self) -> Option<&str> { + match self { + Error::Api { code, .. } => Some(code), + _ => None, + } + } +} + +pub type Result = std::result::Result; + +// ----------------------------------------------------------------------------------- helpers + +/// One key per logical request. Persist it with the request BEFORE sending. +pub fn create_idempotency_key() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// SHA-256 hex of a grant token: the `credential` of a `grant_issue` intent. +pub fn grant_commitment(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +/// A new grant token (`ngt_` + 32 random bytes, unpadded base64url) and its commitment. +/// Store the token encrypted; the API only ever sees the commitment. +pub fn create_grant_credential() -> (String, String) { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + let token = format!("ngt_{}", B64URL.encode(bytes)); + let commitment = grant_commitment(&token); + (token, commitment) +} + +fn valid_token(value: &str, prefix: &str) -> bool { + value.len() == 47 + && value.starts_with(prefix) + && value[4..].bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +// ------------------------------------------------------------------------------------ client + +#[derive(Clone)] +pub struct AgentApi { + http: reqwest::Client, + base_url: String, + api_key: String, + grant_token: Option, +} + +impl AgentApi { + pub fn new(api_key: impl Into, base_url: Option<&str>) -> Result { + let api_key = api_key.into(); + if !valid_token(&api_key, "naa_") { + return Err(Error::Invalid("api_key is not a naa_ key".into())); + } + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|source| Error::Transport { source, idempotency_key: None })?; + Ok(Self { + http, + base_url: base_url.unwrap_or(DEFAULT_BASE_URL).trim_end_matches('/').to_string(), + api_key, + grant_token: None, + }) + } + + pub fn from_env() -> Result { + let key = std::env::var("NEAR_INTENTS_AGENT_API_KEY").map_err(|_| Error::Invalid("NEAR_INTENTS_AGENT_API_KEY missing".into()))?; + let url = std::env::var("NEAR_INTENTS_AGENT_API_URL").ok(); + Self::new(key, url.as_deref()) + } + + /// A client that sends `X-Grant-Token` on delegated calls. One per session/assistant/bot. + pub fn for_grant(&self, grant_token: impl Into) -> Result { + let grant_token = grant_token.into(); + if !valid_token(&grant_token, "ngt_") { + return Err(Error::Invalid("grant_token is not an ngt_ token".into())); + } + Ok(Self { grant_token: Some(grant_token), ..self.clone() }) + } + + pub async fn request( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + body: Option<&Value>, + idempotency_key: Option<&str>, + grant: bool, + ) -> Result { + let dry = body.and_then(|b| b.get("dry")).and_then(Value::as_bool).unwrap_or(false); + let settlement = ["/swap", "/withdraw", "/transfer", "/shield", "/unshield"] + .iter() + .any(|suffix| path.ends_with(suffix)) + && !dry; + let mut request = self + .http + .request(method, format!("{}{}", self.base_url, path)) + .query(query) + .header("accept", "application/json") + .header("x-api-key", &self.api_key) + .timeout(Duration::from_secs(if settlement { 150 } else { 65 })); + if let Some(body) = body { + request = request.json(body); + } + if let Some(key) = idempotency_key { + request = request.header("idempotency-key", key); + } + if grant { + let token = self + .grant_token + .as_deref() + .ok_or_else(|| Error::Invalid("this call needs a grant: use for_grant(token)".into()))?; + request = request.header("x-grant-token", token); + } + let transport = |source| Error::Transport { source, idempotency_key: idempotency_key.map(String::from) }; + let response = request.send().await.map_err(transport)?; + let status = response.status().as_u16(); + let bytes = response.bytes().await.map_err(transport)?; + let document: Value = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap_or(Value::Null) }; + if status >= 400 { + let first = &document["errors"][0]; + return Err(Error::Api { + status, + code: first["code"].as_str().unwrap_or("http_error").into(), + title: first["title"].as_str().unwrap_or("HTTP error").into(), + detail: first["detail"].as_str().map(String::from), + retryable: first["meta"]["retryable"].as_bool().unwrap_or(false), + available_at: first["meta"]["available_at"].as_str().map(String::from), + request_id: document["meta"]["request_id"].as_str().map(String::from), + errors: document["errors"].as_array().cloned().unwrap_or_default(), + idempotency_key: idempotency_key.map(String::from), + }); + } + Ok(document) + } + + async fn get(&self, path: &str, query: &[(&str, String)]) -> Result { + self.request(Method::GET, path, query, None, None, false).await + } + + // -- owner intents ------------------------------------------------------------------------ + + pub async fn generate_intent(&self, request: &Value, idempotency_key: &str) -> Result { + self.request(Method::POST, "/v1/generate-intent", &[], Some(request), Some(idempotency_key), false).await + } + + pub async fn submit_intent(&self, intent_type: &str, correlation_id: &str, signed_data: Value) -> Result { + let body = json!({ "type": intent_type, "correlation_id": correlation_id, "signed_data": signed_data }); + self.request(Method::POST, "/v1/submit-intent", &[], Some(&body), None, false).await + } + + pub async fn get_status(&self, correlation_id: &str, wait_ms: Option) -> Result { + let mut query = vec![("correlation_id", correlation_id.to_string())]; + if let Some(wait) = wait_ms { + query.push(("wait_ms", wait.to_string())); + } + self.get("/v1/status", &query).await + } + + /// Long-polls until the operation stops moving (anything but PROCESSING/QUEUED). + /// UNCERTAIN: keep observing the same id; never resubmit under a new key. + pub async fn wait_for_status(&self, correlation_id: &str, deadline: Duration) -> Result { + let end = Instant::now() + deadline; + loop { + let status = self.get_status(correlation_id, Some(30_000)).await?; + let moving = matches!(status["status"].as_str(), Some("PROCESSING" | "QUEUED")); + if !moving || Instant::now() > end { + return Ok(status); + } + } + } + + // -- reads --------------------------------------------------------------------------------- + + pub async fn get_network(&self) -> Result { + self.get("/v1/network", &[]).await + } + pub async fn get_tokens(&self) -> Result { + Ok(self.get("/v1/tokens", &[]).await?["data"].take()) + } + pub async fn whoami(&self) -> Result { + self.get("/v1/whoami", &[]).await + } + pub async fn get_agent(&self, agent_id: &str) -> Result { + self.get(&format!("/v1/agents/{agent_id}"), &[]).await + } + pub async fn get_policy(&self, agent_id: &str) -> Result { + self.get(&format!("/v1/agents/{agent_id}/policy"), &[]).await + } + pub async fn get_balances(&self, agent_id: &str, source: &str) -> Result { + self.get(&format!("/v1/agents/{agent_id}/balances"), &[("source", source.into())]).await + } + + // -- executions ----------------------------------------------------------------------------- + + /// Dry quote: no grant, no idempotency key, no budget charge. + pub async fn quote_swap(&self, agent_id: &str, mut request: Value) -> Result { + request["dry"] = json!(true); + self.request(Method::POST, &format!("/v1/agents/{agent_id}/swap"), &[], Some(&request), None, false).await + } + + /// `action`: swap | withdraw | transfer | shield | unshield | recover. Needs `for_grant`. + pub async fn execute(&self, agent_id: &str, action: &str, request: &Value, idempotency_key: &str) -> Result { + let path = format!("/v1/agents/{agent_id}/{action}"); + self.request(Method::POST, &path, &[], Some(request), Some(idempotency_key), true).await + } + + /// Inbound deposit address: API key + Idempotency-Key only, no grant. + pub async fn deposit(&self, agent_id: &str, request: &Value, idempotency_key: &str) -> Result { + let path = format!("/v1/agents/{agent_id}/deposit"); + self.request(Method::POST, &path, &[], Some(request), Some(idempotency_key), false).await + } +} + +// ------------------------------------------------------------------------------ NEAR signing +// Exact byte formats; verified against near-api-js. Never rebuild or reorder a payload. + +/// A NEAR ed25519 key from `ed25519:` (near-cli / near-api-js format). +pub struct NearKey { + signing: SigningKey, +} + +impl NearKey { + pub fn from_secret(secret: &str) -> Result { + let raw = bs58::decode(secret.trim_start_matches("ed25519:")) + .into_vec() + .map_err(|e| Error::Invalid(e.to_string()))?; + let seed: [u8; 32] = raw.get(..32).and_then(|s| s.try_into().ok()).ok_or_else(|| Error::Invalid("bad key".into()))?; + Ok(Self { signing: SigningKey::from_bytes(&seed) }) + } + pub fn public_bytes(&self) -> [u8; 32] { + self.signing.verifying_key().to_bytes() + } + pub fn public_key(&self) -> String { + format!("ed25519:{}", bs58::encode(self.public_bytes()).into_string()) + } + fn sign(&self, message: &[u8]) -> [u8; 64] { + self.signing.sign(message).to_bytes() + } +} + +fn borsh_string(out: &mut Vec, value: &str) { + out.extend((value.len() as u32).to_le_bytes()); + out.extend(value.as_bytes()); +} + +fn str_field<'a>(value: &'a Value, name: &str) -> Result<&'a str> { + value[name].as_str().ok_or_else(|| Error::Invalid(format!("payload.{name} missing"))) +} + +fn u_field(value: &Value, name: &str) -> Result { + str_field(value, name)?.parse().map_err(|_| Error::Invalid(format!("{name} is not an integer"))) +} + +/// NEP-413 signature for an `nep413` payload. Returns `{ public_key, signature }`. +pub fn sign_nep413(key: &NearKey, payload: &Value) -> Result { + let nonce = B64.decode(str_field(payload, "nonce")?).map_err(|e| Error::Invalid(e.to_string()))?; + if nonce.len() != 32 { + return Err(Error::Invalid("nonce must be 32 bytes".into())); + } + let mut body = NEP413_TAG.to_le_bytes().to_vec(); + borsh_string(&mut body, str_field(payload, "message")?); + body.extend(&nonce); + borsh_string(&mut body, str_field(payload, "recipient")?); + body.push(0); // callbackUrl: None + let signature = key.sign(&Sha256::digest(&body)); + Ok(json!({ "public_key": key.public_key(), "signature": B64.encode(signature) })) +} + +/// Borsh `DelegateAction` (without the NEP-461 prefix) for an `nep366` payload. +pub fn encode_delegate_action( + sender_id: &str, + payload: &Value, + public_key: &[u8; 32], + nonce: u64, + max_block_height: u64, +) -> Result> { + let actions = payload["actions"].as_array().ok_or_else(|| Error::Invalid("payload.actions missing".into()))?; + let mut out = Vec::new(); + borsh_string(&mut out, sender_id); + borsh_string(&mut out, str_field(payload, "receiverId")?); + out.extend((actions.len() as u32).to_le_bytes()); + for action in actions { + let params = &action["params"]; + out.push(2); // Action::FunctionCall + borsh_string(&mut out, str_field(params, "methodName")?); + // Same bytes as JavaScript JSON.stringify(args): compact, key order preserved. + let args = serde_json::to_vec(¶ms["args"]).map_err(|e| Error::Invalid(e.to_string()))?; + out.extend((args.len() as u32).to_le_bytes()); + out.extend(args); + out.extend((u_field(params, "gas")? as u64).to_le_bytes()); + out.extend(u_field(params, "deposit")?.to_le_bytes()); + } + out.extend(nonce.to_le_bytes()); + out.extend(max_block_height.to_le_bytes()); + out.push(0); // KeyType::ED25519 + out.extend(public_key); + Ok(out) +} + +/// Base64 borsh `SignedDelegate`, given the owner's access-key nonce and the latest block height. +pub fn sign_delegate(key: &NearKey, sender_id: &str, payload: &Value, access_key_nonce: u64, block_height: u64) -> Result { + let action = encode_delegate_action(sender_id, payload, &key.public_bytes(), access_key_nonce + 1, block_height + 120)?; + let mut prefixed = NEP366_TAG.to_le_bytes().to_vec(); + prefixed.extend(&action); + let signature = key.sign(&Sha256::digest(&prefixed)); + let mut signed = action; + signed.push(0); // KeyType::ED25519 + signed.extend(signature); + Ok(B64.encode(signed)) +} + +/// NEP-366 delegate action for an `nep366` payload. Returns `{ signed_delegate }`. +pub async fn sign_nep366(key: &NearKey, account_id: &str, payload: &Value, rpc_url: &str) -> Result { + let body = json!({ + "jsonrpc": "2.0", "id": "access-key", "method": "query", + "params": { "request_type": "view_access_key", "finality": "final", + "account_id": account_id, "public_key": key.public_key() } + }); + let rpc: Value = reqwest::Client::new() + .post(rpc_url) + .json(&body) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|source| Error::Transport { source, idempotency_key: None })? + .json() + .await + .map_err(|source| Error::Transport { source, idempotency_key: None })?; + let result = &rpc["result"]; + let (Some(nonce), Some(height)) = (result["nonce"].as_u64(), result["block_height"].as_u64()) else { + return Err(Error::Invalid(format!("view_access_key failed: {rpc}"))); + }; + Ok(json!({ "signed_delegate": sign_delegate(key, account_id, payload, nonce, height)? })) +} + +/// EIP-712 signature (0x + 65 bytes) for an `eip712` payload. The payload includes `EIP712Domain`. +#[cfg(feature = "evm")] +pub async fn sign_eip712(private_key_hex: &str, payload: &Value) -> Result { + use alloy_signer::Signer as _; + let typed: alloy_dyn_abi::TypedData = serde_json::from_value(payload.clone()).map_err(|e| Error::Invalid(e.to_string()))?; + let hash = typed.eip712_signing_hash().map_err(|e| Error::Invalid(e.to_string()))?; + let signer: alloy_signer_local::PrivateKeySigner = private_key_hex.parse().map_err(|e: alloy_signer_local::LocalSignerError| Error::Invalid(e.to_string()))?; + let signature = signer.sign_hash(&hash).await.map_err(|e| Error::Invalid(e.to_string()))?; + Ok(json!({ "signature": format!("0x{}", hex::encode(signature.as_bytes())) })) +} + +/// `signed_data` for a `generate-intent` response, chosen by `intent.standard`. +/// `webauthn` (passkeys) can only be signed by the owner's authenticator in a browser. +pub async fn sign_intent(generated: &Value, near_key: Option<&NearKey>, rpc_url: &str, evm_key: Option<&str>) -> Result { + let intent = &generated["intent"]; + let payload = &intent["payload"]; + let extra = match intent["standard"].as_str() { + Some("nep413") => sign_nep413(near_key.ok_or_else(|| Error::Invalid("nep413 needs the NEAR key".into()))?, payload)?, + Some("nep366") => { + let key = near_key.ok_or_else(|| Error::Invalid("nep366 needs the NEAR key".into()))?; + sign_nep366(key, str_field(&generated["signer"], "account_id")?, payload, rpc_url).await? + } + #[cfg(feature = "evm")] + Some("eip712") => sign_eip712(evm_key.ok_or_else(|| Error::Invalid("eip712 needs the EVM key".into()))?, payload).await?, + other => return Err(Error::Invalid(format!("cannot sign {other:?} here (evm_key: {})", evm_key.is_some()))), + }; + let mut signed = intent.clone(); + for (name, value) in extra.as_object().into_iter().flatten() { + signed[name] = value.clone(); + } + Ok(signed) +} diff --git a/.agents/skills/near-intents-agent-api/assets/rust/tests/vectors.rs b/.agents/skills/near-intents-agent-api/assets/rust/tests/vectors.rs new file mode 100644 index 000000000..8521ffc84 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/assets/rust/tests/vectors.rs @@ -0,0 +1,33 @@ +//! Byte-for-byte checks against near-api-js / viem output. +//! Run: cargo test --features evm (reads ../test-vectors.json) +use near_intents_agent_api::*; +use serde_json::Value; + +fn vectors() -> Option { + let path = std::env::var("VECTORS").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../test-vectors.json").into()); + Some(serde_json::from_str(&std::fs::read_to_string(path).ok()?).unwrap()) +} + +#[tokio::test] +async fn matches_reference_signers() { + let Some(v) = vectors() else { return }; + let key = NearKey::from_secret(v["near"]["secret"].as_str().unwrap()).unwrap(); + assert_eq!(key.public_key(), v["near"]["public_key"]); + let nep413 = sign_nep413(&key, &v["nep413"]["payload"]).unwrap(); + assert_eq!(nep413["signature"], v["nep413"]["signature_b64"]); + let d = &v["nep366"]; + let nonce: u64 = d["access_key_nonce"].as_str().unwrap().parse().unwrap(); + let height: u64 = d["block_height"].as_str().unwrap().parse().unwrap(); + let signed = sign_delegate(&key, "owner.near", &d["payload"], nonce, height).unwrap(); + assert_eq!(signed, d["signed_delegate"].as_str().unwrap()); + #[cfg(feature = "evm")] + { + let e = &v["eip712"]; + let sig = sign_eip712(e["private_key"].as_str().unwrap(), &e["payload"]).await.unwrap(); + assert_eq!(sig["signature"], e["signature"]); + } + assert_eq!(grant_commitment(v["grant"]["token"].as_str().unwrap()), v["grant"]["commitment"]); + let (token, commitment) = create_grant_credential(); + assert_eq!(token.len(), 47); + assert_eq!(grant_commitment(&token), commitment); +} diff --git a/.agents/skills/near-intents-agent-api/assets/test-vectors.json b/.agents/skills/near-intents-agent-api/assets/test-vectors.json new file mode 100644 index 000000000..9fc6000d1 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/assets/test-vectors.json @@ -0,0 +1,122 @@ +{ + "near": { + "secret": "ed25519:2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSdZd8hbDHTd21as7EAsg7ypityqfsw2pMQKJcVDVcAEsd", + "public_key": "ed25519:9C6hybhQ6Aycep9jaUnP6uL9ZYvDjUp1aSkFWPUFJtpj", + "account_id": "owner.near" + }, + "nep413": { + "payload": { + "message": "{\"purpose\":\"grant_issue\",\"x\":\"ü\"}", + "recipient": "api.example.near", + "nonce": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=" + }, + "signature_b64": "kRPuoOdP8ZZ98mIhp8OUPlgePp2QKGXFpipHRPEl6tRUs7f+OOSrRYHVcEuzB8v8QbxspB3MPV9B2dMCdS8VCg==" + }, + "nep366": { + "payload": { + "receiverId": "outlayer.near", + "actions": [ + { + "type": "FunctionCall", + "params": { + "methodName": "set_policy", + "args": { + "z": 1, + "a": { + "b": "c", + "list": [ + 1, + "two" + ] + }, + "uni": "é" + }, + "gas": "100000000000000", + "deposit": "0" + } + }, + { + "type": "FunctionCall", + "params": { + "methodName": "freeze_wallet", + "args": {}, + "gas": "30000000000000", + "deposit": "1" + } + } + ] + }, + "access_key_nonce": "123456789", + "block_height": "987654321", + "encoded_hex": "6e0100400a0000006f776e65722e6e6561720d0000006f75746c617965722e6e65617202000000020a0000007365745f706f6c696379310000007b227a223a312c2261223a7b2262223a2263222c226c697374223a5b312c2274776f225d7d2c22756e69223a22c3a9227d00407a10f35a000000000000000000000000000000000000020d000000667265657a655f77616c6c6574020000007b7d00e057eb481b00000100000000000000000000000000000016cd5b07000000002969de3a000000000079b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "signed_delegate": "CgAAAG93bmVyLm5lYXINAAAAb3V0bGF5ZXIubmVhcgIAAAACCgAAAHNldF9wb2xpY3kxAAAAeyJ6IjoxLCJhIjp7ImIiOiJjIiwibGlzdCI6WzEsInR3byJdfSwidW5pIjoiw6kifQBAehDzWgAAAAAAAAAAAAAAAAAAAAAAAAINAAAAZnJlZXplX3dhbGxldAIAAAB7fQDgV+tIGwAAAQAAAAAAAAAAAAAAAAAAABbNWwcAAAAAKWneOgAAAAAAebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQATwCh7bJVOnxWKroYQQ8wdIfg7Pp8q1bGZD4KHFl0EGYJwoij7o7PjMVEUjPKcUYcngqx7dcJRm3ULIdczVCpDg==" + }, + "eip712": { + "private_key": "0x1111111111111111111111111111111111111111111111111111111111111111", + "address": "0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a", + "public_key": "0x4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa385b6b1b8ead809ca67454d9683fcf2ba03456d6fe2c4abe2b07f0fbdbb2f1c1", + "payload": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ], + "WalletMessage": [ + { + "name": "chainId", + "type": "string" + }, + { + "name": "signerId", + "type": "string" + }, + { + "name": "nonce", + "type": "uint32" + }, + { + "name": "createdAt", + "type": "string" + }, + { + "name": "timeoutSecs", + "type": "uint32" + }, + { + "name": "ops", + "type": "string" + }, + { + "name": "out", + "type": "string" + } + ] + }, + "primaryType": "WalletMessage", + "domain": { + "name": "NEAR Wallet Contract", + "version": "1" + }, + "message": { + "chainId": "mainnet", + "signerId": "0s1234.near", + "nonce": 42, + "createdAt": "2026-10-07T00:00:00.000Z", + "timeoutSecs": 300, + "ops": "[]", + "out": "{\"after\":[]}" + } + }, + "signature": "0x011bb890ad576627ab6dbce119777912693a1bb16ef0dcd673d08198108cb5b245a4db114560088db56b209680788b4b5d02fcd933947b01912f59da6c1d62111c" + }, + "grant": { + "token": "ngt_CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk", + "commitment": "d632d3d1f5df65812a61000fa4cca20cce14fe14a2e3fe5d3e6bab1a445fc43f" + } +} \ No newline at end of file diff --git a/.agents/skills/near-intents-agent-api/references/architecture.md b/.agents/skills/near-intents-agent-api/references/architecture.md new file mode 100644 index 000000000..884f756bf --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/architecture.md @@ -0,0 +1,97 @@ +# Designing an integration + +## Reference architecture + +``` +┌──────────── browser / app ────────────┐ ┌──────────── your backend ────────────┐ ┌── NEAR Intents Agent API ──┐ +│ owner wallet (NEAR/EVM/passkey) │ │ naa_ key (secret store) │ │ api.agentsonintents.com │ +│ shows preview, signs intent.payload │◄──────►│ BFF routes: generate / submit / read │◄──────►│ policy, grants, dispatch │ +│ never sees naa_ / ngt_ │ │ grant tokens (encrypted) │ │ → agent wallet, 1Click │ +└───────────────────────────────────────┘ │ AI agent runtime / tools │ └───────────────────────────┘ + │ jobs: status poller, network watch │ + └──────────────────────────────────────┘ +``` + +The API never sees owner keys; the browser never sees API keys or grant tokens; the AI model +never sees either. + +## What to persist + +| Table | Columns | Why | +|---|---|---| +| `agent_accounts` | `agent_id`, `user_id`, `name`, `owner_type`, `owner_descriptor`, `created_at` | map users ↔ agents (also send `external_user_id`) | +| `owner_intents` | `correlation_id`, `type`, `agent_id`, `user_id`, `status`, `expires_at` | BFF authorization for submit; UI state | +| `grants` | `grant_id`, `agent_id`, `label`, `token_ciphertext`, `expires_at`, `revoked_at`, `actor` (session/bot id) | the agent's credential; one per actor | +| `operations` | `correlation_id`, `agent_id`, `grant_id`, `action`, `idempotency_key`, `request_body`, `status`, `failure_code`, `created_at`, `updated_at` | idempotent retries, recovery, activity feed | + +Write the `operations` row (key + body) **before** the HTTP call; update it from responses and +the poller. The API is the source of truth; your table is a cache plus your retry ledger. + +## Background jobs + +- **Status poller**: for rows not in a terminal state, `GET /v1/status?wait_ms=…` with bounded + concurrency (≤10 long-polls per key). Skip `NEEDS_REVIEW` (alert instead). +- **Network watch**: `GET /v1/network` every minute; show degraded components in your UI and + pause automated agents when `custody` or `one_click` is `down`. +- **Grant hygiene**: revoke grants of ended sessions; rotate long-lived ones. + +## Key ownership models + +| Model | Owner key held by | Use when | Notes | +|---|---|---|---| +| **User-owned key** (recommended) | the end user's wallet | consumer apps, wallets, AI assistants | user signs in the browser; you never touch the key | +| **Company-as-owner** | your company (KMS/HSM key, EVM recommended) | treasury bots, B2B automation, internal agents | sign `eip712` digests with KMS; you hold that key and control its approvals; add your own approvals around it | +| **Passkey owner** | user's authenticator | mobile/web without crypto wallets | needs your WebAuthn registration flow; no owner approval votes | + +With company-as-owner, the policy still bounds the agent, so a compromised agent runtime cannot +exceed it; protect the owner key separately from the runtime that holds grant tokens. + +## Multi-tenant SaaS + +- One API key per environment; tag every agent with `external_user_id` = your user id and + filter with `GET /v1/agents?external_user_id=`. +- Enforce in your BFF that a user can only generate/submit for their own agents and intents. +- Default limits: 100 agents / 24 h per tenant, 100 deposit addresses / 24 h per agent; plan onboarding + bursts accordingly (`/v1/quotas`). + +## AI agent tool design + +Expose a small, safe tool surface to the model; your backend maps tools to API calls with the +grant of that conversation. + +| Tool | API | Notes | +|---|---|---| +| `get_balances` | `GET …/balances` (public + confidential) | format with `decimals` | +| `get_rules` | `GET …/policy` | summarize actions, destinations, budget remaining, timelock, schedule | +| `list_tokens` | `GET /v1/tokens` | cache; resolve symbols → `asset_id` | +| `quote_swap` / `quote_withdraw` | `dry: true` | always before executing | +| `swap` / `transfer` / `withdraw` | executions | server creates the idempotency key; require explicit user confirmation for amounts above a threshold | +| `deposit_address` | `POST …/deposit` | show address, memo, min amount, expiry | +| `check_operation` | `GET /v1/status` | returns status + hashes | +| `network_status` | `GET /v1/network` | when a route/quote fails | + +Do **not** give the model tools for owner intents (policy changes, grants, freeze, delete); those +belong to the owner's UI with a wallet signature. If the model is asked to "raise my limit", +reply with what the owner must sign and link to the UI. + +Convert amounts at the boundary: the model speaks decimal ("0.5 NEAR"), the tool converts with +token `decimals` to atomic strings, rejecting more fractional digits than `decimals`. + +For MCP servers: run the MCP server on your backend (or a local process for a single user) that +holds the grant token in its environment; one grant per MCP client installation. + +## Security checklist + +- [ ] `naa_` key only in server secret storage; separate keys per environment; rotate by + creating a new key, deploying, then revoking the old one. +- [ ] Grant tokens encrypted at rest, never logged, never in prompts, URLs or client bundles. +- [ ] One grant per actor; short expiry for sessions; revoke on logout/uninstall. +- [ ] BFF checks user ↔ agent ↔ correlation_id ownership on every route. +- [ ] Owner sees `preview.summary` + full policy before signing; deletes show `assets_lost`. +- [ ] Default policy is tight: explicit assets, per-transaction limits, `destinations: only`, + USD budget, and a timelock or owner approval for payouts. +- [ ] Idempotency keys persisted before sending; no new key for `UNCERTAIN`. +- [ ] `NEEDS_REVIEW` alerts a human. +- [ ] Mainnet writes behind explicit configuration flags in dev tooling. +- [ ] Logs carry `request_id` and `correlation_id`, never secrets or signatures. +- [ ] Freeze button available to the owner at all times (no cooldown on freeze). diff --git a/.agents/skills/near-intents-agent-api/references/concepts.md b/.agents/skills/near-intents-agent-api/references/concepts.md new file mode 100644 index 000000000..164e096a1 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/concepts.md @@ -0,0 +1,132 @@ +# Concepts + +## The problem it solves + +An AI agent that pays, swaps or withdraws needs money it can move without asking a human each +time, but the human must stay in control and the integrating app must not hold the owner's key. +The API splits that into three roles: + +- **Owner** — the human who owns the funds. Their wallet signs the account's rules and every + change to them. The API and the partner never hold the owner's key. +- **Agent account** — one wallet on NEAR Intents per agent, run by the wallet provider + (a TEE-backed signer). Funds sit there. It moves money only inside the owner-signed + policy. +- **Partner** — your backend. It holds the `naa_` API key, prepares owner actions, stores grant + tokens and calls executions for its agents. + +## Account, policy, grant + +``` +agent account (1 per agent) +├── owner one wallet: NEAR | EVM | passkey (fixed at creation, no rotation) +├── policy ONE complete, owner-signed rulebook: WHAT may happen +│ actions, assets, limits, destinations, USD budget, timelock, approval, frozen +└── grants 0..50 live grants: WHO may act + each = { grant_id, label, expires_at, sha256(token) } — token held by the partner +``` + +- **Policy = what.** Shared by every grant. Turning on `withdraw` or adding a destination is one + owner signature and every grant can use it immediately. There are no per-grant limits; all + grants draw on the same limits and USD budget. +- **Grant = who.** A bearer credential (`ngt_…`) for one session, assistant, bot or device. + It carries identity and expiry only. Revoke it to cut off that one actor. +- **Freeze** stops everyone (policy `frozen: true`) and takes effect at once. + +When an execution is refused, the error code names the layer that refused it, and only that +layer can lift it: + +| Code | Layer | Who fixes it | +|---|---|---| +| `agent_grant_required` | grant (missing, revoked, expired, wrong agent) | issue a grant / use the right token | +| `wallet_frozen` | policy `frozen` | owner `agent_unfreeze` | +| `policy_action_denied` | policy `actions` / `confidential` | owner `policy_update` | +| `policy_destination_denied` | policy `destinations` | owner `policy_update` | +| `spend_budget_exceeded` | policy `budget` (USD, shared) | owner raises cap, or wait for the window | +| `policy_schedule_denied` | policy `schedule` (owner's weekly hours) | submit again at `meta.available_at`, or owner changes `schedule` | +| `policy_denied` | provider-enforced policy (assets, per-asset limits, rate, …) | owner `policy_update` | +| `policy_not_ready` | latest policy not yet in force | wait for the pending policy intent | + +A bigger USD budget never lifts a per-asset limit and a new grant never lifts a policy rule. + +## Who enforces what + +| Policy field | Enforced by | Changing only these needs | +|---|---|---| +| `destinations`, `budget`, `timelock_ms`, `schedule` | the API, before dispatch | one **off-chain** signature (`nep413`/`eip712`/`webauthn`), no transaction | +| `frozen`, `actions`, `confidential`, `owner_approval`, `assets`, `limits`, `max_actions_per_hour` | the wallet provider, on chain | one **on-chain** signature (`nep366` for NEAR owners; EVM/passkey sign `eip712`/`webauthn` and the API sponsors the call) | + +The API picks which, based on what changed; you just sign whatever `intent.standard` says. +A policy is in force only when `GET …/policy` shows `status: "APPLIED"` and +`provider_policy_synced: true`. + +## Two kinds of writes + +| | Owner intents | Executions | +|---|---|---| +| Endpoints | `POST /v1/generate-intent` → wallet signs → `POST /v1/submit-intent` | `POST /v1/agents/{id}/swap|withdraw|transfer|shield|unshield|deposit` | +| Authorized by | owner's signature on that exact payload | `X-Grant-Token` + the policy (deposit: API key only) | +| Ids | `intent_…` | `op_…` | +| Retry safety | resubmitting the same signature is idempotent | `Idempotency-Key` | + +Both return a `correlation_id`; `GET /v1/status?correlation_id=` reads either. + +## Execution pipeline + +1. **Authorize** — grant live, not frozen, action/asset/limits/destination/budget allowed, and + the `schedule` open at the moment it would run (now + `timelock_ms`). + Refusal = error response, nothing recorded. +2. **Wait** — `timelock_ms > 0` → `QUEUED` until `execute_after` (owner may `execution_cancel`). + `owner_approval` → `PENDING_APPROVAL` until the owner's `approval_vote`. An `approve` + re-runs step 1 (grant live, not frozen, destination allowed; not the schedule) before the + release and refuses the vote when it fails; a `reject` releases the budget charge. A release + outside the `schedule` fails the operation; nothing is sent. +3. **Dispatch commit** — the `schedule` is checked once more, then `dispatch_committed_at` is + set and the request goes to the provider. + From here, revoking the grant/key or freezing does **not** stop this operation. +4. **Settle** — `PROCESSING` → `SUCCESS` | `REFUNDED` | `FAILED`, or `UNCERTAIN` / + `NEEDS_REVIEW` when the outcome cannot be proven. + +## Owners + +- **NEAR**: a named account (`alice.near`) and one of its **FullAccess** ed25519 keys. Signs + `nep366` (on-chain changes, gasless: the API's sponsor pays) and `nep413` (off-chain). +- **EVM**: any secp256k1 EOA (MetaMask, Rabby, a KMS key). The API derives a deterministic NEAR + account from the public key and deploys it on first use, sponsored. Signs `eip712`. No + EIP-1271 contract wallets. **Off by default for new agents** while the EVM wallet build is a + pre-release: a deployment without it answers `owner_type_unsupported` and leaves `evm` out of + `supported_owner_types` on `GET /v1/network`. Existing EVM-owned agents keep working. +- **Passkey**: WebAuthn ES256 credential; also gets a derived NEAR account. Signs `webauthn` in + the browser only. + +The owner is fixed for the account's life: no key rotation, no recovery, no transfer of +ownership. Pick the owner type deliberately. Every owner type can use `owner_approval` and vote +with `approval_vote`; EVM and passkey votes are confirmed by the owner's `0s` wallet. + +## Money and assets + +- Assets are NEAR Intents assets identified by `asset_id` (`nep141:…`, `nep245:…`) from + `GET /v1/tokens`. Balances are per asset inside the agent's NEAR Intents account. +- **Public** balance is normal. **Confidential** balance is shielded (requires policy + `confidential: true`); `shield`/`unshield` move between them; `confidential: true` on + swap/withdraw/transfer spends from it. +- **Deposits** come in through 1Click deposit addresses on any supported chain, or directly on + NEAR. Deposits never need a grant and are always allowed. +- **Withdrawals** leave NEAR Intents to an external chain address. **Transfers** go to another + NEAR Intents account (no bridge). +- Swaps and withdrawals route through 1Click; `dry: true` returns its quote. + +## Lifecycle states + +`AgentView.status`: `PENDING` (created, not yet signed) → `ACTIVE`; `ARCHIVED` (hidden, nothing +changes on chain); `DELETED`; `ABANDONED` (creation expired or failed, never usable). + +## Cooldowns + +- Ordinary policy change: next one allowed 5 minutes after the last (per agent) → + `policy_change_throttled` (429, `meta.available_at`). Creating the account does not start it. +- Pure `agent_freeze` is never throttled (it is the emergency brake). +- `agent_unfreeze` has its own cooldown: an unfreeze is allowed 5 minutes after the previous + unfreeze → `agent_unfreeze_throttled`. So a freeze right after an unfreeze can leave the + account frozen for up to 5 minutes; check before freezing in tests and demos. +- `GET /v1/agents/{id}` → `cooldowns.policy_change_available_at` / `unfreeze_available_at` tell + you before you ask the owner to sign. diff --git a/.agents/skills/near-intents-agent-api/references/errors.md b/.agents/skills/near-intents-agent-api/references/errors.md new file mode 100644 index 000000000..b5b4851e4 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/errors.md @@ -0,0 +1,137 @@ +# Errors + +Every error is a JSON:API document: + +```json +{ + "errors": [{ + "status": "409", + "code": "policy_revision_conflict", + "title": "Policy revision conflict", + "detail": "The policy changed since you read it. Read GET /agents/{agent_id}/policy and generate again with its revision.", + "source": { "pointer": "/expected_revision" }, + "meta": { "retryable": false, "available_at": "2026-10-07T10:10:00Z" } + }], + "meta": { "request_id": "…" } +} +``` + +- Branch on `errors[0].code`. `title`/`detail` are for humans and may change. +- Validation failures (`400 validation_failed`) return **one entry per field** with + `source.pointer`; show all of them. +- `meta.retryable: true` → the same request (same idempotency key and body) can succeed after + `Retry-After`. Server failures carry it too: they may have taken effect, so only the same key is + safe. Absent → repeating will not help now. It says **nothing** about whether an earlier + attempt took effect; a new key is allowed only after a proven refusal (retry rule 4). Never + decide from `detail`. +- `meta.available_at` → when a cooldown/quota/throttle lifts. 429/503 also send `Retry-After`. +- Unknown code → fall back to the HTTP status class. Always log `meta.request_id`. + +## Decision table + +| Code | HTTP | What to do | +|---|---|---| +| **Auth & request** | | | +| `invalid_api_key` | 401 | wrong or revoked key, stray whitespace, or a base URL from a different deployment; fix config, don't retry | +| `validation_failed` | 400 | fix the fields in `source.pointer` | +| `invalid_json`, `invalid_cursor` | 400 | fix the request | +| `idempotency_key_required` | 400 | add `Idempotency-Key` | +| `idempotency_conflict` | 409 | same key used with a different body; resend original body or use a new key for a genuinely new request | +| `rate_limited` | 429 | wait `Retry-After`, retry the same request | +| `database_busy` | 503 | retryable; may have taken effect → retry with the **same** idempotency key, never a new one | +| **Refusals (layer named; only that layer can lift it)** | | | +| `agent_grant_required` | 403 | missing/revoked/expired/foreign grant → issue a grant or use the right token | +| `policy_action_denied` | 403 | action (or confidential form) not in policy → owner `policy_update` | +| `policy_destination_denied` | 403 | address not allowed → owner adds it to `destinations` | +| `policy_schedule_denied` | 403 | outside the owner's `schedule` when it would run (after the timelock); nothing recorded → submit the same request at `meta.available_at`, or owner changes `schedule` | +| `spend_budget_exceeded` | 403 | shared USD budget used up → wait for window (`usage.budget.*.resets_at`) or owner raises cap; retry with a **new** key | +| `policy_denied` | 409 | provider-side policy refused (asset, per-asset limit, hourly rate, …) → owner `policy_update` | +| `wallet_frozen` | 409 | owner froze the account → only `agent_unfreeze` resumes | +| `policy_not_ready` | 409 | latest policy not yet in force → wait for the policy intent to settle | +| `signing_policy_denied` | 403 | recipient not in the policy's `sign.recipients` | +| `signing_recipient_forbidden` | 403 | `intents.near`/`intents.far` are never signing recipients | +| **Execution / routing** | | | +| `insufficient_balance` | 409 | fund the account or lower amount | +| `amount_too_low` | 400 | below route minimum → quote a larger amount | +| `route_unavailable` | 400 | no route/liquidity now; nothing submitted → other pair or later, new key | +| `quote_unavailable` | 503 | upstream quote got no answer; nothing submitted → retry shortly, new key; check `/v1/network` | +| `unsupported_chain`, `unsupported_token`, `invalid_address` | 400/409 | change chain/asset/address | +| `wrapped_unsupported` | 400 | drop `wrapped`; it only applies to NEAR withdrawn to chain `near` | +| `wallet_busy` | 409 | another money op on this wallet is running; nothing sent, refusal recorded (not retryable) → after it finishes, a **new** key | +| `spend_price_unavailable` | 503 | no fresh USD price for budget accounting → retry later, new key | +| `authorization_stale` | 409 | grant/key revoked or policy/owner changed between admission and dispatch → re-evaluate | +| `deposit_quota_exceeded` | 429 | 100 deposit addresses / 24 h for this agent → wait `available_at` | +| **Provider (wallet service/1Click) state** | | | +| `provider_unavailable` | 503 | provider down/breaker open → wait; read `/v1/network` | +| `provider_limit_reached` | 429 | custody provider's daily quota for this wallet: 100 operations / rolling 24 h, `dry` previews included, quotes not; nothing sent → new work at `available_at` (don't burn the quota on repeated previews) | +| `provider_rate_limited` | 429 | reads: wait `Retry-After`, same request; executions: recorded refusal → new key after a short wait | +| `provider_refused` | 409 | provider refused for its own reason → inspect `detail`, don't blind-retry | +| `provider_response_invalid`, `provider_request_mismatch` | 502 | treat outcome as unknown → poll status of the same id | +| **Owner intents** | | | +| `intent_expired` | 409 | generate a new intent | +| `intent_payload_mismatch`, `intent_standard_mismatch`, `intent_type_mismatch` | 409 | submit exactly what was generated, with the right standard/type | +| `signature_invalid` | 400 | malformed wallet output → submit exactly what the wallet returned | +| `signer_mismatch`, `owner_proof_invalid`, `policy_delegate_signature_invalid` | 401/409 | wrong key or altered bytes (see [signing.md](signing.md)) | +| `owner_key_not_full_access` | 403 | NEAR owner must sign with a FullAccess key | +| `owner_nonce_invalid` | 409 | intent used/expired → regenerate | +| `intent_generation_recovery_required` | 409 | retry `generate-intent` with the same idempotency key | +| `policy_revision_conflict` | 409 | re-read policy, regenerate with new `expected_revision`, re-sign | +| `policy_reconciliation_required` | 409 | another policy intent still open → wait | +| `policy_change_throttled`, `agent_unfreeze_throttled` | 429 | cooldown → wait `available_at` | +| `wallet_already_frozen`, `wallet_already_unfrozen` | 409 | no-op; state already as requested | +| `owner_type_unsupported` | 400 | this deployment does not accept that owner type for new agents (check `supported_owner_types` on `GET /v1/network`; EVM is off by default until the final EVM wallet build) | +| `agent_creation_quota_exceeded` | 429 | 100 agents/24 h → wait or request quota | +| `agent_not_bound` | 409 | onboarding not finished → wait for `agent_create` SUCCESS | +| `agent_archived`, `agent_deleted`, `agent_deletion_pending` | 409 | lifecycle state forbids it; restore or stop | +| `pause_before_archive`, `unpause_before_restore` | 409 | `agent_freeze` before `agent_archive`; `agent_unfreeze` before `agent_restore` | +| `sponsor_busy`, `sponsor_balance_insufficient` | 503 | retry the same submit later | +| `transaction_unconfirmed` | 503 | retryable; broadcast but unconfirmed → poll status, or repeat with the **same** key (never sends twice); never as new work | +| `execution_not_cancellable` | 409 | op already left `QUEUED` | +| `approval_closed`, `approval_already_voted`, `approval_not_allowed` | 409 | read the approval's current state | +| `approval_signature_invalid` | 401 | the vote was not signed by the owner's key or wallet → regenerate and sign again | +| `approval_vote_unsupported` | 409 | the provider lists no such owner wallet build on this network → ask the operator; nothing was stored | +| **Grants** | | | +| `grant_limit_reached` | 409 | 50 live grants → revoke unused | +| `grant_credential_reused` | 409 | new token per grant | +| `grant_expiry_invalid` | 409 | `expires_at` must be future and ≤365 days | +| **Recovery** | | | +| `operation_not_recoverable` | 409 | not UNCERTAIN, already dispatched, non-recoverable kind, or other API key → keep polling | +| `operation_recovery_unavailable` | 409 | another recovery running → poll | +| **Not found** | 404 | `agent_not_found`, `grant_not_found`, `intent_not_found`, `operation_not_found`, `status_not_found`, `approval_not_found` | +| **Disabled** | | | +| `transparency_log_disabled` | 501 | this deployment keeps no transparency log; operation proofs are unavailable | + +## Retry rules + +1. **Client timeout or network error on a write**: you don't know if it landed. Retry with the + **same** `Idempotency-Key` and identical body (safe), or read status if you got a + `correlation_id`. Never invent a new key for the same intent to "unstick" it. +2. **Refusal codes (403, most 409)**: the request is wrong for the current state. Retrying the + same thing won't help; change state (policy, grant, balance) or the request. +3. **Server failures** (`internal_error` 500, `database_busy` 503, `transaction_unconfirmed` 503, + all with `meta.retryable`): the request may have taken effect. Repeat it with the **same** key, + which replays or reconciles it, or read status. Never a new key: that can spend twice. +4. **Proven refusals** (`route_unavailable`, `quote_unavailable`, `spend_budget_exceeded`, + `spend_price_unavailable`, `wallet_busy`, and the policy/grant/validation refusals in the + decision table): nothing was sent and the key replays the refusal; a later attempt is a new + request → **new** idempotency key. Only these permit a new key. A `FAILED` or `REFUNDED` + status is **not** such proof: a partial failure is `FAILED`. Read its `details` and the + balances, and send new work only as an explicit decision, never as an automatic retry. +5. **Earlier work under the key** (`intent_generation_recovery_required`, + `operation_not_recoverable`, `operation_not_pending`, any error from `/recover`, and any error + after a server failure or lost response): the original outcome may still be unknown. Keep the original key and + `correlation_id`, read status, and never start the same work under a new key. +6. **Throttles** (429): wait until `available_at` / `Retry-After`. +7. **Status-level outcomes** (`FAILED`, `REFUNDED`, `UNCERTAIN`, `NEEDS_REVIEW`) are not HTTP + errors; see [status-and-recovery.md](status-and-recovery.md). + +## Wrapping errors in your code + +TypeScript SDK: `AgentApiError { status, code, title, detail, retryable, availableAt, requestId, +errors, idempotencyKey }`; transport failures throw `AgentApiRequestError { idempotencyKey }`. +The Python/Rust assets mirror this (`AgentApiError.code`, `.available_at`, …; Rust +`Error::Api { … }` with `error.code()`). + +For AI agents, convert errors into short tool results that name the layer and the fix, e.g. +`"refused by account policy: withdraw is not enabled. Ask the owner to allow withdrawals."` — +never echo API keys, grant tokens, or full request headers into model context. diff --git a/.agents/skills/near-intents-agent-api/references/examples.md b/.agents/skills/near-intents-agent-api/references/examples.md new file mode 100644 index 000000000..f61a75040 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/examples.md @@ -0,0 +1,63 @@ +# Runnable examples + +Repository: (TypeScript, Node 24+, pnpm, +`@near-intents-agent-api/sdk`, `near-api-js`, `viem`). Every flow in this skill has a script there; +port the script when writing another language. + +```bash +git clone https://github.com/NEAR-Intents-Agent-API/examples && cd examples +cp .env.example .env # NEAR_INTENTS_AGENT_API_KEY from the partner dashboard; owner keys for writes +pnpm install +pnpm 01:check-api +``` + +Safety switches: writes refuse to run unless `AGENT_ALLOW_WRITES=true`; swaps, withdrawals and +other live actions stay in preview unless `AGENT_EXECUTE=true`. The hosted API is mainnet: use a +dedicated low-value owner account. + +| Script | File | Shows | +|---|---|---| +| `01:check-api` | `01-getting-started/01-check-api.ts` | network, whoami, quotas, token catalogue | +| `01:list-tokens` | `01-getting-started/02-list-tokens.ts` | token catalogue, decimals, prices | +| `02:create-agent` | `02-your-first-agent/01-create-agent.ts` | `agent_create` with a NEAR owner (NEP-366) | +| `02:inspect-agent` | `02-your-first-agent/02-inspect-agent.ts` | agent, wallet, NEAR address, policy usage and history, balances, grants, history, provider records | +| `02:multiple-agents` | `02-your-first-agent/03-multiple-agents.ts` | several agents per owner | +| `03:near-policy-local` | `03-owner-wallets/near/01-policy-local.ts` | budget/timelock-only edit (NEP-413, off chain) | +| `03:near-policy-provider` | `03-owner-wallets/near/02-policy-provider.ts` | provider-rule edit (NEP-366, on chain) | +| `03:evm-create-agent` | `03-owner-wallets/evm/01-create-agent.ts` | EVM owner onboarding (EIP-712) | +| `03:evm-policy-update` | `03-owner-wallets/evm/02-policy-update.ts` | EVM policy edit | +| `04:issue-grant` | `04-grants/01-issue-grant.ts` | token + commitment, `grant_issue` | +| `04:delegate-client` | `04-grants/02-delegate-client.ts` | `forGrant` client, dry swap | +| `04:revoke-and-audit` | `04-grants/03-revoke-and-audit.ts` | `grant_revoke`, containment | +| `05:quote-and-swap` | `05-money-moves/01-quote-and-swap.ts` | dry quote, swap | +| `05:transfer` | `05-money-moves/02-transfer.ts` | transfer to a NEAR Intents account | +| `05:withdraw` | `05-money-moves/03-withdraw.ts` | dry withdraw, withdraw | +| `05:deposit` | `05-money-moves/04-deposit.ts` | deposit address, min amount, memo, expiry | +| `05:private-balance` | `05-money-moves/05-private-balance.ts` | shield / unshield | +| `05:approvals` | `05-money-moves/06-approvals.ts` | `owner_approval`, approval list, `approval_vote` | +| `06:edit-policy` | `06-policy-and-lifecycle/01-edit-policy.ts` | read → modify → sign complete policy | +| `06:freeze` | `06-policy-and-lifecycle/02-freeze-and-unfreeze.ts` | emergency stop, cooldown-aware | +| `06:archive-and-delete` | `06-policy-and-lifecycle/03-archive-and-delete.ts` | archive, restore, delete preview | +| `06:schedule` | `06-policy-and-lifecycle/04-schedule.ts` | weekly hours, `policy_schedule_denied` | +| `07:enable-signing` | `07-identity-signing/01-enable-signing.ts` | owner adds a `sign` recipient (on chain) | +| `07:sign-and-verify` | `07-identity-signing/02-sign-and-verify.ts` | challenge → `sign` → relying-party verification | +| `07:refusals` | `07-identity-signing/03-refusals.ts` | every signing refusal, incl. `intents.near`/`intents.far` | +| `07:disable-signing` | `07-identity-signing/04-disable-signing.ts` | remove one recipient or `sign` entirely | +| `08:error-taxonomy` | `08-errors-and-recovery/01-error-taxonomy.ts` | real error documents | +| `08:status-and-uncertain` | `08-errors-and-recovery/02-status-and-uncertain.ts` | classify a status; when recovery is legal | +| `08:idempotency` | `08-errors-and-recovery/03-idempotency.ts` | replay with the same key | +| `08:recover` | `08-errors-and-recovery/04-recover.ts` | when `/recover` is legal, with the original key and grant | +| `09:reads` / `09:owner-flow` / `09:execution` / `09:sign` | `09-raw-http/*` | the same flows with plain `fetch` (best template for other languages) | +| `10:bff-pattern` | `10-recipes/01-bff-pattern.ts` | browser ↔ backend boundary | +| `10:timelocked-payout` | `10-recipes/02-timelocked-payout.ts` | timelock, scheduled list, cancel | +| `10:ai-assistant` | `10-recipes/03-ai-assistant.ts` | grant per assistant, tool → API mapping | +| `10:full-journey` | `10-recipes/04-full-journey.ts` | create → grant → fund → quote → swap | +| `11:verify-operation` | `11-verify-operations/01-verify-operation.ts` | operation proof and its evidence, `verifyOperationProof` offline | + +Useful support modules to copy: `support/sign-near-intent.ts` (server-side NEAR signer: +NEP-413 + NEP-366), `support/evm-owner.ts` (EVM owner descriptor + EIP-712), +`support/policy.ts` (policy builders), `support/destinations.ts`, `support/flow.ts` +(generate → sign → submit → wait), `support/raw-http.ts` (fetch client with JSON:API errors), +`support/identity.ts` (identity challenges and NEP-413 signature verification). + +Python and Rust equivalents of the client and signers ship with this skill in `assets/`. diff --git a/.agents/skills/near-intents-agent-api/references/executions.md b/.agents/skills/near-intents-agent-api/references/executions.md new file mode 100644 index 000000000..8c33e639a --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/executions.md @@ -0,0 +1,134 @@ +# Executions: moving money + +All are `POST /v1/agents/{agent_id}/` and return `202` with a `StatusResponse` +(`correlation_id: "op_…"`), except dry quotes (`200`). + +| Action | Headers | Body | Policy needs | +|---|---|---|---| +| `swap` | key, grant, idem | `origin_asset`, `destination_asset`, `amount`, `min_amount_out?`, `confidential?`, `dry?` | `swap` (+`confidential`) | +| `withdraw` | key, grant, idem | `asset`, `amount`, `chain`, `recipient`, `memo?`, `confidential?`, `wrapped?`, `async?`, `dry?` | `withdraw`, destination allowed | +| `transfer` | key, grant, idem | `asset`, `amount`, `recipient`, `confidential?` | `transfer`, destination allowed | +| `shield` / `unshield` | key, grant, idem | `asset`, `amount` | `confidential: true` | +| `deposit` | key, idem | `origin_asset`, `destination_asset?`, `amount?`, `confidential?` | nothing (always allowed) | +| `recover` | key, grant, original idem | `correlation_id`, `request: {type, …original body}` | re-authorized in full | + +`amount` is atomic (integer string). Asset ids come from `GET /v1/tokens`. + +## Quote first + +```json +POST …/swap { "origin_asset": "nep141:wrap.near", "destination_asset": "", "amount": "1000000000000000000000000", "dry": true } +POST …/withdraw { "asset": "", "amount": "5000000", "chain": "base", "recipient": "0xabc…", "dry": true } +→ 200 { "dry": true, "type": "swap", "quote": { …1Click quote, snake_cased… } } +``` + +Dry runs need no grant and no idempotency key, charge no budget and move nothing. The `quote` +object belongs to 1Click (amount out, fees, time estimate); treat its fields as informational +and do not hard-code its shape. Use it to show the user expected output and to choose +`min_amount_out` (slippage floor). + +## Execute + +```http +POST /v1/agents/{agent_id}/swap +X-API-Key: naa_… +X-Grant-Token: ngt_… +Idempotency-Key: swap:user123:2026-10-07T10:00:00Z:7f3c +Content-Type: application/json + +{ "origin_asset": "nep141:wrap.near", "destination_asset": "", "amount": "1000000000000000000000000", "min_amount_out": "2900000" } +``` + +Then poll `GET /v1/status?correlation_id=op_…&wait_ms=30000` (see +[status-and-recovery.md](status-and-recovery.md)). Typical paths: + +- no timelock/approval: `PROCESSING` → `SUCCESS` (`details.amount_out`, `intent_hash`, `tx_hash`) +- timelock: `QUEUED` (`details.execute_after`) → `PROCESSING` → … +- approval: `PENDING_APPROVAL` (`details.approval_id`) → owner votes → `PROCESSING` → … +- route refunded: `REFUNDED` (`details.refund_tx_hash`); never filled: `FAILED` with `failure_code` + +The execution call holds the HTTP request open up to ~2 minutes while the provider answers; give +the client a ~150 s timeout. `withdraw` with `async: true` returns as soon as it is accepted. + +## Swap + +- Inside the account; funds never leave NEAR Intents. Needs no destination rule. +- `min_amount_out`: atomic floor of the destination asset; the swap refunds rather than fill + below it. +- `amount_too_low`: below the route minimum (confidential swaps have their own minimum). +- `route_unavailable` / `quote_unavailable`: nothing was submitted; retry later with a **new** + idempotency key or pick another pair. + +## Withdraw + +- Leaves NEAR Intents to `recipient` on `chain`. Public and confidential withdrawals reach every + `blockchain` value `GET /v1/tokens` lists (`near`, `eth`, `sol`, `btc`, `tron`, …). +- `memo` for chains/exchanges that need one (the policy's `only` rule matches memo exactly). +- NEAR (`nep141:wrap.near`, or `near`) withdrawn to chain `near` arrives as **native NEAR**, as from + a NEAR wallet or 1Click: no `wrap.near` registration needed, and what exchanges and invoices + expect. `wrapped: true` delivers wNEAR instead; only for that route (`wrapped_unsupported`). +- `unsupported_chain` means a chain the catalog doesn't list; `route_unavailable` means no + liquidity for that asset now; `invalid_address` when the provider rejects the address format. +- `details.destination_tx_hash` appears once the destination chain confirms. + +## Transfer + +- To another NEAR Intents account (`recipient` = NEAR account id), no bridge, near-instant. + A recipient is a named account (`alice.near`), a 64-hex implicit account or a lowercase `0x` + EVM address. +- `confidential: true` spends from and to confidential balances. Shield first if the agent's + confidential balance is short. +- **near.com addresses.** near.com shows its users' Intents account as `nearcom:` + (e.g. `nearcom:0x20b6…2367`). The API takes only the bare account: strip `nearcom:` and + lowercase the rest. near.com users see their confidential balance, so send them a + `confidential: true` transfer by default. A public transfer still works when the user asks for + one, but it lands in the recipient's legacy public balance, which near.com does not show by + default: tell the user before sending. A `nearcom:` address is not on any chain: never + `withdraw` to it. + +## Shield / unshield + +Move `amount` of `asset` between the public and confidential balance of the same account. +Read both with `GET …/balances?source=public|confidential`. + +## Deposit (fund the account) + +```http +POST /v1/agents/{agent_id}/deposit +X-API-Key: naa_… +Idempotency-Key: dep:user123: +{ "origin_asset": "", "amount": "25000000" } +``` + +- `origin_asset` is required: the token and chain the payer sends from. +- `amount` is optional. Without it, any payment ≥ `details.min_amount` before + `details.expires_at` is credited; with it, the quote is exact. +- `destination_asset` optional: swap to another asset on arrival. `confidential: true` credits the + confidential balance. +- There is **no `refund_to` request field**: refunds always go back to the agent account; + `details.refund_to` reports where. +- Response `PENDING_DEPOSIT` with `details.deposit_address` (send funds **only** there), + `details.memo` (include it if present), `details.min_amount`, `details.expires_at`. +- Then poll the `op_…` id: `PENDING_DEPOSIT` → `PROCESSING` → `SUCCESS`. +- Without `amount` an address stays open 3 days; with it, 24 h. Each lookup issues a new address. +- Quota: 100 deposit addresses per agent per rolling 24 h → `deposit_quota_exceeded` (429). +- Native NEAR-side funding: `GET …/addresses/near` returns the account; send NEP-141 tokens to it. + +Never show the `correlation_id` as a payment address. Use a fresh idempotency key per deposit +request you want (reusing the key returns the same address). + +## Recover (rare) + +Only for `UNCERTAIN` operations that provably never reached the provider (no +`provider_request_id`, no `dispatch_committed_at`). Same API key, same grant token, same +`Idempotency-Key`, and `request` = the original body plus `type`. See +[status-and-recovery.md](status-and-recovery.md). + +## Pre-flight checklist for an agent tool + +1. `GET …/policy` in force? action allowed? destination listed? budget `remaining_usd` enough? +2. Balance enough? (`GET …/balances`) +3. Dry quote (swap/withdraw) and show it. +4. Create + persist the idempotency key and body, then send. +5. Poll to a stable status, report `SUCCESS`/`REFUNDED`/`FAILED` with hashes; for `UNCERTAIN` + say "outcome pending" and keep observing. diff --git a/.agents/skills/near-intents-agent-api/references/getting-started.md b/.agents/skills/near-intents-agent-api/references/getting-started.md new file mode 100644 index 000000000..254ad308c --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/getting-started.md @@ -0,0 +1,165 @@ +# Getting started: from partner key to a working agent + +This walks a new partner from zero to an agent that can move money. Every step is plain HTTP, so +it works the same from any language; SDK equivalents are in the language references. + +## 0. What you need + +| Thing | Where it comes from | Where it lives | +|---|---|---| +| API base URL | `https://api.agentsonintents.com` (mainnet) | config `NEAR_INTENTS_AGENT_API_URL` | +| Partner API key `naa_…` | Partner dashboard → **API keys** | backend secret store `NEAR_INTENTS_AGENT_API_KEY` | +| Owner wallet | The end user: NEAR account, EVM wallet or passkey | the user's device; never your server (unless you deliberately run the owner key, see [architecture.md](architecture.md)) | +| Grant token `ngt_…` | Generated by your backend, one per agent session | your database, encrypted | + +The hosted API is **mainnet with real funds**. Use small amounts and a dedicated owner account +while building. + +## 1. Get a partner API key + +1. Open the [partner dashboard](https://partners.near-intents.org/) and sign in. +2. Go to **API keys** → create a key, name it after the environment (`prod-backend`, + `staging`). +3. Copy the `naa_…` token **now**. It is shown once and only its prefix is stored. Keys do not + expire; revoke and replace them from the same page. + +The dashboard also shows **Overview** (operation counters), **Agents** (every agent your keys +created, with cooldowns), **Health** and **Settings**. A tenant can hold up to 10 unrevoked keys. +Revoking a key stops new requests at once; operations already past the dispatch commit still +finish and the revoke response lists them. + +Use one key per backend environment. All keys of a tenant see the same agents; agents belong to +the tenant, not the key, except that `/recover` must use the key that admitted the operation. + +## 2. Smoke test + +```bash +export NEAR_INTENTS_AGENT_API_URL=https://api.agentsonintents.com +export NEAR_INTENTS_AGENT_API_KEY=naa_... + +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/network" # public health + owner types +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/whoami" -H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" # {api_key_id, tenant_id} +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/quotas" -H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" # limits and usage +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/tokens" | head -c 600 # asset catalogue +``` + +Or run `scripts/check_api.sh` from this skill. A `401 invalid_api_key` means the key is wrong, +revoked, has stray whitespace, or was sent to a different deployment than the one that issued it. + +Default partner limits (check `/v1/quotas` for yours): 100 new agents per rolling 24 h, 10 API +keys, a per-minute request rate per tenant and per key, 100 deposit addresses per agent per 24 h, 10 +concurrent `wait_ms` long-polls per key, 50 live grants per agent. + +## 3. Pick tokens + +`GET /v1/tokens` returns `{ data: [{ asset_id, symbol, decimals, blockchain, price, … }] }`. +Everything (policy assets, limits, executions) uses `asset_id`, for example +`nep141:wrap.near` (wNEAR, 24 decimals) or the USDC id for the chain you want. Amounts are +atomic strings: `to_atomic("1.5", 24)`. `price` is USD per whole token, `null` when unknown. + +## 4. Create the agent account (owner signs once) + +```http +POST /v1/generate-intent +X-API-Key: naa_… +Content-Type: application/json + +{ + "type": "agent_create", + "name": "Trading assistant", + "external_user_id": "user_123", + "owner": { "type": "near", "account_id": "alice.near", "public_key": "ed25519:…" }, + "policy": { + "frozen": false, + "actions": ["swap"], + "confidential": false, + "owner_approval": false, + "assets": ["nep141:wrap.near", ""], + "limits": { "per_transaction": { "nep141:wrap.near": "5000000000000000000000000" } }, + "max_actions_per_hour": 20, + "destinations": { "mode": "only", "list": [] }, + "budget": { "daily_usd": "50", "weekly_usd": null, "monthly_usd": "500" }, + "timelock_ms": 0 + } +} +``` + +Response (201): `correlation_id` (`intent_…`), `agent_id`, `expires_at`, `signer`, +`intent: { standard, payload }`, `preview: { summary, policy, … }`. + +1. Show `preview.summary` and the policy to the owner. +2. The owner's wallet signs `intent.payload` unchanged (method by `intent.standard`, see + [signing.md](signing.md)). +3. Submit: + +```http +POST /v1/submit-intent +{ "type": "agent_create", "correlation_id": "intent_…", + "signed_data": { "standard": "nep366", "payload": { …unchanged… }, "signed_delegate": "…" } } +``` + +4. Poll `GET /v1/status?correlation_id=intent_…&wait_ms=30000` until `SUCCESS`. Creation is on + chain (the API's sponsor pays gas); it usually takes a few seconds to a minute. +5. `GET /v1/agents/{agent_id}/policy` → `status: "APPLIED"` and `provider_policy_synced: true`. + +`owner` shapes: + +```json +{ "type": "near", "account_id": "alice.near", "public_key": "ed25519:…" } +{ "type": "evm", "address": "0xabc…(lowercase)", "chain_id": 1, "public_key": "0x<128 hex, uncompressed, no 04>" } +{ "type": "passkey", "credential_id": "", "public_key": "", "rp_id": "example.com", "origin": "https://example.com" } +``` + +A NEAR owner key must be a **FullAccess** key on that account. EVM and passkey owners need no +NEAR account: the API derives one and deploys it on first use, sponsored. + +## 5. Issue a grant for the agent + +```text +token = "ngt_" + base64url_nopad(32 random bytes) # keep secret, backend only +credential = sha256_hex(token) # hash of the token STRING +``` + +```http +POST /v1/generate-intent +{ "type": "grant_issue", "agent_id": "…", "label": "assistant-session-42", + "credential": "<64 hex>", "expires_at": "2027-01-01T00:00:00Z" } +``` + +Owner signs, you submit, status `SUCCESS` returns `details.grant.grant_id`. Store +`{grant_id, agent_id, encrypted token, label, expires_at}`. See [grants.md](grants.md). + +## 6. Fund the account + +```http +POST /v1/agents/{agent_id}/deposit +X-API-Key: naa_… +Idempotency-Key: dep_7f3c… +{ "origin_asset": "" } +``` + +Returns `PENDING_DEPOSIT` with `details.deposit_address`, `details.memo` (send it when present), +`details.min_amount`, `details.expires_at`, `details.refund_to` (the agent account). Show those +to the payer, then poll the `correlation_id` until `SUCCESS`. On NEAR you can also send NEP-141 +tokens straight to `GET /v1/agents/{agent_id}/addresses/near`. + +## 7. Act as the agent + +```http +POST /v1/agents/{agent_id}/swap +X-API-Key: naa_… +X-Grant-Token: ngt_… +Idempotency-Key: swap_9a1e… +{ "origin_asset": "nep141:wrap.near", "destination_asset": "", "amount": "1000000000000000000000000", "dry": true } +``` + +`dry: true` returns `{dry: true, type: "swap", quote}` and nothing else (no key, no grant needed). +Drop `dry` to execute: you get `202` with a `StatusResponse` (`op_…`). Poll it to a terminal +status. See [executions.md](executions.md) and [status-and-recovery.md](status-and-recovery.md). + +## 8. Before going live + +- Walk the checklist in [architecture.md](architecture.md#security-checklist). +- Handle every code in [errors.md](errors.md) you can hit, at least the refusal and retry ones. +- Persist `correlation_id` + `Idempotency-Key` + request body before sending. +- Read `GET /v1/network` on a schedule and surface degraded components to users. diff --git a/.agents/skills/near-intents-agent-api/references/grants.md b/.agents/skills/near-intents-agent-api/references/grants.md new file mode 100644 index 000000000..297850ff8 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/grants.md @@ -0,0 +1,85 @@ +# Grants + +A grant is the owner's signed permission for **one actor** (a chat session, an assistant, a bot, +a device) to run money actions on the account. It says *who*; the policy says *what*. All grants +of an account share the same policy, limits and USD budget. + +## Token and commitment + +Your backend creates the token; the API only ever sees its hash. + +```text +token = "ngt_" + base64url_no_padding(32 cryptographically random bytes) # 47 chars +credential = lowercase_hex(sha256(utf8(token))) # hash the STRING, prefix included +``` + +| Language | Code | +|---|---| +| TypeScript | `const { token, commitment } = createGrantCredential()` (SDK) | +| Python | `"ngt_" + base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()`; `hashlib.sha256(token.encode()).hexdigest()` | +| Rust | `create_grant_credential()` → `(token, commitment)` in the asset crate | +| Go | `"ngt_" + base64.RawURLEncoding.EncodeToString(b)`; `hex.EncodeToString(sha256.Sum256([]byte(token)))` | + +Use a **new token for every grant** (`grant_credential_reused` otherwise). Check your +implementation with `grant.token` / `grant.commitment` in `assets/test-vectors.json`. + +## Issue + +```json +POST /v1/generate-intent +{ "type": "grant_issue", "agent_id": "…", "label": "telegram-bot", + "credential": "", "expires_at": "2027-04-01T00:00:00Z" } +``` + +- `label`: what the owner sees in lists and previews; name the actor ("Claude Desktop on + Alice's laptop", "support-bot prod"). +- `expires_at`: required, at most 365 days ahead. Prefer short lives for sessions (hours/days) + and renew. +- Owner signs → submit → `SUCCESS` → `details.grant.grant_id`. +- Limit: 50 live (unrevoked, unexpired) grants per agent → `grant_limit_reached`. + +**Store the token before you ask the owner to sign**, encrypted (KMS envelope or app-level +AES-GCM), keyed by `grant_id` once known. If you lose it, the grant is useless: revoke it and +issue another. Never log it, never put it in an LLM prompt, URL or client bundle. + +## Use + +Send it as `X-Grant-Token` on swap, withdraw, transfer, shield, unshield, recover (and +sign). One client per grant, so concurrent sessions never borrow each other's token: + +```ts +const agentClient = api.forGrant(token); // TS SDK +``` +```python +agent_client = api.for_grant(token) # Python asset +``` + +Every execution's `StatusResponse.grant` = `{ id, label }` shows which grant acted; use it in +activity feeds and audits. + +## Revoke + +```json +{ "type": "grant_revoke", "agent_id": "…", "grant_id": "<64 hex>" } +``` + +Takes effect for anything not yet dispatched. Operations already past the dispatch commit finish; +`details.committed_correlation_ids` lists them (`committed_truncated` if the list was cut). +Expired grants stop working by themselves. + +Audit: `GET /v1/agents/{id}/grants` (revoked included, each with the canonical `owner_message` +the owner signed) and `GET /v1/agents/{id}/containment` (live grants + unfinished operations). + +## Patterns + +| Situation | Do | +|---|---| +| Per chat/assistant session | one grant per session, label with the session, expire in hours/days | +| Long-running bot | one grant per bot deployment, rotate (issue new, then revoke old) every N months | +| Third-party integration (MCP client, partner tool) | its own grant so it can be cut off alone | +| User says "stop that assistant" | `grant_revoke` that one grant | +| User says "stop everything" | `agent_freeze` | +| Agent needs a new capability | `policy_update`; do **not** issue a new grant (grants carry no permissions) | + +Errors: `agent_grant_required` (403: missing, unknown, revoked, expired or other agent's token), +`grant_limit_reached`, `grant_credential_reused`, `grant_not_found`. diff --git a/.agents/skills/near-intents-agent-api/references/http-api.md b/.agents/skills/near-intents-agent-api/references/http-api.md new file mode 100644 index 000000000..56312f9a4 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/http-api.md @@ -0,0 +1,180 @@ +# HTTP API reference + +Base URL `https://api.agentsonintents.com`. The live contract is `GET /openapi.json`; a compact +guide is `GET /llms.txt`. If this file and the server disagree, the server wins. + +## Conventions + +- **JSON, snake_case** everywhere (fields, query, errors). Statuses are UPPER_SNAKE. Exception: + `intent.payload` keeps its wallet standard's own field names (`receiverId`, `methodName`, + `primaryType`, …) because the wallet consumes it as is. +- **Headers** + | Header | When | + |---|---| + | `X-API-Key: naa_…` | every endpoint except `/v1/network`, `/v1/tokens` | + | `X-Grant-Token: ngt_…` | swap, withdraw, transfer, shield, unshield, recover, sign (not dry quotes, not deposit) | + | `Idempotency-Key` | required on executions and deposit (not on `dry: true`); optional on `generate-intent`. 8–128 chars `[A-Za-z0-9._:-]` | + | `Content-Type: application/json` | every body | +- Responses carry `x-request-id`; errors repeat it as `meta.request_id`. Log it. +- **Amounts**: atomic integer strings, up to 78 digits. **USD**: decimal strings (`"50"`, `"12.5"`). +- **Durations**: milliseconds (`timelock_ms`, `wait_ms`, `delay_ms`). **Times**: ISO-8601 UTC. +- **Ids**: `agent_id`, `grant_id` = 64 hex; `correlation_id` = `op_…` (execution) or + `intent_…` (owner intent); `approval_id` = UUID. +- **Pagination**: `{ data: [...], next_cursor: string | null }`; pass `cursor=` + until null. Policy history uses an integer cursor. +- **Timeouts**: give settlement writes (non-dry swap/withdraw/transfer/shield/unshield) ~150 s, + everything else ~65 s (long-poll is 30 s max). Don't retry on client timeout with a new key. +- **Errors**: JSON:API `{ errors: [{ status, code, title, detail?, source?, meta? }], meta: { request_id } }`. + Branch on `errors[0].code`. `meta.retryable`, `meta.available_at`, and `Retry-After` header on + 429/503. See [errors.md](errors.md). + +## Endpoint catalogue + +### Meta (no side effects) + +| Method & path | Auth | Returns | +|---|---|---| +| `GET /v1/network` | none | `{ network: "mainnet", contract_id, supported_owner_types, status: { overall, checked_at, components[] } }` | +| `GET /v1/tokens` | none | `{ data: [{ asset_id, symbol, decimals, blockchain, price, price_updated_at, price_expires_at }] }` | +| `GET /v1/whoami` | key | `{ api_key_id, tenant_id }` | +| `GET /v1/quotas` | key | `{ revision, overrides, limits: { daily_agents, api_keys, requests_per_minute, key_requests_per_minute }, usage: {…}, observed_at }` | +| `GET /openapi.json`, `GET /llms.txt`, `GET /health` | none | contract, guide, liveness | + +`status.components[]`: `{ id: "one_click" | "custody" | "near", status: "operational" | "degraded" | "down" | "unknown", reason, affects[], since, checked_at }`. +Advisory only; the API never refuses a request because of it. + +### Owner intents + +| Method & path | Auth | Body | Returns | +|---|---|---|---| +| `POST /v1/generate-intent` → 201 | key (+ optional idem) | `{ type, agent_id?, …type fields }` | `GenerateIntentResponse` | +| `POST /v1/submit-intent` → 202 | key | `{ type, correlation_id, signed_data }` | `StatusResponse` | + +Details: [owner-intents.md](owner-intents.md), signing: [signing.md](signing.md). + +### Status + +| Method & path | Auth | Query | Returns | +|---|---|---|---| +| `GET /v1/status` | key | `correlation_id`, `wait_ms` (0–30000), `refresh` (bool) | `StatusResponse` | +| `GET /v1/agents/{agent_id}/history` | key | `cursor`, `limit` (1–100, default 25), `include_unpaid` (bool, default false) | page of `StatusResponse` | +| `GET /v1/agents/{agent_id}/operations/{correlation_id}/proof` | key | path `correlation_id` is an `op_…` id | `{ correlation_id, origin, notary, events }`: each audit event of the execution with a `c2sp.org/tlog-proof@v1` (`PROVEN`) and its `evidence` (the recorded result, hashed into the opening), `PENDING` until the next checkpoint, or `UNLOGGED`; `notary` holds the verifier keys and TDX birth attestation. A deployment without a transparency log answers 501 `transparency_log_disabled` | + +### Agents and reads + +| Method & path | Query | Returns | +|---|---|---| +| `GET /v1/agents` | `external_user_id`, `cursor` | page of `AgentView` | +| `GET /v1/agents/{agent_id}` | | `AgentView` | +| `GET /v1/agents/{agent_id}/wallet` | | `{ wallet_id, near_account_id }` | +| `GET /v1/agents/{agent_id}/balances` | `source` = `public` \| `confidential`, `asset` | `{ near_account_id, source, balances: BalanceEntry[] }` | +| `GET /v1/agents/{agent_id}/addresses/near` | | `{ chain: "near", address }` | +| `GET /v1/agents/{agent_id}/policy` | | `PolicyView` | +| `GET /v1/agents/{agent_id}/policy/history` | `cursor` (int), `limit` | page of policy revisions | +| `GET /v1/agents/{agent_id}/grants` | | `{ data: GrantView[] }` (revoked included) | +| `GET /v1/agents/{agent_id}/executions/scheduled` | `cursor`, `limit` | `{ data: [{ correlation_id, execute_after, state, action }], next_cursor }` | +| `GET /v1/agents/{agent_id}/approvals` | | `{ data: ApprovalView[] }` | +| `GET /v1/agents/{agent_id}/approvals/{approval_id}` | | `ApprovalView` | +| `GET /v1/agents/{agent_id}/containment` | `grants_cursor`, `operations_cursor` | live grants + unfinished operations ("what is still able to act") | +| `GET /v1/agents/{agent_id}/provider/{kind}` | `kind` = requests \| audit \| deposits \| deposit_history; `limit`, `offset`, `type` | provider's raw records (`{ data }`) | + +### Executions (all `POST /v1/agents/{agent_id}/…`, 202) + +| Path | Auth | Body | +|---|---|---| +| `/swap` | key, grant, idem | `origin_asset`, `destination_asset`, `amount`, `min_amount_out?`, `confidential?`, `dry?` | +| `/withdraw` | key, grant, idem | `asset`, `amount`, `chain`, `recipient`, `memo?`, `confidential?`, `wrapped?`, `async?`, `dry?` | +| `/transfer` | key, grant, idem | `asset`, `amount`, `recipient` (NEAR Intents account), `confidential?` | +| `/shield`, `/unshield` | key, grant, idem | `asset`, `amount` | +| `/deposit` | key, idem | `origin_asset`, `destination_asset?`, `amount?`, `confidential?` | +| `/recover` | key, grant, idem (original) | `correlation_id`, `request: { type, …original body }` | +| `/sign` → 200 | key, grant | `message`, `encoding?`, `recipient` (must be in the policy's `sign.recipients`; never `intents.near`/`intents.far`) | + +`dry: true` on swap/withdraw → 200 `{ dry: true, type, quote }`; no grant, no idempotency key +needed. Details: [executions.md](executions.md). + +## Core shapes + +```ts +StatusResponse = { + correlation_id: string, // op_… | intent_… + agent_id: string | null, + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "agent_archive" + | "agent_restore" | "agent_delete" | "grant_issue" | "grant_revoke" | "execution_cancel" + | "approval_vote" | "swap" | "withdraw" | "transfer" | "shield" | "unshield" | "deposit", + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" + | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW", + failure_code: string | null, + created_at, updated_at, + dispatch_committed_at: string | null, + grant: { id, label } | null, + details: { … by type … } +} +``` + +`details` by type: + +| type | details | +|---|---| +| executions, `deposit`, `agent_delete` | `action`, `near_account_id?`, `provider_request_id?`, `chain?`, `execute_after?`, `delay_ms?`, `approval_id?`, `intent_hash?`, `tx_hash?`, `destination_tx_hash?`, `amount_out?`, `deposit_address?`, `memo?`, `min_amount?`, `min_amount_out?`, `expires_at?`, `refund_to?`, `refund_tx_hash?`, `failure_reason?`, `reason?` (NEEDS_REVIEW), `never_executed?`, `never_submitted?`, `settled_late?`, + provider fields | +| `agent_create` | `agent_id`, `revision`, `policy_hash`, `transaction_hash` | +| `policy_update`, `agent_freeze`, `agent_unfreeze` | `revision`, `policy_hash`, `transaction_hash` (null for off-chain changes) | +| `grant_issue` | `grant: GrantView \| null` | +| `grant_revoke` | `grant_id`, `committed_correlation_ids`, `committed_truncated` | +| `execution_cancel` | `cancelled_correlation_id` | +| `agent_archive`, `agent_restore` | `archived` | +| `approval_vote` | `approval_id`, `verdict` | + +Narrow on `type` before reading `details`. + +```ts +AgentView = { + id, name, external_user_id: string | null, + status: "PENDING" | "ACTIVE" | "ARCHIVED" | "DELETED" | "ABANDONED", + archived, deleted, + owner: OwnerWallet | null, + owner_account: { account_id, public_key, authority: "wallet" } | null, + wallet: { wallet_id, near_account_id } | null, + created_at, + cooldowns: { policy_change_available_at: string | null, unfreeze_available_at: string | null } +} + +PolicyView = { + wallet_id, revision: int | null, policy_hash, status: "NONE" | "SIGNED" | "APPLIED" | "FAILED", + applied_at, transaction_hash, + provider_policy_synced: boolean, // in force only when APPLIED && true + cooldowns, policy: Policy | null, + usage: { + budget: { daily|weekly|monthly: { limit_usd, spent_usd, remaining_usd, resets_at } }, + timelock: { delay_ms, scheduled_count } + } +} + +BalanceEntry = { asset_id, symbol, decimals, blockchain, price, …, balance_raw: string, balance: string | null } + +GrantView = { grant_id, agent_id, wallet_id, label, issued_at, expires_at, + revoked_at, revoked_reason, owner_epoch, owner_message } + +ApprovalView = { approval_id, wallet_id, wallet_pubkey, request_hash, + status: "PENDING" | "APPROVED" | "REJECTED" | "EXPIRED", + request_type, request_data, required_approvals, expires_at } + +OwnerWallet = + | { type: "near", account_id, public_key: "ed25519:…" } + | { type: "evm", address: "0x…" (lowercase), chain_id: number, public_key: "0x" + 128 hex } + | { type: "passkey", credential_id, public_key (b64url DER SPKI), rp_id, origin } +``` + +The `Policy` shape is in [policy.md](policy.md). + +## curl cheatsheet + +```bash +H=(-H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" -H "Content-Type: application/json") +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/agents?external_user_id=user_123" "${H[@]}" +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/agents/$AGENT_ID/policy" "${H[@]}" +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/agents/$AGENT_ID/balances?source=public" "${H[@]}" +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/status?correlation_id=$CID&wait_ms=30000" "${H[@]}" +curl -s -X POST "$NEAR_INTENTS_AGENT_API_URL/v1/agents/$AGENT_ID/swap" "${H[@]}" -H "X-Grant-Token: $GRANT" \ + -d '{"origin_asset":"nep141:wrap.near","destination_asset":"'"$USDC"'","amount":"100000000000000000000000","dry":true}' +``` diff --git a/.agents/skills/near-intents-agent-api/references/other-languages.md b/.agents/skills/near-intents-agent-api/references/other-languages.md new file mode 100644 index 000000000..e1c35fa98 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/other-languages.md @@ -0,0 +1,210 @@ +# Other languages (Go, Java/Kotlin, C#, Ruby, PHP, Elixir, …) + +The API is plain HTTPS + JSON. Any language works; you need: + +1. An HTTP client with per-request timeouts (65 s normal, 150 s for settlement writes). +2. JSON that can keep `intent.payload` verbatim (raw bytes or an order-preserving tree). +3. SHA-256, random bytes, base64url, hex — for grant tokens and idempotency keys. +4. Only if you sign on the server: ed25519 + borsh-style byte packing (NEAR owners) or + secp256k1 + EIP-712 (EVM owners). See [signing.md](signing.md). + +## Generate types from OpenAPI + +```bash +curl -s https://api.agentsonintents.com/openapi.json -o openapi.json +# Go +oapi-codegen -generate types,client -package agentapi openapi.json > agentapi.gen.go +# Java / Kotlin / C# / PHP / Ruby +openapi-generator-cli generate -i openapi.json -g kotlin -o ./agentapi +openapi-generator-cli generate -i openapi.json -g csharp -o ./agentapi +``` + +Treat `intent.payload` and `signed_data.payload` as opaque JSON (`json.RawMessage`, +`JsonNode`, `JsonElement`) in generated models; typed re-serialization can reorder or drop +fields and break signature checks. + +## Minimal client contract + +``` +headers: X-API-Key, Content-Type: application/json, Accept: application/json + + X-Grant-Token on grant endpoints, + Idempotency-Key on executions/deposit +success: 2xx JSON +error: ≥400 JSON:API → raise {status, code=errors[0].code, detail, retryable, available_at, request_id, idempotency_key} +transport: raise with the idempotency key so the caller can retry with it +``` + +## Go example + +```go +package agentapi + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const DefaultBaseURL = "https://api.agentsonintents.com" + +type Client struct { + BaseURL, APIKey, GrantToken string + HTTP *http.Client +} + +type APIError struct { + Status int + Code, Detail string + Retryable bool + AvailableAt string + RequestID string + IdempotencyKey string +} + +func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Detail) } + +func New(apiKey string) *Client { + return &Client{BaseURL: DefaultBaseURL, APIKey: apiKey, HTTP: &http.Client{Timeout: 150 * time.Second}} +} + +func (c *Client) ForGrant(token string) *Client { cp := *c; cp.GrantToken = token; return &cp } + +// Do sends one request. body may be nil, a value, or json.RawMessage (sent verbatim). +func (c *Client) Do(ctx context.Context, method, path string, body any, idemKey string, grant bool, out any) error { + var reader *bytes.Reader + if body != nil { + raw, ok := body.(json.RawMessage) + if !ok { + var err error + if raw, err = json.Marshal(body); err != nil { + return err + } + } + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader) + if err != nil { + return err + } + req.Header.Set("X-API-Key", c.APIKey) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if idemKey != "" { + req.Header.Set("Idempotency-Key", idemKey) + } + if grant { + req.Header.Set("X-Grant-Token", c.GrantToken) + } + res, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("transport (idempotency key %q): %w", idemKey, err) + } + defer res.Body.Close() + if res.StatusCode >= 400 { + var doc struct { + Errors []struct { + Code, Detail string + Meta struct { + Retryable bool `json:"retryable"` + AvailableAt string `json:"available_at"` + } + } + Meta struct { + RequestID string `json:"request_id"` + } + } + _ = json.NewDecoder(res.Body).Decode(&doc) + e := &APIError{Status: res.StatusCode, RequestID: doc.Meta.RequestID, IdempotencyKey: idemKey} + if len(doc.Errors) > 0 { + e.Code, e.Detail = doc.Errors[0].Code, doc.Errors[0].Detail + e.Retryable, e.AvailableAt = doc.Errors[0].Meta.Retryable, doc.Errors[0].Meta.AvailableAt + } + return e + } + if out == nil { + return nil + } + return json.NewDecoder(res.Body).Decode(out) +} + +func NewGrantCredential() (token, commitment string) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(err) + } + token = "ngt_" + base64.RawURLEncoding.EncodeToString(b) + sum := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(sum[:]) +} + +func NewIdempotencyKey() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return "idem_" + hex.EncodeToString(b) +} +``` + +Owner intent round trip, keeping the payload verbatim: + +```go +type Generated struct { + CorrelationID string `json:"correlation_id"` + Type string `json:"type"` + AgentID string `json:"agent_id"` + Intent json.RawMessage `json:"intent"` // {standard, payload} — never re-marshal from a map + Preview json.RawMessage `json:"preview"` + ExpiresAt string `json:"expires_at"` +} + +var g Generated +err := c.Do(ctx, "POST", "/v1/generate-intent", request, NewIdempotencyKey(), false, &g) +// send g.Intent + g.Preview to the browser; receive signedData (json.RawMessage) back +submit := map[string]any{"type": g.Type, "correlation_id": g.CorrelationID, "signed_data": signedData} +err = c.Do(ctx, "POST", "/v1/submit-intent", submit, "", false, nil) +``` + +To add wallet output fields server-side without reordering the payload, decode `g.Intent` into a +`map[string]json.RawMessage` (the inner `payload` stays raw bytes), add `"signature"` etc., and +marshal that map: top-level key order does not matter, only the payload bytes do. When you +produce bytes you will **sign** (NEP-366 `args`), use `json.Compact` on the raw bytes or an +`Encoder` with `SetEscapeHTML(false)`: plain `json.Marshal` escapes `<`, `>`, `&` and would +differ from `JSON.stringify`. + +Go signing building blocks: `crypto/ed25519` (NEAR keys: `ed25519.NewKeyFromSeed(seed[:32])`), +`github.com/mr-tron/base58`, `encoding/binary` (little-endian lengths), and for EVM +`github.com/ethereum/go-ethereum/signer/core/apitypes` (`TypedDataAndHash`) + +`crypto.Sign`. Validate against `assets/test-vectors.json`. + +## Java / Kotlin notes + +- `java.net.http.HttpClient` or OkHttp; Jackson `JsonNode` for payloads (keeps order). +- Grant token: `SecureRandom` 32 bytes → `Base64.getUrlEncoder().withoutPadding()`; SHA-256 via + `MessageDigest`, hex lowercase. +- NEAR: `net.i2p.crypto:eddsa` or BouncyCastle Ed25519; EVM: web3j `StructuredDataEncoder` + + `Sign.signMessage(hash, keyPair, false)`. + +## C# notes + +- `HttpClient` + `System.Text.Json` `JsonNode`; serialize with + `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` when you must re-emit JSON you will sign. +- NEAR: `NSec.Cryptography` Ed25519; EVM: Nethereum `Eip712TypedDataSigner`. + +## Porting checklist + +- [ ] Base URL configurable; default hosted. +- [ ] API key from secret storage; grant tokens per client instance. +- [ ] Idempotency key generated, persisted, sent; returned on errors. +- [ ] JSON:API error mapping with `code`, `retryable`, `available_at`, `request_id`. +- [ ] Long-poll status helper with deadline. +- [ ] Payload pass-through without reordering; tests against `test-vectors.json` if signing. +- [ ] Amount conversion with integer/decimal types only. diff --git a/.agents/skills/near-intents-agent-api/references/owner-intents.md b/.agents/skills/near-intents-agent-api/references/owner-intents.md new file mode 100644 index 000000000..52dc1cd1a --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/owner-intents.md @@ -0,0 +1,180 @@ +# Owner intents + +Every change the owner authorizes uses one loop: + +``` +backend: POST /v1/generate-intent {type, …} → { correlation_id, intent, preview, expires_at, signer } +browser: show preview → wallet signs intent.payload (method from intent.standard) +backend: POST /v1/submit-intent {type, correlation_id, signed_data: {...intent, }} +backend: GET /v1/status?correlation_id=…&wait_ms=30000 until SUCCESS / FAILED +``` + +The API builds the exact bytes, so the partner never assembles anything security-relevant; +`preview.summary` is one human sentence describing what is signed. + +## Intent types + +| type | Extra fields (besides `agent_id`) | Notes | +|---|---|---| +| `agent_create` | `name`, `external_user_id?`, `owner`, `policy` (no `agent_id`) | reserves a `PENDING` agent and returns its `agent_id` immediately | +| `policy_update` | `policy` (complete), `expected_revision` (current applied revision) | 10-min cooldown between ordinary changes | +| `agent_freeze` | — | emergency stop; immediate; never throttled | +| `agent_unfreeze` | — | own cooldown (`agent_unfreeze_throttled`) | +| `agent_archive` / `agent_restore` | — | hides/unhides from default lists; nothing changes on chain. `agent_archive` needs a frozen account, and `agent_restore` needs an unfrozen one; otherwise `pause_before_archive` / `unpause_before_restore` | +| `agent_delete` | — | `preview.deletion` lists balances and `assets_lost`; withdraw first, there is no sweep. Open deposit addresses never block it: deletion closes them (`agent_deleted`), and funds sent later are lost | +| `grant_issue` | `label`, `credential` (sha256 hex of token), `expires_at` (≤365 days) | see [grants.md](grants.md) | +| `grant_revoke` | `grant_id` | committed operations still finish | +| `execution_cancel` | `correlation_id` of a `QUEUED` op | only while the timelock holds it | +| `approval_vote` | `approval_id`, `verdict: "approve" \| "reject"` | any owner; EVM/passkey votes go through their `0s` wallet | + +## Generate response + +```ts +{ + correlation_id: "intent_…", + type, agent_id, + status: "PENDING_SIGNATURE", + expires_at: string, // submit before this (minutes, not hours) + signer: OwnerWallet, // the wallet that must sign + intent: { standard: "nep413" | "nep366" | "eip712" | "webauthn", payload }, + preview: { + summary: string, + revision?, previous_revision?, policy_hash?, policy?, // policy changes + deletion?: { near_account_id, beneficiary, native_balance, public[], confidential[], assets_lost, retirement }, + approval?: { approval_id, request_type, request_hash, verdict } + } +} +``` + +The SDK and the reference clients also return the idempotency key they used. If +`generate-intent` is interrupted (timeout, crash), retry with the **same** `Idempotency-Key`; +`intent_generation_recovery_required` means keep that key and retry it rather than starting over. + +## Submit + +```json +{ + "type": "policy_update", + "correlation_id": "intent_…", + "signed_data": { "standard": "nep413", "payload": { "…": "exactly as generated" }, + "public_key": "ed25519:…", "signature": "" } +} +``` + +| standard | `signed_data` = intent + | +|---|---| +| `nep413` | `public_key: "ed25519:…"`, `signature` (base64, base58, `ed25519:` or hex) | +| `nep366` | `signed_delegate` (base64 borsh `SignedDelegate`) | +| `eip712` | `signature: "0x…"` (65 bytes, 130 hex) | +| `webauthn` | `credential: AuthenticationResponseJSON` (from `@simplewebauthn/browser`) | + +Submitting the same signature twice is safe and returns the same operation. The response is +usually `PROCESSING`; on-chain intents (`nep366`, sponsored EVM/passkey calls) settle in seconds +to a minute; off-chain ones settle almost immediately. + +## Which standard you get + +| Owner | Change enforced by provider (on chain) | Change enforced by API (off chain) | +|---|---|---| +| NEAR | `nep366` delegate action (sponsor pays gas): `agent_create`, provider policy edits, freeze/unfreeze | `nep413`: destinations/budget/timelock/schedule-only edits, grants, cancel, archive, votes, … | +| EVM | `eip712` | `eip712` | +| Passkey | `webauthn` | `webauthn` | + +Always switch on `intent.standard`; never assume from the type. + +## Backend-for-frontend (BFF) split + +The browser never sees the API key or any grant token. A typical pair of routes: + +```text +POST /app/agents/:id/intents (your auth: user owns agent :id) + → api.generate_intent({...}) → returns { correlation_id, intent, preview, expires_at } to the browser + +POST /app/intents/:cid/submit (your auth: user owns the intent you stored for :cid) + body: { signed_data } ← wallet output from the browser + → api.submit_intent(type, cid, signed_data) + → api.get_status(cid, wait_ms=30000) → return status +``` + +Rules: +- Persist `{correlation_id, type, agent_id, user_id, created_at}` when you generate, and on + submit check that the user owns that correlation id. Never let a client pick `type` or + `correlation_id` you did not issue to them. +- Validate the user owns `agent_id` (your `external_user_id` mapping) before generating. +- Let the browser only *forward* the wallet output; don't let it supply a different `payload` + (the API refuses mismatches with `intent_payload_mismatch` anyway). +- Show `preview.summary`, the full `preview.policy`, and for deletes `preview.deletion.assets_lost`. + +Browser signing: + +```ts +switch (intent.standard) { + case "eip712": + return { ...intent, signature: await walletClient.signTypedData({ account, ...intent.payload }) }; + case "nep366": { + const r = await wallet.signDelegateActions({ delegateActions: [intent.payload] }); + return { ...intent, signed_delegate: r.signedDelegateActions[0] }; + } + case "nep413": { + const nonce = Uint8Array.from(atob(intent.payload.nonce), (c) => c.charCodeAt(0)); + const r = await wallet.signMessage({ ...intent.payload, nonce }); + return { ...intent, public_key: r.publicKey, signature: r.signature }; + } + case "webauthn": + return { ...intent, credential: await startAuthentication({ optionsJSON: intent.payload }) }; +} +``` + +Adapt to your wallet library's result shape (near-connect / wallet-selector return `publicKey` and +`signature`; the server normalizes base58/base64/hex encodings). + +## Errors you should handle here + +| Code | Meaning / action | +|---|---| +| `intent_expired` (409) | signing window closed → generate again | +| `intent_payload_mismatch`, `intent_standard_mismatch` | the payload or standard was altered → sign the original | +| `signature_invalid` | malformed wallet output → submit exactly what the wallet returned | +| `signer_mismatch` | wrong key/account signed → have the `signer` wallet sign | +| `owner_key_not_full_access` (403) | NEAR key is a function-call key → use a FullAccess key | +| `owner_nonce_invalid` | intent already used/expired → generate again | +| `policy_revision_conflict` (409) | re-read `GET …/policy`, regenerate with its `revision`, re-sign | +| `policy_reconciliation_required` | another policy intent is still open → wait for it to settle/expire | +| `policy_change_throttled` / `agent_unfreeze_throttled` (429) | wait until `meta.available_at` (also on `GET /v1/agents/{id}` cooldowns) | +| `wallet_already_frozen` / `wallet_already_unfrozen` | already in that state | +| `agent_creation_quota_exceeded` (429) | 100 agents/24h → wait for `available_at` or ask for a higher quota | +| `sponsor_busy` / `sponsor_balance_insufficient` (503) | retry the same submit later | +| `transaction_unconfirmed` (503) | broadcast but not confirmed → poll status, or submit the same signed intent again (never sends twice); never generate a new intent for it | +| `agent_not_bound` | the agent is still onboarding → wait for `agent_create` SUCCESS | + +## Recipes + +**Edit policy safely** + +```text +view = GET /v1/agents/{id}/policy +assert view.status == "APPLIED" +new = modify(copy(view.policy)) # complete policy, never a diff +gen = generate_intent({type:"policy_update", agent_id, policy:new, expected_revision:view.revision}) +… owner signs, submit, wait SUCCESS … +poll GET …/policy until status APPLIED && provider_policy_synced +``` + +**Emergency stop**: `agent_freeze` (no cooldown, takes effect at once). To stop one actor only, +`grant_revoke`. To see what is still live: `GET …/containment`. + +**Cancel a timelocked payment**: list `GET …/executions/scheduled`, then `execution_cancel` +with that `correlation_id` before `execute_after`. + +**Approve a payment** (policy `owner_approval: true`, any owner type): list `GET …/approvals`, show +`request_data`, then `approval_vote` with `verdict`. The execution moves out of +`PENDING_APPROVAL`; keep polling its `op_…` id. `reject` ends it and releases its budget +charge (`FAILED`, `details.never_executed`). `approve` re-runs the admission checks first: a +revoked or expired grant, a frozen or changed policy, or a destination no longer allowed refuses +the vote (`authorization_stale`, `wallet_frozen`, `policy_destination_denied`), nothing is +sent, and the request stays held for a `reject`. `approval_not_recorded` means the API has not +yet recorded the request under its approval (its dispatch reply was lost): send the same vote +again a few minutes later. + +**Delete**: withdraw balances first, `agent_delete`, show `preview.deletion`; refuse to proceed +in your UI when `assets_lost` is true unless the owner explicitly accepts. diff --git a/.agents/skills/near-intents-agent-api/references/policy.md b/.agents/skills/near-intents-agent-api/references/policy.md new file mode 100644 index 000000000..d9eebcc0b --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/policy.md @@ -0,0 +1,177 @@ +# Policy + +One complete, owner-signed rulebook per agent account. Every grant acts within it. Each +`agent_create` and `policy_update` carries the **whole** policy, never a diff; every field below +is required except `schedule` and `sign`. + +```ts +Policy = { + frozen: boolean, + actions: ("swap" | "transfer" | "withdraw")[], // unique; deposits are always allowed + confidential: boolean, // shield/unshield + confidential form of each action + owner_approval: boolean, // every outgoing money action waits for the owner's vote + assets: "any" | string[], // asset_id values, ≤128 + limits: { // per-asset caps, atomic strings (1–78 digits) + per_transaction?: Record, + hourly?: Record, + daily?: Record, + monthly?: Record, + }, + max_actions_per_hour: number | null, // positive int ≤ 1_000_000, null = no cap + destinations: { mode: "only" | "except" | "any", list: Destination[] }, // list ≤256; "any" ⇒ [] + budget: { daily_usd: string | null, weekly_usd: string | null, monthly_usd: string | null }, + timelock_ms: number, // 0 … 2_592_000_000 (30 days) + schedule?: Schedule, // omit = money actions may run any time + sign?: { recipients: string[] } // omit = the agent signs nothing; 1–256 NEAR accounts +} + +Destination = + | { action: "withdraw", chain: string, address: string, memo: string | null } // external chain address + | { action: "transfer", address: string, confidential: boolean } // NEAR Intents account + +Schedule = { + mode: "only" | "except", // run only inside the windows, or pause inside them + time_zone: string, // IANA zone, e.g. "Europe/Berlin", "UTC" + windows: { // 1 … 28 + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[], // unique, 1 … 7 + start: string, // "HH:MM", 24-hour, inclusive + end: string, // "HH:MM" or "24:00", exclusive, ≠ start + }[] +} +``` + +Unknown fields are rejected (strict objects). Field names are snake_case. + +## Field by field + +| Field | Effect | Enforced by | +|---|---|---| +| `frozen` | kill switch: every money action refused (`wallet_frozen`). Use `agent_freeze`/`agent_unfreeze` intents, not a policy edit | provider | +| `actions` | which money actions exist at all. Missing → `policy_action_denied` | provider | +| `confidential` | enables `shield`, `unshield`, and `confidential: true` on swap/withdraw/transfer | provider | +| `owner_approval` | outgoing actions go `PENDING_APPROVAL` until the owner votes; deposits unaffected. Hard limits still reject first | provider | +| `assets` | tokens the agent may spend (`"any"` or explicit ids). Swaps may buy unlisted tokens but not sell them | provider | +| `limits` | per-asset caps per transaction and per rolling hour/day/month. Needs named assets to be meaningful | provider | +| `max_actions_per_hour` | rate cap on money actions regardless of amount | provider | +| `destinations` | where withdrawals/transfers may go | **API** | +| `budget` | total USD value across all assets, rolling 24 h / 7 d / 30 d; shared by every grant | **API** | +| `timelock_ms` | every outgoing money action is `QUEUED` this long; owner can `execution_cancel` | **API** | +| `schedule` | weekly windows on the owner's clock when money actions may run (`only`) or are paused (`except`) | **API** | + +`budget` notes: charged atomically at dispatch using 1Click USD prices; an asset with no fresh +price fails with `spend_price_unavailable` (only while a budget is set). A larger budget never +lifts a per-asset limit; both must pass. Live usage is in `GET …/policy` → +`usage.budget.{daily,weekly,monthly}.{limit_usd, spent_usd, remaining_usd, resets_at}`. + +`destinations` notes: +- `only` + `[]` (the safe default) keeps funds inside the account: swaps only. +- `only` matches the **exact** entry, memo included. `except` blocks an account under any memo. +- EVM addresses must be canonical lowercase hex; other chains keep their exact case. +- `transfer` entries are NEAR Intents account ids (`alice.near`, implicit hex accounts). + +`schedule` notes: +- A window is `start` inclusive, `end` exclusive, in `time_zone`. `00:00`–`24:00` is a whole day. +- An `end` earlier than `start` runs past midnight and belongs to the day it starts on: + `{"days":["fri"],"start":"22:00","end":"02:00"}` covers Friday night into Saturday morning. +- Daylight saving follows the time zone: a window means the same local hours all year. +- Judged when the action would **run**: at submit for `now + timelock_ms`, again when the timelock + releases, and again at dispatch. A timelocked action whose release falls outside the schedule + fails; nothing is sent. +- Refusal: `403 policy_schedule_denied` with `meta.available_at` (next allowed moment) and + `Retry-After`. Submit again at `available_at` with a **new** `Idempotency-Key`, or the owner + changes `schedule`. A same-key replay returns the original operation, not a fresh judgement. +- Exempt: deposits, `sign` and owner approval votes. +- Not sent to the provider: a schedule-only edit is an off-chain owner signature. + +## Signing (`sign`) + +`sign: { recipients: ["login.example.near"] }` lets any grant ask the agent's key to sign an +identity challenge for those NEAR accounts (`POST /v1/agents/{agent_id}/sign`), so the agent can +prove it controls the account, for example to log in to a service. Omit it and nothing is signed. + +- Only the canonical `near-intents-agent-api.identity.v1` challenge is signed (exact fields + `domain, purpose, chain, audience, challenge, issued_at_ms, expires_at_ms`; `audience` = + `recipient`; lives ≤ 5 minutes). Never a transaction, an intent or free text. +- `intents.near` and `intents.far` (the NEAR Intents contracts) are never recipients: the policy is + refused with `validation_failed`, a request with `403 signing_recipient_forbidden`. +- Moves no value: `timelock_ms`, `schedule`, `budget` and `owner_approval` do not apply. +- Off unless the owner adds it. Warn the owner before signing it: a listed service may treat the + signature as the account logging in, so list only trusted services. +- Changing `sign` is an on-chain policy update (the wallet provider enforces the same list). + +## Ready-made policies + +**Swap-only trading assistant** (funds never leave the account): + +```json +{ + "frozen": false, "actions": ["swap"], "confidential": false, "owner_approval": false, + "assets": ["nep141:wrap.near", ""], + "limits": { "per_transaction": { "nep141:wrap.near": "10000000000000000000000000" } }, + "max_actions_per_hour": 30, + "destinations": { "mode": "only", "list": [] }, + "budget": { "daily_usd": "100", "weekly_usd": null, "monthly_usd": "1000" }, + "timelock_ms": 0 +} +``` + +**Payroll / payouts to an allow-list with a review window**: + +```json +{ + "frozen": false, "actions": ["transfer", "withdraw"], "confidential": false, "owner_approval": false, + "assets": [""], + "limits": { "per_transaction": { "": "500000000" }, "daily": { "": "2000000000" } }, + "max_actions_per_hour": 10, + "destinations": { "mode": "only", "list": [ + { "action": "transfer", "address": "vendor.near", "confidential": false }, + { "action": "withdraw", "chain": "base", "address": "0xabc…", "memo": null } + ] }, + "budget": { "daily_usd": "2000", "weekly_usd": "5000", "monthly_usd": null }, + "timelock_ms": 3600000 +} +``` + +**Business hours only** (any policy plus a schedule; here Mon–Fri 09:00–17:00 Berlin time): + +```json +"schedule": { + "mode": "only", "time_zone": "Europe/Berlin", + "windows": [{ "days": ["mon","tue","wed","thu","fri"], "start": "09:00", "end": "17:00" }] +} +``` + +Pause overnight instead: `{"mode":"except","time_zone":"UTC","windows":[{"days":["mon","tue","wed","thu","fri","sat","sun"],"start":"22:00","end":"07:00"}]}`. + +**Human-in-the-loop** (any owner type): as above with `"owner_approval": true`. + +**Broad, capped by USD**: `"actions": ["swap","transfer","withdraw"]`, `"assets": "any"`, +`"limits": {}`, `"destinations": {"mode":"any","list":[]}`, budget set. Only for owners who +explicitly accept that the agent can send anywhere. + +## Changing a policy + +1. `view = GET /v1/agents/{id}/policy`. Require `status == "APPLIED"`. +2. Check `GET /v1/agents/{id}` → `cooldowns.policy_change_available_at` is null or past. +3. Build the complete new policy from `view.policy` (to remove a schedule, omit `schedule`). +4. `generate-intent {type: "policy_update", agent_id, policy, expected_revision: view.revision}`. +5. Owner signs (`nep413`/`eip712` when only API-enforced fields changed, otherwise on chain). +6. Submit, wait `SUCCESS`, then poll `GET …/policy` until `APPLIED` and + `provider_policy_synced: true`. Until then executions fail with `policy_not_ready`. + +Cooldowns (per agent, default 5 minutes): +- any ordinary change (anything other than a pure freeze/unfreeze) → next ordinary change waits + → `policy_change_throttled` with `meta.available_at`; +- `agent_freeze` is never throttled; +- an unfreeze waits 5 minutes after the previous unfreeze → `agent_unfreeze_throttled`; +- `agent_create` does not start a cooldown, so a freshly created agent can be edited at once. + +Plan UX around it: batch edits into one signature, and show `available_at` instead of a +generic error. + +## Validation errors + +A malformed policy returns `400 validation_failed` with one error per field +(`source.pointer` like `/policy/destinations/list/0/address`). Other policy-related codes: +`policy_revision_conflict`, `policy_reconciliation_required`, +`policy_blocks_delete` (an old-shape policy must be re-signed before deletion). diff --git a/.agents/skills/near-intents-agent-api/references/python.md b/.agents/skills/near-intents-agent-api/references/python.md new file mode 100644 index 000000000..e4a238c48 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/python.md @@ -0,0 +1,114 @@ +# Python + +There is no published Python package. Copy +[../assets/python/near_intents_agent_api.py](../assets/python/near_intents_agent_api.py) into +your project (one file, mirrors the TypeScript SDK). + +```bash +pip install httpx cryptography # client + NEAR signing +pip install eth-account # only for EVM (EIP-712) owners +python assets/python/test_vectors.py # signer self-check against assets/test-vectors.json +``` + +Python 3.10+. + +## Client + +```python +from near_intents_agent_api import AgentApi, AgentApiError, AgentApiRequestError, create_idempotency_key + +api = AgentApi.from_env() # NEAR_INTENTS_AGENT_API_KEY, NEAR_INTENTS_AGENT_API_URL (default hosted) +# or AgentApi(api_key="naa_…", base_url="https://api.agentsonintents.com") + +api.get_network(); api.whoami(); api.get_quotas() +tokens = {t["asset_id"]: t for t in api.get_tokens()} +``` + +Methods: `generate_intent`, `submit_intent`, `get_status(cid, wait_ms, refresh)`, +`wait_for_status(cid, deadline_s)`, `list_agents`, `get_agent`, `get_balances(agent_id, source, asset)`, +`get_policy`, `list_grants`, `get_history`, `list_scheduled`, `list_approvals`, +`get_containment`, `quote_swap`, `quote_withdraw`, `swap`, `withdraw`, `transfer`, `shield`, +`unshield`, `deposit`, `recover`, plus the generic `request(method, path, query=, body=, idempotency_key=, grant=)` +for anything else. Responses are plain dicts with the API's snake_case fields. + +Helpers: `create_idempotency_key()`, `create_grant_credential()` → `{token, commitment}`, +`grant_commitment(token)`, `to_atomic("1.5", 24)`, `to_decimal(raw, decimals)`, +`iterate_pages(lambda c: api.list_agents(cursor=c))`. + +## Owner flow with a NEAR key (script / company-held key) + +```python +from near_intents_agent_api import NearKey, near_owner, sign_intent, run_owner_intent + +key = NearKey(os.environ["AGENT_OWNER_PRIVATE_KEY"]) # ed25519: +owner = near_owner(os.environ["AGENT_OWNER_ACCOUNT_ID"], key) + +status = run_owner_intent( + api, + {"type": "agent_create", "name": "Treasury bot", "owner": owner, "policy": policy}, + lambda generated: sign_intent(generated, near_key=key, rpc_url="https://free.rpc.fastnear.com"), +) +agent_id = status["details"]["agent_id"] +``` + +EVM owner: + +```python +from near_intents_agent_api import evm_owner, sign_intent +owner = evm_owner(os.environ["AGENT_OWNER_EVM_PRIVATE_KEY"]) +sign = lambda g: sign_intent(g, evm_private_key=os.environ["AGENT_OWNER_EVM_PRIVATE_KEY"]) +``` + +In a web product, skip the signers: return `generated["intent"]` and `generated["preview"]` to +the browser and pass the wallet's output to `api.submit_intent(type, correlation_id, signed_data)`. + +## Grant and execute + +```python +from near_intents_agent_api import create_grant_credential +from datetime import datetime, timedelta, timezone + +cred = create_grant_credential() # store cred["token"] encrypted +expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat().replace("+00:00", "Z") +done = run_owner_intent(api, {"type": "grant_issue", "agent_id": agent_id, "label": "bot", + "credential": cred["commitment"], "expires_at": expires}, sign) +grant_id = done["details"]["grant"]["grant_id"] + +agent = api.for_grant(cred["token"]) +quote = agent.quote_swap(agent_id, {"origin_asset": "nep141:wrap.near", "destination_asset": usdc, + "amount": to_atomic("0.1", 24)}) +key = create_idempotency_key() # persist with the body first +op = agent.swap(agent_id, {"origin_asset": "nep141:wrap.near", "destination_asset": usdc, + "amount": to_atomic("0.1", 24)}, idempotency_key=key) +final = api.wait_for_status(op["correlation_id"]) +``` + +## Errors + +```python +try: + agent.withdraw(agent_id, body, idempotency_key=key) +except AgentApiError as e: + if e.code == "policy_destination_denied": ... + elif e.code in ("policy_change_throttled", "rate_limited"): retry_at = e.available_at + else: raise +except AgentApiRequestError as e: + # network/timeout: retry with e.idempotency_key and the same body + ... +``` + +`AgentApiError` has `status`, `code`, `title`, `detail`, `retryable`, `available_at`, +`request_id`, `errors`, `idempotency_key`. + +## Async / frameworks + +The asset uses a sync `httpx.Client`. For asyncio (FastAPI, aiohttp), switch to +`httpx.AsyncClient` and `await` in `request()` — the rest is unchanged. Run long-polls in +background tasks (Celery, RQ, arq) rather than request handlers. + +## Pitfalls + +- Use `Decimal`/strings for amounts, never floats (`to_atomic` refuses excess precision). +- Don't `json.dumps(..., sort_keys=True)` anything you sign; `js_json` reproduces + `JSON.stringify` for NEP-366 args. +- EIP-712 needs `eth-account>=0.13` (`encode_typed_data(full_message=payload)`). diff --git a/.agents/skills/near-intents-agent-api/references/rust.md b/.agents/skills/near-intents-agent-api/references/rust.md new file mode 100644 index 000000000..ed1adde9c --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/rust.md @@ -0,0 +1,105 @@ +# Rust + +No crate is published. Copy [../assets/rust/](../assets/rust/) into your workspace (or vendor +`src/lib.rs` into an existing crate and merge the dependencies from `Cargo.toml`). + +```bash +cd assets/rust +cargo test # NEAR + grant vectors +cargo test --features evm # + EIP-712 vector (pulls in alloy) +``` + +Key dependencies: `reqwest` (rustls), `serde_json` **with `preserve_order`** (required: NEP-366 +args must be serialized in the API's key order), `ed25519-dalek`, `sha2`, `bs58`, `base64`; +optional `alloy-*` behind feature `evm`. + +## Client + +```rust +use near_intents_agent_api::{AgentApi, Error, create_idempotency_key}; +use serde_json::json; + +let api = AgentApi::from_env()?; // NEAR_INTENTS_AGENT_API_KEY, NEAR_INTENTS_AGENT_API_URL (default hosted) +// or AgentApi::new("naa_…", None)? + +let network = api.get_network().await?; +let tokens = api.get_tokens().await?; +let agent = api.get_agent(&agent_id).await?; +let policy = api.get_policy(&agent_id).await?; +let balances = api.get_balances(&agent_id, "public").await?; +``` + +Bodies and responses are `serde_json::Value`. For anything without a helper use +`api.request(Method, path, query, body, idempotency_key, grant)`. Generate typed models from +`/openapi.json` (e.g. `openapi-generator -g rust` or `progenitor`) if you want structs; keep +`intent.payload` as `Value` either way so you never re-order it. + +## Owner flow + +```rust +use near_intents_agent_api::{NearKey, sign_intent}; + +let key = NearKey::from_secret(&std::env::var("AGENT_OWNER_PRIVATE_KEY")?)?; +let owner = json!({ "type": "near", "account_id": account_id, "public_key": key.public_key() }); + +let generated = api.generate_intent(&json!({ + "type": "agent_create", "name": "Treasury bot", "owner": owner, "policy": policy +}), &create_idempotency_key()).await?; + +let signed = sign_intent(&generated, Some(&key), "https://free.rpc.fastnear.com", None).await?; +let cid = generated["correlation_id"].as_str().unwrap(); +api.submit_intent(generated["type"].as_str().unwrap(), cid, signed).await?; +let status = api.wait_for_status(cid, std::time::Duration::from_secs(300)).await?; +``` + +EVM owner (feature `evm`): `sign_intent(&generated, None, "", Some(&evm_private_key_hex))`. +Passkeys can only be signed in a browser. + +## Grant and execute + +```rust +use near_intents_agent_api::create_grant_credential; + +let (token, commitment) = create_grant_credential(); // store token encrypted +// grant_issue with "credential": commitment … owner signs … wait SUCCESS + +let agent = api.for_grant(token)?; +let quote = agent.quote_swap(&agent_id, json!({ + "origin_asset": "nep141:wrap.near", "destination_asset": usdc, "amount": "100000000000000000000000" +})).await?; + +let key = create_idempotency_key(); // persist with the body first +let op = agent.execute(&agent_id, "swap", &json!({ + "origin_asset": "nep141:wrap.near", "destination_asset": usdc, "amount": "100000000000000000000000" +}), &key).await?; +let final_status = api.wait_for_status(op["correlation_id"].as_str().unwrap(), std::time::Duration::from_secs(300)).await?; +``` + +`execute` covers `swap`, `withdraw`, `transfer`, `shield`, `unshield`; `deposit` has its own +method (no grant). + +## Errors + +```rust +match agent.execute(&agent_id, "withdraw", &body, &key).await { + Ok(op) => track(op), + Err(Error::Api { code, available_at, .. }) if code == "spend_budget_exceeded" => { + /* show remaining budget; retry after `available_at` with a NEW key */ + } + Err(e) if e.code() == Some("policy_destination_denied") => { /* ask the owner to allow it */ } + Err(Error::Transport { idempotency_key, .. }) => { + /* outcome unknown: retry with the same key and body */ + } + Err(e) => return Err(e.into()), +} +``` + +`Error::Api { status, code, title, detail, retryable, available_at, request_id, errors, idempotency_key }`, +`Error::Transport { source, idempotency_key }`, `Error::Invalid(String)`. + +## Pitfalls + +- Without `preserve_order`, `serde_json::Map` is a `BTreeMap` and sorts keys: NEP-366 args + bytes change and the API rejects the signature. +- Use `u128` (or strings) for amounts; 24-decimal NEAR amounts overflow `u64`. +- The crate allows `clippy::result_large_err` on purpose (errors carry the full document). diff --git a/.agents/skills/near-intents-agent-api/references/signing.md b/.agents/skills/near-intents-agent-api/references/signing.md new file mode 100644 index 000000000..70a60ecc1 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/signing.md @@ -0,0 +1,151 @@ +# Signing owner intents + +The owner's wallet signs `intent.payload` exactly as `generate-intent` returned it, using the +method named by `intent.standard`. You submit `signed_data = { ...intent, }`. + +In a product, **the owner's wallet in the browser signs** and your backend only forwards bytes. +Server-side signers (below) exist for scripts, tests, CI, and integrations where the company +deliberately operates the owner key (see [architecture.md](architecture.md#key-ownership-models)). + +Never: rebuild the payload, re-serialize it with different key order, hash it yourself before a +wallet that hashes again, sign with a function-call key, or guess the standard from the type. + +All byte formats below are pinned by [../assets/test-vectors.json](../assets/test-vectors.json), +generated with near-api-js and viem. Any new port must reproduce every vector. + +## nep413 — off-chain NEAR message + +Payload: `{ message: string, recipient: string, nonce: base64(32 bytes) }`. + +```text +tag = u32_le(2^31 + 413) # 0x9d 0x01 0x00 0x80 +body = borsh_string(message) # u32_le(len(utf8)) || utf8 + || nonce_bytes[32] # raw, decoded from base64 (fixed array: no length) + || borsh_string(recipient) + || 0x00 # callbackUrl: Option = None +digest = sha256(tag || body) +sig = ed25519_sign(owner_secret, digest) +signed_data = { ...intent, public_key: "ed25519:", signature: base64(sig) } +``` + +Browser wallets: `wallet.signMessage({ message, recipient, nonce: Uint8Array })` — decode the +base64 nonce to bytes first. The key must be a **FullAccess** key of `signer.account_id` +(otherwise `owner_key_not_full_access`). + +## nep366 — gasless NEAR delegate action (on chain) + +Payload: `{ receiverId, actions: [ { type: "FunctionCall", params: { methodName, args, gas, deposit } } ] }` +(1–2 actions; `args` is a JSON object; `gas` and `deposit` are decimal strings). + +The signer needs chain state: query the owner's access key (`view_access_key`, finality +`final`) on a NEAR mainnet RPC (e.g. `https://free.rpc.fastnear.com`, +`https://rpc.mainnet.near.org`). + +```text +nonce = access_key.nonce + 1 +max_block_height = access_key_query.block_height + 120 # ~2 minutes + +DelegateAction (borsh) = + borsh_string(sender_id = signer.account_id) + || borsh_string(receiverId) + || u32_le(len(actions)) + || for each action: + 0x02 # Action::FunctionCall + || borsh_string(methodName) + || u32_le(len(argsBytes)) || argsBytes # argsBytes = JSON.stringify(args) as UTF-8 + || u64_le(gas) + || u128_le(deposit) + || u64_le(nonce) + || u64_le(max_block_height) + || 0x00 || pubkey[32] # PublicKey::ED25519 + +digest = sha256(u32_le(2^30 + 366) || DelegateAction) # NEP-461 prefix 0x6e 0x01 0x00 0x40 +signature = ed25519_sign(owner_secret, digest) +SignedDelegate = DelegateAction || 0x00 || signature[64] # Signature::ED25519 +signed_data = { ...intent, signed_delegate: base64(SignedDelegate) } +``` + +**The args trap.** The API compares the base64 of `argsBytes` against what it generated, so +`argsBytes` must equal JavaScript `JSON.stringify(args)`: keys in the order received, no spaces, +non-ASCII characters as raw UTF-8 (not `\uXXXX`), integers without `.0`. In practice: + +| Language | Do | +|---|---| +| Python | `json.loads` keeps order; `json.dumps(args, separators=(",", ":"), ensure_ascii=False).encode()` | +| Rust | `serde_json` with feature **`preserve_order`**, then `serde_json::to_vec(&args)` | +| Go | decode into an ordered structure (e.g. keep `json.RawMessage` of `args` and compact it with `json.Compact`), never `map[string]any` (Go sorts map keys) | +| Java/Kotlin | Jackson `ObjectMapper` reading into `JsonNode` keeps order; write without pretty printing; disable `ESCAPE_NON_ASCII` | +| C# | `System.Text.Json` `JsonNode` keeps order; use `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` so non-ASCII is not escaped | + +The simplest robust approach in any language: keep `args` as the raw JSON bytes from the +response and minify them without reordering. + +Browser wallets: `wallet.signDelegateActions({ delegateActions: [intent.payload] })` and submit +`signedDelegateActions[0]`. The API's sponsor relays it; the owner pays no gas. + +`policy_delegate_signature_invalid` / `intent_payload_mismatch` on nep366 almost always means args bytes, +nonce (stale access-key read: use finality `final` and sign right away) or the wrong key. + +## eip712 — EVM owners + +Payload is a complete typed-data document: `{ types: { EIP712Domain: [...], : [...] }, primaryType, domain, message }`. +Domain is `{ name: "NEAR Wallet Contract", version: "1" }` (no chainId / verifyingContract); +`primaryType` is `Authorization` or `WalletMessage`. + +```text +signature = secp256k1_sign(eip712_hash(payload)) # standard eth_signTypedData_v4 +signed_data = { ...intent, signature: "0x" + r(32) + s(32) + v(1) } # 130 hex chars +``` + +| Language | Call | +|---|---| +| Browser | viem `walletClient.signTypedData({ account, ...payload })` / `eth_signTypedData_v4` | +| TypeScript server | viem `privateKeyToAccount(pk).signTypedData(payload)` | +| Python | `Account.sign_message(encode_typed_data(full_message=payload), private_key=pk)` (eth-account ≥0.13) | +| Rust | alloy `TypedData` (from the JSON) → `eip712_signing_hash()` → `PrivateKeySigner::sign_hash` | +| Go | go-ethereum `apitypes.TypedDataAndHash(typedData)` → `crypto.Sign`, then add 27 to `v` | + +Owner descriptor for creation: `{ type: "evm", address: lowercase, chain_id: 1, +public_key: "0x" + uncompressed point without the 04 prefix (128 hex) }`. EOAs only. + +KMS/HSM keys (AWS KMS, GCP KMS, Fireblocks raw signing) work: compute the EIP-712 digest +locally, have the KMS sign the 32-byte digest, normalize `s` to low-S, derive `v` by recovery. + +## webauthn — passkey owners + +Payload is WebAuthn request options: `{ challenge, rpId, allowCredentials, userVerification: "required", timeout }`. +Only the user's authenticator can sign: + +```ts +import { startAuthentication } from "@simplewebauthn/browser"; +const credential = await startAuthentication({ optionsJSON: intent.payload }); +signed_data = { ...intent, credential }; +``` + +Registration: ES256 (`alg: -7`), user verification required; store the credential id and the +**SPKI DER** public key (base64url), `rp_id` and exact `origin`. Convert COSE keys to SPKI in +your backend. Cross-origin iframes are rejected. + +## Server-side signer implementations in this skill + +| Language | File | Functions | +|---|---|---| +| TypeScript | examples repo `support/sign-near-intent.ts`, `support/evm-owner.ts` | `signNearIntent`, `signEvmIntent` (near-api-js, viem) | +| Python | [../assets/python/near_intents_agent_api.py](../assets/python/near_intents_agent_api.py) | `sign_intent`, `sign_nep413`, `sign_nep366`, `sign_delegate`, `sign_eip712`, `NearKey` | +| Rust | [../assets/rust/src/lib.rs](../assets/rust/src/lib.rs) | `sign_intent`, `sign_nep413`, `sign_nep366`, `sign_delegate`, `sign_eip712` (feature `evm`), `NearKey` | + +NEAR secret key format everywhere: `ed25519:` (near-cli / +near-api-js key files). Public key: `ed25519:`. + +## Verifying a port + +1. Load `test-vectors.json`. +2. `NearKey(near.secret).public_key == near.public_key`. +3. `sign_nep413(near, nep413.payload).signature == nep413.signature_b64` (ed25519 is deterministic). +4. `hex(encode_delegate_action(...))` with `access_key_nonce + 1` and `block_height + 120`, + prefixed by the NEP-461 tag, `== nep366.encoded_hex`; `signed_delegate` matches. +5. EIP-712 signature over `eip712.payload` with `eip712.private_key` `== eip712.signature`. +6. `sha256_hex(grant.token) == grant.commitment`. + +Python: `python assets/python/test_vectors.py`. Rust: `cargo test --features evm` in +`assets/rust`. diff --git a/.agents/skills/near-intents-agent-api/references/status-and-recovery.md b/.agents/skills/near-intents-agent-api/references/status-and-recovery.md new file mode 100644 index 000000000..5c1e71760 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/status-and-recovery.md @@ -0,0 +1,119 @@ +# Status, idempotency and recovery + +## Statuses + +| Status | Meaning | Your move | +|---|---|---| +| `PENDING_SIGNATURE` | owner intent generated, not yet signed | get the signature or let it expire | +| `QUEUED` | held by the policy timelock until `details.execute_after` | wait; owner may `execution_cancel` | +| `PENDING_APPROVAL` | waiting for the owner's `approval_vote` (`details.approval_id`) | ask the owner; poll slowly | +| `PENDING_DEPOSIT` | deposit address issued, waiting for funds | show `deposit_address`; poll until `expires_at` | +| `PROCESSING` | submitted, settlement not yet observed | keep polling | +| `SUCCESS` | settled with evidence | done | +| `REFUNDED` | route refunded the input (`refund_tx_hash`) | done; tell the user | +| `FAILED` | never executed, or failed with `failure_code` | done; new request needs a new key | +| `UNCERTAIN` | outcome unknown, **may have executed** | keep polling the same id; never resubmit under a new key | +| `NEEDS_REVIEW` | automatic settlement stopped | stop; inspect `details.reason`; then `refresh=true` | + +Terminal: `SUCCESS`, `REFUNDED`, `FAILED`. `UNCERTAIN` usually resolves on its own as the API +reconciles with the provider. + +## Polling + +```text +GET /v1/status?correlation_id=&wait_ms=30000 +``` + +- Long-poll: returns early when the status becomes terminal, needs a signature, or needs review; + otherwise after `wait_ms` (max 30000). Reading also advances the operation (the API checks + chain/provider on read), so polling is how slow operations make progress — don't stop early. +- Max 10 concurrent long-polls per API key; further reads answer immediately (no error), so + don't build a tight loop on top of them. Add a small sleep when a read returns instantly with + a non-terminal status. +- Typical loop: repeat while status ∈ {`PROCESSING`, `QUEUED`} up to your deadline (a few + minutes for swaps/transfers, longer for cross-chain withdrawals and deposits). For + `PENDING_APPROVAL` / `PENDING_DEPOSIT` hand control back to the user and poll on a slower + schedule (or when they return). +- `GET /v1/agents/{id}/history` lists everything for an activity feed (newest first). Deposit + addresses nothing was paid into (open, expired, closed by deletion) are left out unless + `include_unpaid=true`; read one by its `op_…` id with `GET /v1/status`. +- A single long poll does not promise settlement; `SUCCESS` means reconciled evidence, not just + a broadcast hash. + +```python +def settle(api, cid, deadline_s=300): + end = time.monotonic() + deadline_s + while True: + s = api.get_status(cid, wait_ms=30_000) + if s["status"] not in ("PROCESSING", "QUEUED") or time.monotonic() > end: + return s +``` + +## Idempotency keys + +- Required on every non-dry execution and deposit; optional on `generate-intent`. +- 8–128 characters from `[A-Za-z0-9._:-]`. A UUIDv4 or `":"` works. + Don't build keys from unbounded user data (length) or from values that repeat across distinct + requests (e.g. `transfer-${agentId}-${recipient}` would block the second payment to the same + person). +- **Create → persist with the body → send.** If the process dies after sending, the stored key + lets you find or safely repeat the request. +- Same key + same body → the original operation (no double spend), even across restarts. +- Same key + different body → `409 idempotency_conflict`. +- After a definitive refusal or `FAILED`, a retry is a new request → new key. +- SDK/assets return the key they used (`result.idempotencyKey`, `AgentApiRequestError.idempotencyKey`) + so you can retry with it after a transport error. + +## UNCERTAIN + +The API lost the provider's answer around dispatch, so it cannot prove whether the money moved. + +1. Keep polling the same `correlation_id`. The API reconciles via the provider's records. +2. Never resubmit under a new idempotency key — that can pay twice. +3. If it stays `UNCERTAIN`, look at the operation: + - has `provider_request_id` or `dispatch_committed_at` → **the provider got it**. Only + polling resolves it. Never recover. + - neither → **the provider never got it**. `POST …/recover` may dispatch it, once. + +### `POST /v1/agents/{agent_id}/recover` + +```http +POST /v1/agents/{agent_id}/recover +X-API-Key: +X-Grant-Token: +Idempotency-Key: +{ "correlation_id": "op_…", "request": { "type": "withdraw", …the original body, byte-identical after canonicalization… } } +``` + +Authorization runs again in full: if the grant was revoked, the account frozen or the policy +tightened since, recovery is refused. `operation_not_recoverable` means it was not eligible (not +UNCERTAIN, already reached the provider, cross-chain deposit, or another API key); +`operation_recovery_unavailable` means a recovery is already running. This is why you persist +the original body and key. + +## NEEDS_REVIEW + +Automatic settlement stopped because something did not add up (e.g. evidence conflicts). + +1. Stop polling and stop retrying. +2. Read `details.reason`; cross-check `GET …/provider/requests`, `…/provider/audit`, + balances. +3. After resolving, `GET /v1/status?correlation_id=…&refresh=true` to re-observe. +4. Surface it to an operator; don't tell the user the payment failed or succeeded. + +## Operation proofs + +`GET /v1/agents/{agent_id}/operations/{correlation_id}/proof` (an `op_…` id) returns every +retained audit event of one execution, each `PROVEN` with a `c2sp.org/tlog-proof@v1` against the +notary-signed checkpoint of the API's transparency log, `PENDING` until the next checkpoint covers +it, or `UNLOGGED`. Each event's `evidence` is the result it recorded, with any settlement +transaction hashes, committed by the opening's `evidenceHash`. Use it to audit an execution offline without trusting the API; it does not +change the execution's status. Check it with the SDK's `verifyOperationProof(proof, origin)`, pinning +the origin yourself (`/log`, `api.agentsonintents.com/log` for the hosted API); see +[typescript.md](typescript.md#operation-proofs) and the examples' `11:verify-operation`. + +## Revocation and freezing vs in-flight work + +`dispatch_committed_at` is the point of no return. Before it, revoking the grant, revoking the +API key or freezing refuses the operation. After it, the operation completes; `grant_revoke` +details and API-key revocation list those committed ids. diff --git a/.agents/skills/near-intents-agent-api/references/typescript.md b/.agents/skills/near-intents-agent-api/references/typescript.md new file mode 100644 index 000000000..ea70c1660 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/typescript.md @@ -0,0 +1,204 @@ +# TypeScript / JavaScript + +```bash +npm i @near-intents-agent-api/sdk # or pnpm add / yarn add / bun add +``` + +Node 24+ (or any runtime with `fetch` and Web Crypto). The SDK is a thin, typed client generated +from the server's endpoint registry: one method per endpoint, JSON:API errors, no retries, no +wallet signers, **server-side only** (it needs the API key). + +## Client + +```ts +import { AgentApi, AgentApiError, AgentApiRequestError, createGrantCredential, createIdempotencyKey } from "@near-intents-agent-api/sdk"; + +const api = new AgentApi({ + apiKey: process.env.NEAR_INTENTS_AGENT_API_KEY!, // naa_… from the partner dashboard + baseUrl: process.env.NEAR_INTENTS_AGENT_API_URL, // optional; defaults to https://api.agentsonintents.com +}); + +await api.getNetwork(); +await api.whoami(); +await api.getPartnerQuota(); +const tokens = await api.getTokens(); // TokenView[] +``` + +Options: `baseUrl`, `timeoutMs` (default 150 s for settlement writes, 65 s otherwise), +`maxResponseBytes`, `fetch`, `grantToken`. + +## Owner intent loop + +```ts +const generated = await api.generateIntent({ + type: "agent_create", + name: "Trading assistant", + external_user_id: user.id, + owner: { type: "near", account_id: "alice.near", public_key: "ed25519:…" }, + policy, +}); +// generated: { correlation_id, agent_id, intent: { standard, payload }, preview, expires_at, signer, idempotencyKey } + +// → send { correlation_id, intent, preview } to the browser; it returns signed_data +const signed_data = await browserSigns(generated.intent); + +await api.submitIntent({ type: generated.type, correlation_id: generated.correlation_id, signed_data }); +let status = await api.getStatus(generated.correlation_id, { waitMs: 30_000 }); +``` + +`generateIntent` returns `idempotencyKey`; to retry an interrupted generation pass the same one: +`api.generateIntent(req, { idempotencyKey })`. + +Typed narrowing: `GenerateIntentResponseOf<"policy_update">`, `StatusResponseOf<"swap">`. + +## Grants + +```ts +const { token, commitment } = createGrantCredential(); // store token encrypted first +const g = await api.generateIntent({ + type: "grant_issue", agent_id, label: "assistant-session-42", + credential: commitment, + expires_at: new Date(Date.now() + 7 * 86_400_000).toISOString(), +}); +// owner signs, submit, wait → status.details.grant.grant_id + +const agent = api.forGrant(token); // one client per grant +``` + +## Executions + +```ts +const quote = await agent.swap(agentId, { + origin_asset: "nep141:wrap.near", destination_asset: usdc, amount: "1000000000000000000000000", dry: true, +}); // QuoteResponse, no key + +const idempotencyKey = createIdempotencyKey(); // persist with the body first +const op = await agent.swap(agentId, { origin_asset: "nep141:wrap.near", destination_asset: usdc, amount }, { idempotencyKey }); +// op: StatusResponseOf<"swap"> & { idempotencyKey } + +const deposit = await api.deposit(agentId, { origin_asset: usdcOnBase }, { idempotencyKey: createIdempotencyKey() }); +// deposit.details: deposit_address, memo?, min_amount, expires_at, refund_to +``` + +Also: `withdraw`, `transfer`, `shield`, `unshield`, `recover(agentId, { correlation_id, request }, { idempotencyKey })`. + +## Reads + +`listAgents({ external_user_id, cursor })`, `getAgent`, `getWallet`, `getBalances(id, { source, asset })`, +`getPolicy`, `getPolicyHistory`, `listGrants`, `listScheduledExecutions`, `getContainment`, +`listApprovals`, `getApproval`, `getAddress(id, "near")`, `listProviderRecords(id, kind)`, +`getHistory(id, { cursor, limit, include_unpaid })`, `getStatus(cid, { waitMs, refresh })`, +`getOperationProof(id, cid)`. + +## Identity signing + +With a grant, and only for recipients in the policy's `sign.recipients` (never `intents.near` or +`intents.far`), the API signs a canonical identity challenge with the agent's NEAR key (NEP-413): + +```ts +const signature = await agent.sign(agentId, { message: canonicalChallengeJson, recipient: "login.example.near" }); +// signature: { near_account_id, public_key, signature (hex), nonce (base64), recipient } +``` + +The challenge format is in [policy.md](policy.md#signing-sign). The relying party verifies the +NEP-413 signature over `message`, `nonce` and `recipient`, and that `public_key` is a full-access +key of `near_account_id`; the examples' `support/identity.ts` does both. + +## Operation proofs + +```ts +import { NoteError, ProofError, verifyOperationProof } from "@near-intents-agent-api/sdk"; + +const proof = await api.getOperationProof(agentId, "op_…"); // executions only +const verified = verifyOperationProof(proof, "api.agentsonintents.com/log"); // pin the origin yourself +// verified.proven: [{ index, fields, checkpoint, cosignedAt, evidence }]; verified.pending, verified.unlogged +``` + +Offline and without trusting the API: each `PROVEN` event hashes to a leaf of a checkpoint signed by +the log key and cosigned by the notary, and names this execution. Its `evidence`, the result it +recorded with any settlement transaction hashes, must hash to the opening's `evidenceHash` and comes +back parsed (`null` when none was served). A proof that does not hold, or altered evidence, throws +`NoteError` or `ProofError`. `PENDING` events wait for the next checkpoint. To tie the keys to +attested code, check the notary's TDX birth quote (`proof.notary.birth`) and compare its +`report_data` with `verified.birthReportData`. A deployment without a log answers +`501 transparency_log_disabled`. + +## Errors + +```ts +try { + await agent.withdraw(agentId, req, { idempotencyKey }); +} catch (error) { + if (error instanceof AgentApiError) { + switch (error.code) { + case "policy_destination_denied": /* ask owner to allow the address */ break; + case "spend_budget_exceeded": /* show remaining budget */ break; + case "policy_schedule_denied": /* outside owner hours: same request at error.availableAt */ break; + case "policy_change_throttled": /* error.availableAt */ break; + default: throw error; + } + } else if (error instanceof AgentApiRequestError) { + // transport failure: retry with error.idempotencyKey and the same body + } +} +``` + +## Waiting helper + +```ts +async function settle(api: AgentApi, cid: string, deadlineMs = 300_000) { + const end = Date.now() + deadlineMs; + for (;;) { + const s = await api.getStatus(cid, { waitMs: 30_000 }); + if (!["PROCESSING", "QUEUED"].includes(s.status) || Date.now() > end) return s; + } +} +``` + +## Signing on the server (scripts, tests, company-held owner keys) + +NEAR (near-api-js v7) — from the examples' `support/sign-near-intent.ts`: + +```ts +import { createHash } from "node:crypto"; +import { actions, encodeDelegateAction, encodeSignedDelegate, JsonRpcProvider, KeyPair, Signature } from "near-api-js"; + +async function signNearIntent(generated, keyPair: KeyPair, rpcUrl: string) { + const { intent, signer } = generated; + if (intent.standard === "nep413") + return { ...intent, public_key: signer.public_key, + signature: await signNep413(keyPair, { ...intent.payload, nonce: Buffer.from(intent.payload.nonce, "base64") }) }; + const access = await new JsonRpcProvider({ url: rpcUrl }).viewAccessKey({ + accountId: signer.account_id, publicKey: signer.public_key, finalityQuery: { finality: "final" } }); + const delegateAction = { + senderId: signer.account_id, receiverId: intent.payload.receiverId, publicKey: keyPair.getPublicKey(), + nonce: access.nonce + 1n, maxBlockHeight: BigInt(access.block_height) + 120n, + actions: intent.payload.actions.map(({ params }) => + actions.functionCall(params.methodName, Buffer.from(JSON.stringify(params.args)), BigInt(params.gas), BigInt(params.deposit))), + }; + const digest = createHash("sha256").update(encodeDelegateAction(delegateAction)).digest(); + const signature = new Signature({ keyType: 0, data: keyPair.sign(digest).signature }); + return { ...intent, signed_delegate: Buffer.from(encodeSignedDelegate({ delegateAction, signature })).toString("base64") }; +} +``` + +(`encodeDelegateAction` already prepends the NEP-461 prefix. `signNep413` is in the examples' +`support/near-owner.ts`: sha256 of the NEP-413 borsh body, signed with the key pair, base64.) + +EVM (viem): + +```ts +import { privateKeyToAccount } from "viem/accounts"; +const account = privateKeyToAccount(pk); +const owner = { type: "evm", address: account.address.toLowerCase(), chain_id: 1, public_key: `0x${account.publicKey.slice(4)}` }; +const signed_data = { ...intent, signature: await account.signTypedData(intent.payload) }; +``` + +Browser signing for all four standards: [owner-intents.md](owner-intents.md#backend-for-frontend-bff-split). + +## Next.js / serverless notes + +- Keep the SDK in server code only (route handlers, server actions); never import it into a + client component with the key. +- Long-polls take up to 30 s and executions up to ~2 min: set the function timeout accordingly + or move polling to a background job / queue. diff --git a/.agents/skills/near-intents-agent-api/scripts/check_api.sh b/.agents/skills/near-intents-agent-api/scripts/check_api.sh new file mode 100755 index 000000000..f77aea5c9 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/scripts/check_api.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Read-only smoke test for a NEAR Intents Agent API key. Moves no funds, changes nothing. +# +# NEAR_INTENTS_AGENT_API_KEY=naa_... ./check_api.sh +# NEAR_INTENTS_AGENT_API_URL=https://api.agentsonintents.com NEAR_INTENTS_AGENT_API_KEY=naa_... ./check_api.sh +# +# Needs curl; uses jq for pretty output when installed. +set -euo pipefail + +API_URL="${NEAR_INTENTS_AGENT_API_URL:-https://api.agentsonintents.com}" +API_URL="${API_URL%/}" + +pretty() { if command -v jq >/dev/null 2>&1; then jq "$@"; else cat; fi; } +count() { if command -v jq >/dev/null 2>&1; then jq -r '.data | length'; else grep -o "\"$1\"" | wc -l | tr -d ' '; fi; } + +call() { + local path="$1" auth="${2:-}" + local args=(-sS -w '\n%{http_code}' -H 'Accept: application/json') + [[ -n "$auth" ]] && args+=(-H "X-API-Key: $auth") + local out code + out="$(curl "${args[@]}" "$API_URL$path")" + code="${out##*$'\n'}" + BODY="${out%$'\n'*}" + STATUS="$code" +} + +echo "API: $API_URL" + +call /v1/network +if [[ "$STATUS" != 200 ]]; then + echo "✗ GET /v1/network → $STATUS (is the URL right?)"; echo "$BODY"; exit 1 +fi +echo "✓ network" +echo "$BODY" | pretty '{network, contract_id, supported_owner_types, overall: .status.overall, + components: [.status.components[] | {id, status, reason}]}' + +call /v1/tokens +echo "✓ tokens: $(echo "$BODY" | count asset_id) assets" + +if [[ -z "${NEAR_INTENTS_AGENT_API_KEY:-}" ]]; then + echo "! NEAR_INTENTS_AGENT_API_KEY not set: skipped authenticated checks (create a key in the partner dashboard)" + exit 0 +fi +if [[ ! "$NEAR_INTENTS_AGENT_API_KEY" =~ ^naa_[A-Za-z0-9_-]{43}$ ]]; then + echo "✗ NEAR_INTENTS_AGENT_API_KEY does not look like a naa_ key (check for quotes or whitespace)"; exit 1 +fi + +call /v1/whoami "$NEAR_INTENTS_AGENT_API_KEY" +if [[ "$STATUS" != 200 ]]; then + echo "✗ GET /v1/whoami → $STATUS"; echo "$BODY" | pretty '.errors[0] | {code, detail}'; exit 1 +fi +echo "✓ key valid"; echo "$BODY" | pretty . + +call /v1/quotas "$NEAR_INTENTS_AGENT_API_KEY" +echo "✓ quotas"; echo "$BODY" | pretty '{limits, usage}' + +call "/v1/agents" "$NEAR_INTENTS_AGENT_API_KEY" +echo "✓ agents on this tenant (first page): $(echo "$BODY" | count external_user_id)" diff --git a/.changeset/agent-connect-client.md b/.changeset/agent-connect-client.md new file mode 100644 index 000000000..1736c412c --- /dev/null +++ b/.changeset/agent-connect-client.md @@ -0,0 +1,5 @@ +--- +"better-near-auth": minor +--- + +Add `signAgentIntent` to the client surface (`better-near-auth/client`): signs a generated Agent Connect owner intent byte-for-byte with the connected wallet — `nep413` via signMessage, `nep366` via signDelegateActions, `webauthn` via the passkey authenticator — returning the wallet's output for the caller to submit. The agent-account control plane itself moved out of this package into the new `@everything-dev/agent-connect-plugin`. diff --git a/.changeset/agent-connect-plugin.md b/.changeset/agent-connect-plugin.md new file mode 100644 index 000000000..8f957a930 --- /dev/null +++ b/.changeset/agent-connect-plugin.md @@ -0,0 +1,5 @@ +--- +"everything-dev": minor +--- + +New plugin: the Agent Connect control plane — every signed-in user gets an owner-controlled agent account on the NEAR Intents Agent API (https://api.agentsonintents.com). The owner signs the rules once with their wallet; agents hold revocable `ngt_` grant tokens that only act inside the owner-signed policy. The plugin consumes `@near-intents-agent-api/sdk` (catalog-pinned, the only protocol dependency — route outputs are the SDK's own types, never re-declared) and adds only platform concerns: session ownership via the auth context, the sealed grant-token vault (HKDF-SHA256 → AES-256-GCM, never sent to a browser), the pre-send intent ledger (persist before send; prepare routes accept an optional client idempotency key — same key replays the stored intent or resends the stored request, cross-type reuse conflicts), submit and status polling share one reconciliation path (a later success applies the same local effects as an immediate one), and the oRPC surface (`agent/create`, `agent/list`, `agent/{id}`, `agent/intents/prepare|submit|status`, `agent/{id}/grants`) which exposes the domain to REST and MCP through the host's OpenAPI mounts. Owner intents follow the BFF split — prepare returns `intent.payload` + preview, the browser signs via better-near-auth's `signAgentIntent`, submit carries the wallet's output. Dormant until `NEAR_INTENTS_AGENT_API_KEY` is configured; own `plugin_agent_connect` schema (`AGENT_CONNECT_DATABASE_URL`, pglite default); `AGENT_CONNECT_ENCRYPTION_KEY` isolates the vault key. diff --git a/.env.test b/.env.test index 28a9bcdb8..7486e19d8 100644 --- a/.env.test +++ b/.env.test @@ -17,3 +17,6 @@ PROPOSALS_DATABASE_URL=pglite::memory: # plugins.votes VOTES_DATABASE_URL=pglite::memory: +# plugins.agent-connect +AGENT_CONNECT_DATABASE_URL=pglite::memory: + diff --git a/GLOSSARY.md b/GLOSSARY.md index dc4269519..c5c47baf2 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -138,6 +138,24 @@ _Avoid_: provision, register One of a Gas Key's independent nonce sequences; parallel sends pick different Lanes and never serialize on each other. _Avoid_: nonce (the per-lane counter), slot +## Agent connect + +**Agent Account**: +A custody account on NEAR Intents owned by a signed-in user and driven by AI agents under the owner-signed policy; the auth plugin maps one Better Auth user to their Agent Accounts (`agentAccount`). +_Avoid_: agent wallet (that's the custody Wallet inside it), bot account + +**Owner Intent**: +One owner-authorized change to an Agent Account (create, policy, grant, freeze, delete) — generated by the backend, signed by the owner's wallet byte-for-byte, then submitted; tracked per user in `ownerIntent` by Correlation Id. +_Avoid_: transaction (on-chain only for some intents), action (that's a policy permission) + +**Grant Token**: +The revocable `ngt_…` credential letting one agent session act on an Agent Account inside the policy; the server stores it encrypted at rest and it never reaches a browser or model. +_Avoid_: API key (the partner's `naa_` credential — different party), session token + +**Correlation Id**: +The tracking id of one Owner Intent or execution (`intent_…`/`op_…`); the ledger authorizes submits and status reads against it. +_Avoid_: operation id generically, request id (that's the API's `meta.request_id`) + ## Custody publish **Custody Wallet**: diff --git a/bos.app.ts b/bos.app.ts index 9699faba0..5c96fc75b 100644 --- a/bos.app.ts +++ b/bos.app.ts @@ -124,5 +124,16 @@ export default App({ secrets: ["AI_API_KEY"], ui: { name: "ai-ui", path: "plugins/ai/ui" }, }), + "agent-connect": Plugin("agent-connect").path("plugins/agent-connect", { + name: "@everything-dev/agent-connect-plugin", + variables: { + apiBaseUrl: "https://api.agentsonintents.com", + }, + secrets: [ + "NEAR_INTENTS_AGENT_API_KEY", + "AGENT_CONNECT_ENCRYPTION_KEY", + "AGENT_CONNECT_DATABASE_URL", + ], + }), }, }); diff --git a/docs/plans/README.md b/docs/plans/README.md index a50eb2bb6..bbecbd93d 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -13,6 +13,7 @@ These are architecture and implementation plans, not issue tickets — sprint ti docs/plans/ ├── 001–066 (flat) # advisor audit queue (open work) ├── beta-v2/ # v2 platform architecture (5 docs) +├── target-state/ # platform target-state vision: primitives, default path, economy ├── deploy-agents-rebuild/ # sprint: deploy consolidation + agents rebuild (SPRINT.md + tickets) ├── extensions/ # plans that extend the beta-v2 architecture ├── infra/ # infrastructure & migration plans diff --git a/docs/plans/target-state/README.md b/docs/plans/target-state/README.md new file mode 100644 index 000000000..9f4c29d91 --- /dev/null +++ b/docs/plans/target-state/README.md @@ -0,0 +1,232 @@ +# The target state: an elegant runtime for apps and agents + +> **Category**: direction — this is a vision doc, not an executor plan. It +> records the target state of the platform and the decisions and open design +> space that follow from it. Decisions recorded here graduate into ADRs when +> they land; open items graduate into wayfinder tickets when they are ready +> to be decided. Nothing in this doc gates an executor. + +## Thesis + +Humans own wallets and sign rules. Agents act through scoped credentials. +Apps are published compositions. Machines are rented, pinned images. And +everything durable is on-chain. + +A developer writes one authored file (`bos.app.ts`); the runtime composes it. +A user signs in once and automatically holds everything they need — session, +platform access, publish identity, payment rail. An agent holds nothing and +acts through credentials scoped to one plane each. No principal ever holds +more key than its plane requires. + +"For other apps" is the point: any app extends the root runtime, composes +plugins, and inherits the entire surface — auth, API, MCP, the deploy train, +the agent money planes — without building any of it. Tenants compose; agents +operate; the platform pays for itself at the seams where value moves. + +## The primitives inventory + +The underlying primitives, each grounded in what exists today, with what +stays open. + +| Primitive | What it is | Grounded in | Open | +|---|---|---|---| +| **Identity** | Named NEAR account (vanity, subaccounts), implicit hex account (machine identity), custody wallet (`wk_`, TEE-held), agent account (on NEAR Intents) | ADR 0001 (namespace = signer), custody publish (see AGENTS.md "Custody publish") | Custody ↔ named-account bridge (decision: hex accepted for now — see "The default path") | +| **Credentials** | `api_` (platform access), `wk_` (custody: signing + compute payment), `naa_` (Agent API partner), `ngt_` (agent grant), session gas key (NEP-611), minted deploy keys | auth plugin (API keys, statements/roles), ADR 0017, `bos login --key`, OutLayer custody | Scoped publish delegate for CI (design below) | +| **Registry** | FastKV — self-authenticating namespaces (namespace = signer), action-indexed immutable writes, history as audit trail | ADR 0001 | Metering/fee hooks on writes (economy, below) | +| **Composition** | `bos.app.ts` authored descriptor; `extends` lineage; plugins; `bos import` byte-sourcing with SRI-verified source archives | ADR 0005, 0022, 0030; beta-v2 plan | — | +| **Distribution** | One universal image (digest-pinned), CDN bundles (SRI-pinned), the deploy train with post-publish smoke and rollback | ADR 0011, 0020, 0021 | — | +| **Tenancy & compute** | Tiers: shared-host tenants (`*.`), own-instance children (`.`), sandboxes (`*.*.` — platform image on provisioned machines, lease-scoped) | ADR 0020/0021, plan 032 (spike) | Hot-swap lifecycle, request drain (wayfinder ticket 10); lease pricing | +| **Money** | NEAR Intents: policy (what may happen), grant (who may act), atomic integer amounts, idempotency keys, UNCERTAIN/NEEDS_REVIEW semantics | Agent API (api.agentsonintents.com), `@near-intents-agent-api/sdk`, better-near-auth agent-connect | Platform-side policy templates for apps | +| **Gas** | Three gasless planes (see below) | ADR 0017 (gas keys primary, relayer fallback), custody prepaid balance | Sponsored first publishes (economy, below) | +| **Access** | Platform API keys minted self-serve, permission statements/roles, org delegation | auth plugin permission registry, plan 053 | Key distribution economy (economy, below) | +| **Agent education** | MCP tools from OpenAPI, `/llms.txt`, `/skill.md`, vendored agent skills | host MCP surface, `.agents/skills/near-intents-agent-api` | Refresh cadence from NEAR-Intents-Agent-API/skills upstream | + +### The five credentials, five planes + +| Credential | Plane | Held by | Grants | +|---|---|---|---| +| wallet (NEAR / EVM / passkey) | ownership | the human | signs owner intents: policies, publishes, grants | +| `api_…` | platform access | a person, org, or agent | MCP / REST / oRPC on a runtime | +| `wk_…` | machine identity + compute payment | TEE (OutLayer) | signs publishes; pays agent compute and sandbox leases | +| `naa_…` | Agent API partner | the app backend | calls the hosted agent API | +| `ngt_…` | agent money authority | the backend, per agent session | moves funds inside an owner-signed policy | + +### The three gasless planes + +One mechanism per principal type — they never compose, by protocol: + +| Principal | Mechanism | +|---|---| +| Browser user | Session gas key (NEP-611, sponsor-funded), NEP-366 relayer fallback | +| Machine / agent | Custody wallet prepaid balance via the OutLayer coordinator | +| Named-account owner | Their own gas (escape hatch) | + +A gas key cannot sign delegate actions and `WithdrawFromGasKey` is refused +inside a delegate; the browser plane and the machine plane stay separate. +The custody publish path never touches the NEP-366 relayer. + +## The target-state loop + +An app's whole lifecycle, with no human ever holding a raw private key: + +1. **Author** — `bos init` scaffolds; the developer (or an agent) writes + `bos.app.ts`, declaring local workspaces and `extends`. +2. **Log in** — `bos login` is the one login: SIWN session + `api_` key + + custody wallet registration (TEE-held `wk_`). The CLI prints the wallet's + hex account, network, and funding handoff URL — never the key itself. + When the `outlayer` CLI is present, the wallet links into it. +3. **Fund** — the custody wallet is funded once via the OutLayer handoff URL + (manual for now; see economy). Its prepaid balance pays every publish. +4. **Publish** — `bos deploy` runs the train: preflight → build → bundle + upload (SRI-verified, CDN) → FastKV publish signed by the custody wallet + through the coordinator (`/wallet/v1/call` → `__fastdata_kv`, prepaid gas + deducted, read-back confirmed) → universal image → post-publish smoke → + Railway pinned to the digest. A red smoke rolls the config back. +5. **Inhabit** — tenants extend the runtime (`*.` composition), users + SIWN in and write gaslessly, agents get agent accounts with owner-signed + policies and per-session `ngt_` grants. +6. **Operate** — every runtime is a full agent surface: MCP tools generated + from OpenAPI, REST, oRPC, plugin RPC — all behind `api_` keys or sessions. +7. **Compute** — sandbox tier: agents rent machines (plan 032), paid from the + same `wk_` balance, leases scoped and released like every Effect Layer. +8. **Audit** — every durable step is a FastKV write: config history, deploy + manifests, custody signatures. The on-chain record is the source of truth. + +## The default path + +Decisions (operator, 2026-10-16): + +- **Custody-first login.** `bos login` becomes the single login flow — + session + `api_` key + custody wallet registration in one step. The + `--custody` flag is dropped (it is the default); escape hatches remain as + flags for named-account publishing. +- **`publish.auth` auto-resolves**: custody wallet stored → custody publish; + else wallet session → session publish; else `NEAR_PRIVATE_KEY`. Explicit + authored config still wins over auto-resolution. +- **`NEAR_PRIVATE_KEY` is an escape hatch** — advanced / named-account / + break-glass use. `bos key generate` and the near-cli-rs steps move out of + the default self-deploy flow into escape-hatch docs. +- **Relayer explicit mode (`RelayerExplicitConfig`) is deleted** — already + vestigial; ephemeral mode is the only relayer configuration. +- **Funding is manual for now** — the handoff URL is documented in the login + output and in the deploy failure path (`wallet_underfunded` 402 carries the + exact cost). Sponsored first publishes and stake-to-start are open economy + items, not onboarding blockers. +- **Hex namespaces are accepted** — custody runtimes publish under + `apps//`; branding and routing ride on + `domain`/`gateway`. A named-account bridge (link or delegate) may be + designed later; it is not required. +- **Child CI signs with a scoped publish delegate** — the plan 042 "mint the + token to set in CI" pattern extended from bundle uploads to FastKV writes: + minted from the custody wallet, scoped to account/gateway, revocable, one + secret in CI, no raw NEAR key anywhere. + +### Graduation path (implementation backlog, not built yet) + +1. `bos login` default flow: custody registration folded in, `--custody` + flag retired. +2. `publish.auth` auto-resolution in `publish.ts` (custody → session → + `NEAR_PRIVATE_KEY`). +3. Scoped publish delegate mint (extends the deploy-key mint server route to + FastKV write scope). +4. Delete `RelayerExplicitConfig` + re-add-`NEAR_RELAYER_PRIVATE_KEY` docs. +5. Self-deploy doc rewrite around the 3-command default path. + +## The economy layer (open design space) + +Mapped, not decided. Each item graduates into a wayfinder ticket when it is +ready to be decided. + +### Staking — the two senses + +- **Staking for access** — a principal stakes NEAR to obtain or maintain + access tiers: API-key quotas, sandbox lease eligibility, publish rate + limits. The stake is sybil resistance and skin-in-the-game; it is + collateral, not payment. +- **Staking to run infra** — operators (city nodes, sandbox hosts, tenant + platforms) stake NEAR to participate in serving the network; the stake is + forfeitable for abuse or downtime. This is the economic security layer for + the compute tiers. + +The primitives already support both: the custody wallet is a NEAR account +that can hold staked position, and FastKV's action-indexed writes make +access grants and their revocations auditable. + +### Key issuance and distribution + +- Platform `api_` keys: self-serve minting in Settings → API Keys (exists); + org-scoped delegation and per-member/agent distribution (plan 053 + permissioning is the base); quotas as statements. +- Partner `naa_` keys: the hosted Agent API's partner dashboard — apps + integrate by holding one backend key. +- Minted deploy keys / publish delegates: scoped, revocable, per + account/gateway — the CI story above. + +Open: who can mint for whom; whether staking gates issuance tiers; expiry +and revocation conventions; how tenants re-brand/re-sell keys to their own +users (the two-level distribution problem). + +### Who pays whom + +| Cost | Payer today | Open surface | +|---|---|---| +| Publish gas | custody wallet prepaid balance | sponsored first publishes; publish fees | +| Browser user writes | Sponsor (capped) | sponsor budget tiers; stake-to-raise caps | +| Sandbox compute | `wk_` payments (planned, plan 032) | lease pricing; marketplace of hosts | +| Bundle storage / CDN | platform (R2) | borrowed-storage cold start (plan 042); storage rent | +| Agent executions | users move their own funds inside policies | platform fee via Intents solver spread | + +### Revenue surfaces the primitives already expose + +1. **Compute** — metered sandbox leases paid from `wk_` balances (the same + credential that signs publishes pays for the machines; nothing new to + collect). +2. **Sponsorship beyond caps** — sponsored publishes/writes above the free + tier. +3. **Agent execution flow** — the platform as a high-volume `naa_` partner: + fee or spread share on the money its apps' agents move. +4. **Staking float** — staked access collateral earns yield while locked + (stNEAR-style), aligning the platform's carry with users' security. +5. **Tenant services** — premium composition features (SSR gating is already + a per-tenant column; provisioned infra per plan 031). + +## Use cases this unlocks + +- **Zero-key onboarding** — `bos init` → `bos login` → `bos deploy`; no + near-cli-rs, no env secrets, no crypto knowledge required to run a + production app. +- **Agents as full-stack deployers** — an agent with a custody wallet + authors, publishes, rents compute, and operates an app end to end; a human + signed a policy once. +- **Supply-chain posture** — no plaintext private keys in CI secrets or + repos, ever; TEE-held keys and revocable scoped delegates replace them. +- **Apps with agent economies built in** — a tenant app embeds agent + accounts via better-near-auth's agent-connect (hosted API, `naa_`/`ngt_`), + so its users' agents hold and move funds inside owner-signed policies. +- **A marketplace of runtimes** — the spreadability tree + (everything.dev → citynode.app → chicago.citynode.app) extends downward + forever; each hop is a FastKV record, not a fork. + +## Agents domain engine posture + +Decision (operator, 2026-10-16): **hosted-first**. The hosted Agent API is +the only money plane for now. The vendored self-hosted control plane +(deploy-agents-rebuild sprint, Stream B) is paused; the +`better-near-auth` agent-connect plugin (SDK against +api.agentsonintents.com) is the integration surface. The wire contract is +the stable seam — the vendoring decision is reversible without touching +call sites, and tickets 14–15 (upstream proposals to +NEARFDE/near-intents-agent-api) remain the long-run convergence path. + +## Consulted parents + +- [beta-v2/](../beta-v2/) — composition vision (`app.ts`, tiers, tenants) +- [wayfinder/beta-v2-map.md](../wayfinder/beta-v2-map.md) — decision map; + the "Everything is a node" wayfinder (citynode.app#233–#252) re-derives + tenancy in node/zone vocabulary and consults this doc +- [deploy-agents-rebuild/](../deploy-agents-rebuild/) — deploy consolidation + (landed) + agents rebuild sprint (paused, hosted-first) +- ADRs 0001, 0011, 0017, 0020, 0021, 0022, 0030; plans 031, 032, 042, 053 +- Agent API skill: `.agents/skills/near-intents-agent-api/` (vendored from + NEAR-Intents-Agent-API/skills; upstream currently ships no LICENSE file — + flagged for an upstream MIT request) diff --git a/packages/better-near-auth/package.json b/packages/better-near-auth/package.json index c6b3584b5..091e7c41e 100644 --- a/packages/better-near-auth/package.json +++ b/packages/better-near-auth/package.json @@ -50,6 +50,8 @@ "authentication", "blockchain", "web3", + "agent-connect", + "near-intents", "plugin", "tanstack-intent" ], @@ -79,10 +81,12 @@ } }, "dependencies": { + "@near-intents-agent-api/sdk": "^0.4.1", "@noble/curves": "^2.4.0", "@noble/hashes": "^2.4.0", "@noble/post-quantum": "^0.6.1", "@scure/base": "^2.2.0", + "@simplewebauthn/browser": "^13.3.0", "nanostores": "^1.1.0", "near-kit": "^0.20.2", "zod": "^4.4.3" diff --git a/packages/better-near-auth/src/agent-connect-client.ts b/packages/better-near-auth/src/agent-connect-client.ts new file mode 100644 index 000000000..babc17f2f --- /dev/null +++ b/packages/better-near-auth/src/agent-connect-client.ts @@ -0,0 +1,57 @@ +import type { GenerateIntentResponse, SignedData } from "@near-intents-agent-api/sdk"; +import { getWalletSigner } from "./wallet-signer.js"; + +/** + * Client-side agent wallet signing — the wallet-plumbing half of the BFF + * split. The server half (intent preparation, submission, grant vault) lives + * in the `agent-connect` plugin; this surface only signs a generated + * `intent.payload` with the connected wallet and returns the wallet's output, + * byte-for-byte, for the plugin's submit route. + */ + +export async function signAgentIntent(generated: GenerateIntentResponse): Promise { + const { standard, payload } = generated.intent; + + if (standard === "nep413") { + const signer = getWalletSigner(); + if (!signer) { + throw new Error("Connect your NEAR wallet first to sign this intent"); + } + const nonce = Uint8Array.from(atob(payload.nonce), (c) => c.charCodeAt(0)); + const signedMessage = await signer.wallet.signMessage({ + message: payload.message, + recipient: payload.recipient, + nonce, + network: signer.network, + }); + return { + standard, + payload, + public_key: signedMessage.publicKey, + signature: signedMessage.signature, + }; + } + + if (standard === "nep366") { + const signer = getWalletSigner(); + if (!signer) { + throw new Error("Connect your NEAR wallet first to sign this intent"); + } + const result = await signer.wallet.signDelegateActions({ delegateActions: [payload] }); + const signedDelegate = result.signedDelegateActions[0]; + if (!signedDelegate) { + throw new Error("Wallet returned no signed delegate action"); + } + return { standard, payload, signed_delegate: signedDelegate }; + } + + if (standard === "webauthn") { + const { startAuthentication } = await import("@simplewebauthn/browser"); + const credential = await startAuthentication({ + optionsJSON: payload as Parameters[0]["optionsJSON"], + }); + return { standard, payload, credential } as SignedData; + } + + throw new Error(`Unsupported intent signing standard: ${standard}`); +} diff --git a/packages/better-near-auth/src/auth-callbacks.ts b/packages/better-near-auth/src/auth-callbacks.ts new file mode 100644 index 000000000..425fdb002 --- /dev/null +++ b/packages/better-near-auth/src/auth-callbacks.ts @@ -0,0 +1,4 @@ +export interface AuthCallbacks { + onSuccess?: () => void; + onError?: (error: Error & { status?: number; code?: string }) => void; +} diff --git a/packages/better-near-auth/src/client.ts b/packages/better-near-auth/src/client.ts index d4b708f3c..b4337fa90 100644 --- a/packages/better-near-auth/src/client.ts +++ b/packages/better-near-auth/src/client.ts @@ -58,7 +58,13 @@ import { type ViewContractRequestT, type ViewContractResponseT, } from "./types.js"; +import { + clearWalletSigner, + type NearConnectSignerWallet, + setWalletSigner, +} from "./wallet-signer.js"; +export type { AuthCallbacks } from "./auth-callbacks.js"; export { getNearAtoms, type NearAtomsSource, @@ -67,10 +73,7 @@ export { type NearState, } from "./store.js"; -export interface AuthCallbacks { - onSuccess?: () => void; - onError?: (error: Error & { status?: number; code?: string }) => void; -} +import type { AuthCallbacks } from "./auth-callbacks.js"; export interface SIWNClientConfig { recipient?: string; @@ -161,6 +164,7 @@ export interface SIWNClientActions { }; } +export { signAgentIntent } from "./agent-connect-client.js"; export { BOS_CLI_CLIENT_ID, DEFAULT_DEVICE_LINK_CLIENT_ID } from "./constants.js"; export { isPasskeyWalletAvailable, type PasskeyWalletNetwork } from "./passkey.js"; @@ -248,6 +252,14 @@ export const siwnClient = (config: SIWNClientConfig) => { .getConnectedWallet() .then((connected) => { connectedWallets.set(network, connected); + if (connected.accounts?.[0]?.accountId) { + setWalletSigner({ + accountId: connected.accounts[0].accountId, + publicKey: connected.accounts[0].publicKey ?? null, + network, + wallet: connected.wallet as NearConnectSignerWallet, + }); + } return connected; }) .finally(() => { @@ -315,6 +327,7 @@ export const siwnClient = (config: SIWNClientConfig) => { connector.on("wallet:signOut", () => { connectedWallets.delete(network); + clearWalletSigner(network); if (activeNetwork.get() !== network) return; walletConnected.set(false); const state = nearState.get(); diff --git a/packages/better-near-auth/src/utils.ts b/packages/better-near-auth/src/utils.ts index a93ffbada..ef4e20263 100644 --- a/packages/better-near-auth/src/utils.ts +++ b/packages/better-near-auth/src/utils.ts @@ -20,7 +20,7 @@ export function hexToBytes(hex: string): Uint8Array { return bytes; } -async function deriveAesKey(secret: string): Promise { +async function deriveAesKey(secret: string, salt = "better-near-auth-relayer"): Promise { const keyMaterial = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), @@ -32,7 +32,7 @@ async function deriveAesKey(secret: string): Promise { { name: "HKDF", hash: "SHA-256", - salt: new TextEncoder().encode("better-near-auth-relayer"), + salt: new TextEncoder().encode(salt), info: new Uint8Array(0), }, keyMaterial, @@ -45,8 +45,9 @@ async function deriveAesKey(secret: string): Promise { export async function encryptPrivateKey( privateKey: Uint8Array, secret: string, + salt?: string, ): Promise<{ encrypted: string; iv: string }> { - const aesKey = await deriveAesKey(secret); + const aesKey = await deriveAesKey(secret, salt); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, @@ -63,8 +64,9 @@ export async function decryptPrivateKey( encrypted: string, iv: string, secret: string, + salt?: string, ): Promise { - const aesKey = await deriveAesKey(secret); + const aesKey = await deriveAesKey(secret, salt); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: base64ToBytes(iv) as Uint8Array }, aesKey, diff --git a/packages/better-near-auth/src/wallet-signer.ts b/packages/better-near-auth/src/wallet-signer.ts new file mode 100644 index 000000000..a78fc69d9 --- /dev/null +++ b/packages/better-near-auth/src/wallet-signer.ts @@ -0,0 +1,34 @@ +export interface NearConnectSignerWallet { + signMessage: (params: { + message: string; + recipient: string; + nonce: Uint8Array; + network?: string; + callbackUrl?: string; + }) => Promise<{ accountId: string; publicKey: string; signature: string }>; + signDelegateActions: (params: { + delegateActions: unknown[]; + }) => Promise<{ signedDelegateActions: string[] }>; +} + +export interface NearConnectSigner { + accountId: string; + publicKey: string | null; + network: "mainnet" | "testnet"; + wallet: NearConnectSignerWallet; +} + +const signers = new Map<"mainnet" | "testnet", NearConnectSigner>(); + +export function setWalletSigner(signer: NearConnectSigner): void { + signers.set(signer.network, signer); +} + +export function clearWalletSigner(network: "mainnet" | "testnet"): void { + signers.delete(network); +} + +export function getWalletSigner(network?: "mainnet" | "testnet"): NearConnectSigner | null { + if (network) return signers.get(network) ?? null; + return signers.get("mainnet") ?? signers.get("testnet") ?? null; +} diff --git a/plugins/agent-connect/CHANGELOG.md b/plugins/agent-connect/CHANGELOG.md new file mode 100644 index 000000000..60b1dc240 --- /dev/null +++ b/plugins/agent-connect/CHANGELOG.md @@ -0,0 +1,7 @@ +# @everything-dev/agent-connect-plugin + +## 0.1.0-rc.0 + +### Minor Changes + +- Initial release: agent-account control plane over the hosted NEAR Intents Agent API — session-scoped agent accounts, BFF-split owner intents (prepare → wallet-sign → submit), sealed grant-token vault, own plugin schema. diff --git a/plugins/agent-connect/README.md b/plugins/agent-connect/README.md new file mode 100644 index 000000000..51fda6171 --- /dev/null +++ b/plugins/agent-connect/README.md @@ -0,0 +1,58 @@ +# Agent Connect + +Control plane giving every signed-in user an owner-controlled agent account on the +[NEAR Intents Agent API](https://api.agentsonintents.com) — the owner signs the rules +once with their wallet, and agents hold revocable `ngt_` grant tokens that only act +inside the owner-signed policy. + +## Layering + +- **`@near-intents-agent-api/sdk`** (catalog-pinned) is the only protocol dependency. + Every domain type is re-exported from it, never re-declared. The hosted API is the + source of truth for all shapes (`/openapi.json`). +- **This plugin** adds only platform concerns: session ownership (auth context), + the sealed grant-token vault, the pre-send intent ledger, and the oRPC surface + (REST + MCP via the host's OpenAPI mounts). +- **`better-near-auth`** owns the wallet side: `signAgentIntent` (client surface) + signs a generated `intent.payload` with the connected wallet — nep413, nep366, + or webauthn — and the browser submits the wallet's output back here. + +Flow (the BFF split): `agentCreate` / `prepareIntent` return `intent.payload` + +`preview` only → the browser signs → `intentSubmit` submits the wallet's output. +The `naa_` partner key and `ngt_` grant tokens never reach a browser. + +## Invariants (enforced in code and tests) + +1. Every route requires a session; every agent/grant/correlation id is checked + against the calling user's rows before any host call. +2. Grant tokens are sealed at rest (HKDF-SHA256 → AES-256-GCM) and never appear in + a response — only their sha256 commitment enters the owner-signed intent. +3. Intents are persisted **before** the send (status `PENDING_SIGNATURE`, 15-minute + pre-send TTL) and linked to the real correlation id after; a failed link asks the + caller to retry, which reuses the same idempotency key. +4. Idempotent prepare: `agentCreate`/`prepareIntent` accept an optional client + `idempotencyKey` (8–128 chars, `[A-Za-z0-9._:-]`) — the same key with the same + request replays the stored intent (or resends the exact stored request body under + the same key), and the same key with a different request type conflicts. Upstream + still enforces body equality (`409 idempotency_conflict`). +5. Submit and status polling share one reconciliation path: a `SUCCESS` observed + later applies the same local effects as an immediate one (grant id linked, revoke + marked, agent row activated). Ledger writes are best-effort; the intent-link + failure is fatal. + +## Configuration + +- Secret `NEAR_INTENTS_AGENT_API_KEY` (`naa_…`, partner dashboard) — the plugin is + dormant without it (routes fail with a clear "not configured" error). +- Secret `AGENT_CONNECT_ENCRYPTION_KEY` (32+ bytes) — grant vault key; falls back to + a key derived from the API key when absent (set a dedicated key in production). +- Secret `AGENT_CONNECT_DATABASE_URL` — own `plugin_agent_connect` schema; `pglite:` + default for local dev. +- Variable `apiBaseUrl` — hosted API origin (non-mainnet deployments point elsewhere). + +## Upstream-first + +Needs that require protocol changes go upstream (NEAR-Intents-Agent-API: `sdk-typescript`, +`skills`; private: `api`, `agent-connect`) — never forked locally. The vendored skill +(`.agents/skills/near-intents-agent-api`) is the rules source and drift-tracked via +`skills-lock.json`. diff --git a/plugins/agent-connect/drizzle.config.ts b/plugins/agent-connect/drizzle.config.ts new file mode 100644 index 000000000..7c13ac9d5 --- /dev/null +++ b/plugins/agent-connect/drizzle.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "drizzle-kit"; +import { resolveDatabaseUrl, workspaceIdentityFromModuleUrl } from "everything-dev/db"; + +const identity = workspaceIdentityFromModuleUrl(import.meta.url); + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./src/db/migrations", + dialect: "postgresql", + dbCredentials: { + url: resolveDatabaseUrl(identity), + }, + migrations: { + schema: identity.journal.schema, + table: identity.journal.table, + }, + verbose: true, + strict: true, +}); diff --git a/plugins/agent-connect/package.json b/plugins/agent-connect/package.json new file mode 100644 index 000000000..1f263a6e7 --- /dev/null +++ b/plugins/agent-connect/package.json @@ -0,0 +1,56 @@ +{ + "name": "@everything-dev/agent-connect-plugin", + "version": "0.1.0-rc.0", + "description": "Agent Connect control plane — agent accounts on the NEAR Intents Agent API for every signed-in user", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./contract": { + "types": "./src/contract.ts", + "default": "./src/contract.ts" + } + }, + "peerDependencies": { + "typescript": "catalog:" + }, + "dependencies": { + "@electric-sql/pglite": "catalog:", + "@near-intents-agent-api/sdk": "catalog:", + "@orpc/contract": "catalog:", + "@orpc/openapi": "catalog:", + "@orpc/server": "catalog:", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "pg": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@module-federation/node": "catalog:", + "@module-federation/runtime-tools": "catalog:", + "@rspack/cli": "catalog:", + "@rspack/core": "catalog:", + "@types/node": "catalog:", + "@types/pg": "catalog:", + "@proj-airi/unplugin-drizzle-orm-migrations": "^0.1.4", + "dotenv": "^17.2.3", + "drizzle-kit": "catalog:", + "everything-dev": "workspace:^", + "every-plugin": "workspace:^", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "catalog:" + }, + "scripts": { + "build": "every-plugin build", + "dev": "every-plugin dev", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "typecheck": "tsc --noEmit" + } +} diff --git a/plugins/agent-connect/plugin.dev.ts b/plugins/agent-connect/plugin.dev.ts new file mode 100644 index 000000000..de03f50f3 --- /dev/null +++ b/plugins/agent-connect/plugin.dev.ts @@ -0,0 +1,21 @@ +import "dotenv/config"; +import type { PluginConfigInput } from "every-plugin"; +import type Plugin from "./src/index"; + +export default { + port: Number(process.env.PORT) || 3016, + config: { + variables: { + apiBaseUrl: process.env.AGENT_CONNECT_API_BASE_URL || undefined, + apiTimeoutMs: process.env.AGENT_CONNECT_API_TIMEOUT_MS + ? Number(process.env.AGENT_CONNECT_API_TIMEOUT_MS) + : undefined, + }, + secrets: { + NEAR_INTENTS_AGENT_API_KEY: process.env.NEAR_INTENTS_AGENT_API_KEY || `naa_${"a".repeat(43)}`, + AGENT_CONNECT_ENCRYPTION_KEY: process.env.AGENT_CONNECT_ENCRYPTION_KEY, + AGENT_CONNECT_DATABASE_URL: + process.env.AGENT_CONNECT_DATABASE_URL || "pglite:.bos/agent-connect/:memory:", + }, + } satisfies PluginConfigInput, +}; diff --git a/plugins/agent-connect/src/contract.ts b/plugins/agent-connect/src/contract.ts new file mode 100644 index 000000000..261886565 --- /dev/null +++ b/plugins/agent-connect/src/contract.ts @@ -0,0 +1,301 @@ +import "@orpc/openapi/extensions/route"; +import type { + AgentPage, + AgentView, + GenerateIntentResponse, + GrantView, + StatusResponse, +} from "@near-intents-agent-api/sdk"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; + +/** + * Agent Connect control-plane contract. + * + * Output envelopes are deliberately loose (`z.looseObject`): the hosted Agent + * API (via `@near-intents-agent-api/sdk`) is the source of truth for every + * domain shape — see `.agents/skills/near-intents-agent-api` and the hosted + * `/openapi.json`. Re-declaring deep shapes here would drift. + */ + +const Errors = { + UNAUTHORIZED: { + message: "Authentication required", + }, + FORBIDDEN: { + message: "Operation not permitted", + }, + NOT_FOUND: { + message: "Agent not found for this user", + }, + CONFLICT: { + message: "Conflict with the Agent API state", + }, + BAD_REQUEST: { + message: "Bad request", + }, + TOO_MANY_REQUESTS: { + message: "Agent API rate limit reached", + }, + BAD_GATEWAY: { + message: "Agent API returned an upstream failure", + }, + SERVICE_UNAVAILABLE: { + message: "Agent API is unreachable — retry with the same request", + }, +}; + +const PolicySchema = z + .record(z.string(), z.unknown()) + .describe("Owner-signed spending policy — the hosted Agent API validates its shape"); + +const SignedDataSchema = z + .record(z.string(), z.unknown()) + .describe("The wallet's signature output over intent.payload, submitted unchanged"); + +const IdempotencyKeySchema = z + .string() + .regex(/^[A-Za-z0-9._:-]{8,128}$/, "8–128 chars of [A-Za-z0-9._:-]") + .describe( + "Optional retry key: the same key with the same request returns the same intent (safe retry); the same key with a different request is a conflict", + ); + +/** + * Output envelopes are the SDK's own types (`z.custom`) — the hosted Agent + * API (via `@near-intents-agent-api/sdk`) is the source of truth for every + * domain shape; re-declaring them here would drift. The type-level test + * asserts the generated client drives `signAgentIntent` and the UI without + * casts. The browser sees `intent.payload` + `preview` only — never the + * `naa_` partner key or an `ngt_` grant token. + */ +const GeneratedIntentSchema = z + .custom() + .describe( + "Generated owner intent: correlation_id, agent_id, type, status (PENDING_SIGNATURE), expires_at, signer, intent { standard, payload }, preview, idempotencyKey", + ); + +const StatusSchema = z + .custom() + .describe( + "Intent/operation status: correlation_id, agent_id, type, status, failure_code, details (shape varies by type)", + ); + +const AgentPageSchema = z + .custom() + .describe("Page of agent views: { data, next_cursor }"); + +const AgentViewSchema = z.custom().describe("Agent account view (SDK AgentView)"); + +const GrantsSchema = z + .object({ data: z.array(z.custom()) }) + .describe("Grant views for the agent account (revoked included)"); + +const PrepareIntentInputSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("agent_create"), + name: z.string().min(1).max(64).describe("Display name for the new agent account"), + policy: PolicySchema, + idempotencyKey: IdempotencyKeySchema.optional(), + }), + z.object({ + type: z.literal("grant_issue"), + agentId: z.string().min(1).describe("Agent account to issue the grant on"), + label: z.string().min(1).max(100).describe("Label for the grant, e.g. the session it serves"), + expiresAt: z + .string() + .datetime() + .optional() + .describe("Grant expiry; default 7 days, max 365 days"), + idempotencyKey: IdempotencyKeySchema.optional(), + }), + z.object({ + type: z.literal("grant_revoke"), + agentId: z.string().min(1).describe("Agent account holding the grant"), + grantId: z.string().min(1).describe("Grant to revoke"), + idempotencyKey: IdempotencyKeySchema.optional(), + }), +]); + +export const contract = oc.router({ + agentCreate: oc + .route({ + method: "POST", + path: "/agents", + summary: "Prepare agent account creation", + description: + "Generates the owner-signed agent_create intent. The browser signs intent.payload and submits it via intentSubmit. Requires authentication.", + tags: ["Agents"], + }) + .input( + z.object({ + name: z.string().min(1).max(64).describe("Display name for the new agent account"), + policy: PolicySchema, + idempotencyKey: IdempotencyKeySchema.optional(), + }), + ) + .output(GeneratedIntentSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + agentList: oc + .route({ + method: "GET", + path: "/agents", + summary: "List the user's agent accounts", + description: "Lists agent accounts registered on the hosted Agent API for this user.", + tags: ["Agents"], + }) + .output(AgentPageSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + agentGet: oc + .route({ + method: "GET", + path: "/agents/{agentId}", + summary: "Get an agent account view", + description: "Fetches one agent account; refused unless the agent belongs to the caller.", + tags: ["Agents"], + }) + .input( + z.object({ + agentId: z.string().min(1).describe("Agent account id"), + }), + ) + .output(AgentViewSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + NOT_FOUND: Errors.NOT_FOUND, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + prepareIntent: oc + .route({ + method: "POST", + path: "/agents/intents/prepare", + summary: "Prepare an owner intent for signing", + description: + "Generates a grant_issue or grant_revoke owner intent for the caller to sign. Grant tokens are created server-side, sealed at rest, and never appear in the response — only their commitment enters the intent.", + tags: ["Intents"], + }) + .input(PrepareIntentInputSchema) + .output(GeneratedIntentSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + NOT_FOUND: Errors.NOT_FOUND, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + intentSubmit: oc + .route({ + method: "POST", + path: "/agents/intents/submit", + summary: "Submit a signed owner intent", + description: + "Submits the wallet's signature output for a previously prepared intent. The correlation id must have been issued to the calling user, and the intent type must match.", + tags: ["Intents"], + }) + .input( + z.object({ + type: z + .enum(["agent_create", "grant_issue", "grant_revoke"]) + .describe("Must match the type of the issued intent"), + correlationId: z.string().min(1).describe("The correlation id returned at prepare time"), + signedData: SignedDataSchema, + }), + ) + .output(StatusSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + NOT_FOUND: Errors.NOT_FOUND, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + intentStatus: oc + .route({ + method: "POST", + path: "/agents/intents/status", + summary: "Read the status of an issued intent", + description: + "Polls the hosted Agent API for an intent's status; the correlation id must have been issued to the calling user.", + tags: ["Intents"], + }) + .input( + z.object({ + correlationId: z.string().min(1).describe("The correlation id returned at prepare time"), + waitMs: z + .number() + .int() + .min(0) + .max(30_000) + .optional() + .describe("Long-poll up to this many milliseconds for a terminal status"), + }), + ) + .output(StatusSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + NOT_FOUND: Errors.NOT_FOUND, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + grantList: oc + .route({ + method: "GET", + path: "/agents/{agentId}/grants", + summary: "List an agent account's grants", + description: + "Lists grants from the hosted Agent API; refused unless the agent belongs to the caller.", + tags: ["Grants"], + }) + .input( + z.object({ + agentId: z.string().min(1).describe("Agent account id"), + }), + ) + .output(GrantsSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + NOT_FOUND: Errors.NOT_FOUND, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), +}); + +export type ContractType = typeof contract; diff --git a/plugins/agent-connect/src/db/index.ts b/plugins/agent-connect/src/db/index.ts new file mode 100644 index 000000000..8e9cc360c --- /dev/null +++ b/plugins/agent-connect/src/db/index.ts @@ -0,0 +1,26 @@ +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; +import { createDatabaseDriver as createSharedDriver } from "everything-dev/db"; +import * as schema from "./schema"; + +export type Database = PgDatabase; + +export { DatabaseError, unwrapDatabaseError } from "everything-dev/db"; + +export interface DatabaseDriver { + readonly db: Database; + close(): Promise; +} + +/** + * Thin adapter over the shared `createDatabaseDriver` (`everything-dev/db`). + * + * Engine selection (`pglite:` / `:memory:` → PGlite, else postgres), + * protocol-level `search_path`, one-time schema creation, env-driven pool + * config, and the idempotent close handler all live in the shared driver. + */ +export async function createDatabaseDriver( + url: string, + schemaName?: string, +): Promise { + return createSharedDriver(url, schema, schemaName); +} diff --git a/plugins/agent-connect/src/db/layer.ts b/plugins/agent-connect/src/db/layer.ts new file mode 100644 index 000000000..90feddee3 --- /dev/null +++ b/plugins/agent-connect/src/db/layer.ts @@ -0,0 +1,87 @@ +import { Context, Effect, Layer } from "effect"; +import { PluginIdTag } from "every-plugin"; +import { getMigrationStorage, pluginMigrationSlug } from "everything-dev/db"; +import { createDatabaseDriver, type Database, DatabaseError } from "./index"; +import { detectDrift, loadMigrations, migrate } from "./migrate"; + +export class DatabaseTag extends Context.Service()("Database") {} + +export const DatabaseLive = (url: string) => + Layer.effect( + DatabaseTag, + Effect.gen(function* () { + const pluginId = yield* PluginIdTag; + const slug = pluginMigrationSlug(pluginId); + const schemaName = `plugin_${slug}`; + + const driver = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => createDatabaseDriver(url, schemaName), + catch: (cause) => new DatabaseError({ stage: "driver", cause }), + }), + (driver) => + Effect.tryPromise({ + try: () => driver.close(), + catch: (cause) => new DatabaseError({ stage: "close", cause }), + }).pipe(Effect.ignore), + ); + + const storage = getMigrationStorage(slug); + const { migrations, source } = yield* loadMigrations; + + if (migrations.length === 0) { + yield* Effect.logWarning( + `[Database] No migrations found (source: ${source}) — schema may be missing`, + ); + } else { + const applied = yield* migrate(driver.db, migrations, storage, schemaName); + + if (applied === 0) { + yield* Effect.logInfo( + `[Database] Schema up to date (0 migrations needed, source: ${source})`, + ); + } else { + yield* Effect.logInfo( + `[Database] Applied ${applied}/${migrations.length} migration(s) (source: ${source}, journal: ${storage.schema}.${storage.table}, schema: ${schemaName})`, + ); + } + + const drift = yield* detectDrift(driver.db, migrations, storage, schemaName); + if (drift.status === "healthy" || drift.status === "untracked-existing-schema") { + yield* Effect.logInfo(`[Database] Ready`); + } else if (drift.status === "drift-safe-repair") { + yield* Effect.logWarning( + `[Database] ⚠️ Migration drift detected: ${drift.missingTables.length} expected table(s) missing: ${drift.missingTables.join(", ")}`, + ); + yield* Effect.logWarning( + `[Database] Run \`bos db doctor ${storage.slug}\` to diagnose and \`bos db repair ${storage.slug}\` to fix.`, + ); + return yield* new DatabaseError({ + stage: "migration", + migrationTag: "drift-safe-repair", + cause: new Error( + `Migration journal has ${drift.appliedHashes} applied hashes but all ${drift.expectedTables.length} expected table(s) are missing. ` + + `Run \`bos db repair ${storage.slug}\` to reset the migration history and reapply migrations. ` + + `Missing tables: ${drift.missingTables.join(", ")}`, + ), + }); + } + if (drift.status === "drift-manual") { + yield* Effect.logWarning( + `[Database] ⚠️ Partial migration drift detected: ${drift.missingTables.length}/${drift.expectedTables.length} expected table(s) missing.`, + ); + return yield* new DatabaseError({ + stage: "migration", + migrationTag: "drift-manual", + cause: new Error( + `Partial schema drift — ${drift.missingTables.length}/${drift.expectedTables.length} expected table(s) are missing. ` + + `Run \`bos db doctor ${storage.slug}\` for details. Manual intervention required. ` + + `Missing tables: ${drift.missingTables.join(", ")}`, + ), + }); + } + } + + return driver.db; + }), + ); diff --git a/plugins/agent-connect/src/db/migrate.ts b/plugins/agent-connect/src/db/migrate.ts new file mode 100644 index 000000000..48a5a7355 --- /dev/null +++ b/plugins/agent-connect/src/db/migrate.ts @@ -0,0 +1,43 @@ +import { Effect } from "effect"; +import { + type DatabaseError, + type LoadedMigrations, + loadMigrations as loadSharedMigrations, + type Migration, + type MigrationDatabase, + type MigrationReport, + type MigrationStorage, + runMigrations, +} from "everything-dev/db"; + +export type { DriftReport, LoadedMigrations } from "everything-dev/db"; +export { detectDrift } from "everything-dev/db"; + +export const loadMigrations: Effect.Effect = loadSharedMigrations({ + fromDir: import.meta.dirname, + virtual: () => import("virtual:drizzle-migrations.sql"), +}); + +/** + * Thin adapter over the shared `runMigrations` runner (`everything-dev/db`). + * + * This file is sync-owned: `bos sync` copies it verbatim into every plugin's + * `src/db/migrate.ts`, so it must stay workspace-agnostic — the drizzle + * migrations arrive via the bundler's `virtual:drizzle-migrations.sql` module + * (declared in each workspace's `src/global.d.ts`) with a disk fallback. + * Duplicate-DDL tolerance, savepoint handling, and the retryable-SQLSTATE + * journal-init backoff all live in the shared runner now. + */ +export function migrate( + db: MigrationDatabase, + migrations: Migration[], + storage?: MigrationStorage, + schemaName?: string, +): Effect.Effect { + return Effect.map( + runMigrations(db, migrations, { journal: storage, schemaName }), + (report) => report.applied, + ); +} + +export type { Migration, MigrationReport }; diff --git a/plugins/agent-connect/src/db/migrations/0000_init.sql b/plugins/agent-connect/src/db/migrations/0000_init.sql new file mode 100644 index 000000000..be7b58425 --- /dev/null +++ b/plugins/agent-connect/src/db/migrations/0000_init.sql @@ -0,0 +1,49 @@ +CREATE TABLE "agent_account" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "agent_id" text NOT NULL, + "name" text NOT NULL, + "owner_type" text NOT NULL, + "owner_account_id" text, + "status" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agent_grant" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "agent_id" text NOT NULL, + "grant_id" text, + "correlation_id" text, + "label" text NOT NULL, + "token_ciphertext" text NOT NULL, + "token_iv" text NOT NULL, + "commitment" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "owner_intent" ( + "id" text PRIMARY KEY NOT NULL, + "correlation_id" text NOT NULL, + "idempotency_key" text NOT NULL, + "intent_type" text NOT NULL, + "agent_id" text, + "grant_row_id" text, + "user_id" text NOT NULL, + "status" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "request" jsonb, + "response" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "agentAccount_agentId_uidx" ON "agent_account" USING btree ("agent_id");--> statement-breakpoint +CREATE INDEX "agentAccount_userId_idx" ON "agent_account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "agentGrant_agentId_idx" ON "agent_grant" USING btree ("agent_id");--> statement-breakpoint +CREATE INDEX "agentGrant_userId_idx" ON "agent_grant" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "agentGrant_commitment_idx" ON "agent_grant" USING btree ("commitment");--> statement-breakpoint +CREATE UNIQUE INDEX "ownerIntent_correlationId_uidx" ON "owner_intent" USING btree ("correlation_id");--> statement-breakpoint +CREATE UNIQUE INDEX "ownerIntent_idempotencyKey_uidx" ON "owner_intent" USING btree ("idempotency_key");--> statement-breakpoint +CREATE INDEX "ownerIntent_userId_idx" ON "owner_intent" USING btree ("user_id"); \ No newline at end of file diff --git a/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json b/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..1ee797669 --- /dev/null +++ b/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,376 @@ +{ + "id": "dcc38b20-e016-4567-b55c-73b3630c78e9", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_account": { + "name": "agent_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentAccount_agentId_uidx": { + "name": "agentAccount_agentId_uidx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAccount_userId_idx": { + "name": "agentAccount_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_grant": { + "name": "agent_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "commitment": { + "name": "commitment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentGrant_agentId_idx": { + "name": "agentGrant_agentId_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentGrant_userId_idx": { + "name": "agentGrant_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentGrant_commitment_idx": { + "name": "agentGrant_commitment_idx", + "columns": [ + { + "expression": "commitment", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.owner_intent": { + "name": "owner_intent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent_type": { + "name": "intent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_row_id": { + "name": "grant_row_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ownerIntent_correlationId_uidx": { + "name": "ownerIntent_correlationId_uidx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ownerIntent_idempotencyKey_uidx": { + "name": "ownerIntent_idempotencyKey_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ownerIntent_userId_idx": { + "name": "ownerIntent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/plugins/agent-connect/src/db/migrations/meta/_journal.json b/plugins/agent-connect/src/db/migrations/meta/_journal.json new file mode 100644 index 000000000..c47d811e7 --- /dev/null +++ b/plugins/agent-connect/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1792180403527, + "tag": "0000_init", + "breakpoints": true + } + ] +} diff --git a/plugins/agent-connect/src/db/schema.ts b/plugins/agent-connect/src/db/schema.ts new file mode 100644 index 000000000..827468359 --- /dev/null +++ b/plugins/agent-connect/src/db/schema.ts @@ -0,0 +1,73 @@ +import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; + +/** + * Agent Connect control-plane tables (own plugin schema, `plugin_agent_connect`). + * + * `user_id` is the Better Auth user id resolved from the request context + * server-side — never trusted from client input; no FK (the user table lives + * in the auth plugin's database). + */ + +export const agentAccount = pgTable( + "agent_account", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + agentId: text("agent_id").notNull(), + name: text("name").notNull(), + ownerType: text("owner_type").notNull(), + ownerAccountId: text("owner_account_id"), + status: text("status").notNull(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("agentAccount_agentId_uidx").on(table.agentId), + index("agentAccount_userId_idx").on(table.userId), + ], +); + +export const ownerIntent = pgTable( + "owner_intent", + { + id: text("id").primaryKey(), + correlationId: text("correlation_id").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + intentType: text("intent_type").notNull(), + agentId: text("agent_id"), + grantRowId: text("grant_row_id"), + userId: text("user_id").notNull(), + status: text("status").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), + request: jsonb("request").$type(), + response: jsonb("response").$type(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("ownerIntent_correlationId_uidx").on(table.correlationId), + uniqueIndex("ownerIntent_idempotencyKey_uidx").on(table.idempotencyKey), + index("ownerIntent_userId_idx").on(table.userId), + ], +); + +export const agentGrant = pgTable( + "agent_grant", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + agentId: text("agent_id").notNull(), + grantId: text("grant_id"), + correlationId: text("correlation_id"), + label: text("label").notNull(), + tokenCiphertext: text("token_ciphertext").notNull(), + tokenIv: text("token_iv").notNull(), + commitment: text("commitment").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { mode: "date", withTimezone: true }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("agentGrant_agentId_idx").on(table.agentId), + index("agentGrant_userId_idx").on(table.userId), + index("agentGrant_commitment_idx").on(table.commitment), + ], +); diff --git a/plugins/agent-connect/src/global.d.ts b/plugins/agent-connect/src/global.d.ts new file mode 100644 index 000000000..aa4d71ce6 --- /dev/null +++ b/plugins/agent-connect/src/global.d.ts @@ -0,0 +1,11 @@ +declare module "virtual:drizzle-migrations.sql" { + export interface Migration { + idx: number; + when: number; + tag: string; + hash: string; + sql: string[]; + } + const migrations: Migration[]; + export default migrations; +} diff --git a/plugins/agent-connect/src/index.ts b/plugins/agent-connect/src/index.ts new file mode 100644 index 000000000..358dd4456 --- /dev/null +++ b/plugins/agent-connect/src/index.ts @@ -0,0 +1,516 @@ +import { + createGrantCredential, + createIdempotencyKey, + DEFAULT_BASE_URL, + type GenerateIntentResponse, + type StatusResponse, +} from "@near-intents-agent-api/sdk"; +import { ORPCError } from "@orpc/server"; +import { Effect, Layer } from "effect"; +import { createPlugin } from "every-plugin"; +import { z } from "zod"; +import { contract } from "./contract"; +import { DatabaseLive } from "./db/layer"; +import { ContextSchema } from "./lib/context"; +import { + AgentApiClient, + type AgentApiClientShape, + type AgentApiServiceError, + agentApiClientLayer, +} from "./services/agent-api"; +import { + AgentStore, + type AgentStoreShape, + agentStoreLayer, + type OwnerIntentRow, +} from "./services/store"; +import { GrantVault, grantVaultLayer } from "./services/vault"; + +/** + * Agent Connect — the control plane giving every signed-in user an + * owner-controlled agent account on the NEAR Intents Agent API. + * + * Layering: `@near-intents-agent-api/sdk` is the only protocol dependency; + * this plugin adds only platform concerns — session ownership, the sealed + * grant-token vault, the pre-send intent ledger, and the oRPC surface + * (REST + MCP via the host's OpenAPI mounts). + * + * Owner intents follow the BFF split: prepare (this plugin, returns + * `intent.payload` + preview) → the browser signs with the connected wallet + * (better-near-auth's `signAgentIntent`) → submit (this plugin). The `naa_` + * partner key and `ngt_` grant tokens never reach a browser. + * + * Idempotency: prepare routes accept an optional client idempotency key — + * the same key with the same request returns the same intent (safe retry: + * a stored response replays, otherwise the exact stored request body is + * resent under the same key), and the same key with a different request + * type conflicts. Upstream enforces body equality (`409 idempotency_conflict`). + * + * Submit and status polling share one reconciliation path, so a success + * observed later applies the same local effects as an immediate one. + * + * Owner derivation reads the request context (`context.near`) — the primary + * linked NEAR account. Passkey-only owners are a tracked follow-up. + * + * The plugin is dormant until `NEAR_INTENTS_AGENT_API_KEY` is configured: + * routes exist but fail with a clear "not configured" error. + */ + +const PRE_SEND_TTL_MS = 15 * 60 * 1000; +const DEFAULT_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_GRANT_TTL_MS = 365 * 24 * 60 * 60 * 1000; +const LINK_FAILURE_MESSAGE = + "Agent intent was created but could not be recorded — retry with the same idempotency key to reuse it"; + +const NOT_CONFIGURED: AgentApiServiceError = new ORPCError("SERVICE_UNAVAILABLE", { + message: "Agent Connect is not configured — set NEAR_INTENTS_AGENT_API_KEY", + data: { message: "Agent Connect is not configured — set NEAR_INTENTS_AGENT_API_KEY" }, +}); + +const dormantClientLayer = Layer.succeed(AgentApiClient, { + generateIntent: () => Effect.fail(NOT_CONFIGURED), + submitIntent: () => Effect.fail(NOT_CONFIGURED), + getStatus: () => Effect.fail(NOT_CONFIGURED), + listAgents: () => Effect.fail(NOT_CONFIGURED), + getAgent: () => Effect.fail(NOT_CONFIGURED), + listGrants: () => Effect.fail(NOT_CONFIGURED), +}); + +const newId = (): string => crypto.randomUUID(); + +/** The pieces of the generated intent the ledger stores and the UI needs. */ +interface GeneratedIntent { + correlation_id: string; + agent_id?: string | null; + status: string; + expires_at: string; +} + +type LedgerError = ORPCError<"INTERNAL_SERVER_ERROR", unknown>; + +const CONFLICT_ERROR = (message: string) => new ORPCError("CONFLICT", { message }); + +export default createPlugin({ + variables: z.object({ + apiBaseUrl: z.string().default(DEFAULT_BASE_URL).describe("Hosted Agent API origin"), + apiTimeoutMs: z.number().min(1000).max(60_000).optional().describe("Agent API request timeout"), + }), + + secrets: z.object({ + NEAR_INTENTS_AGENT_API_KEY: z + .string() + .optional() + .describe( + "Partner API key (naa_…) from the partner dashboard; the plugin stays dormant without it", + ), + AGENT_CONNECT_ENCRYPTION_KEY: z + .string() + .min(32) + .optional() + .describe( + "Grant-token vault key (32+ bytes). Falls back to a key derived from NEAR_INTENTS_AGENT_API_KEY when absent — set a dedicated key in production.", + ), + AGENT_CONNECT_DATABASE_URL: z + .string() + .default("pglite:.bos/agent-connect/:memory:") + .describe("Database connection string. Use pglite: for local, postgres:// for production."), + }), + + context: ContextSchema, + + contract, + + initialize: (config) => + Effect.gen(function* () { + const apiKey = config.secrets.NEAR_INTENTS_AGENT_API_KEY; + + const vaultSecret = + config.secrets.AGENT_CONNECT_ENCRYPTION_KEY ?? + (apiKey + ? yield* Effect.logWarning( + "[agent-connect] AGENT_CONNECT_ENCRYPTION_KEY not set — deriving the grant vault key from NEAR_INTENTS_AGENT_API_KEY; set a dedicated key in production", + ).pipe(Effect.as(apiKey)) + : "agent-connect-dormant"); + + const clientLayer = apiKey + ? agentApiClientLayer({ + apiKey, + baseUrl: config.variables.apiBaseUrl, + timeoutMs: config.variables.apiTimeoutMs, + }) + : dormantClientLayer; + + return Layer.mergeAll( + clientLayer, + grantVaultLayer(vaultSecret), + agentStoreLayer.pipe( + Layer.provide(DatabaseLive(config.secrets.AGENT_CONNECT_DATABASE_URL)), + ), + ); + }), + + createRouter: (builder) => { + const requireUser = (userId: string | null | undefined): string => { + if (!userId) { + throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" }); + } + return userId; + }; + + const deriveOwner = ( + near: + | { + primaryAccountId: string | null; + linkedAccounts: Array<{ accountId: string; publicKey: string }>; + } + | null + | undefined, + ): { type: "near"; account_id: string; public_key: string } => { + const primary = near?.primaryAccountId + ? near.linkedAccounts.find((account) => account.accountId === near.primaryAccountId) + : undefined; + if (!primary) { + throw new ORPCError("BAD_REQUEST", { + message: "No owner wallet available — link a NEAR account first", + }); + } + return { type: "near", account_id: primary.accountId, public_key: primary.publicKey }; + }; + + /** + * Ledger link — fatal on failure: the caller retries with the same key + * and lands on the reuse path. + */ + const linkGenerated = ( + store: AgentStoreShape, + idempotencyKey: string, + generated: GenerateIntentResponse, + ): Effect.Effect => + store + .linkIntent({ + idempotencyKey, + correlationId: generated.correlation_id, + status: generated.status, + expiresAt: new Date(generated.expires_at), + agentId: generated.agent_id ?? null, + response: generated, + }) + .pipe( + Effect.catch(() => + Effect.fail(new ORPCError("INTERNAL_SERVER_ERROR", { message: LINK_FAILURE_MESSAGE })), + ), + Effect.as(generated), + ); + + /** + * The intent ledger, uniformly: reuse (stored response replays; otherwise + * the exact stored request body is resent under the same key) or fresh + * (record pending with the request → send → link). + */ + const prepareWithLedger = (args: { + store: AgentStoreShape; + client: AgentApiClientShape; + userId: string; + intentType: "agent_create" | "grant_issue" | "grant_revoke"; + idempotencyKeyInput: string | undefined; + buildFreshRequest: () => Effect.Effect< + { request: unknown; grantRowId?: string }, + AgentApiServiceError | LedgerError | Error + >; + }): Effect.Effect => + Effect.gen(function* () { + const { store, client, userId } = args; + const idempotencyKey = args.idempotencyKeyInput ?? createIdempotencyKey(); + + const existing = yield* store.findIntentByKey(userId, idempotencyKey); + if (existing) { + if (existing.intentType !== args.intentType) { + return yield* Effect.fail( + CONFLICT_ERROR("Idempotency key was already used for a different request"), + ); + } + if (existing.response != null) { + return existing.response as GenerateIntentResponse; + } + const regenerated = yield* client.generateIntent(existing.request as never, { + idempotencyKey, + }); + return yield* linkGenerated(store, idempotencyKey, regenerated); + } + + const fresh = yield* args.buildFreshRequest(); + yield* store.insertPendingIntent({ + id: newId(), + correlationId: `pending-${idempotencyKey}`, + idempotencyKey, + intentType: args.intentType, + agentId: null, + grantRowId: fresh.grantRowId ?? null, + userId, + request: fresh.request, + expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), + }); + + const generated = yield* client.generateIntent(fresh.request as never, { + idempotencyKey, + }); + return yield* linkGenerated(store, idempotencyKey, generated); + }); + + /** + * One reconciliation path for a settled intent — shared by submit and + * status polling, so a success observed later applies the same local + * effects as an immediate one. Ledger writes are best-effort. + */ + const reconcileIntent = ( + store: AgentStoreShape, + userId: string, + intentRow: OwnerIntentRow, + status: StatusResponse, + ): Effect.Effect => { + const update = store + .updateIntentStatus(intentRow.correlationId, status.status) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to update intent status:", error).pipe( + Effect.asVoid, + ), + ), + ); + + if (status.status !== "SUCCESS") { + return update; + } + + const settled = Effect.gen(function* () { + const details = status.details as { grant?: { grant_id?: string } | null } | undefined; + if (intentRow.intentType === "grant_issue" && intentRow.grantRowId) { + const issuedGrantId = details?.grant?.grant_id; + if (issuedGrantId) { + yield* store + .linkGrantId(intentRow.grantRowId, issuedGrantId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to link grant row:", error).pipe( + Effect.asVoid, + ), + ), + ); + } + } + if (intentRow.intentType === "grant_revoke" && intentRow.grantRowId) { + yield* store + .markGrantRevoked(intentRow.grantRowId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to mark grant revoked:", error).pipe( + Effect.asVoid, + ), + ), + ); + } + if (intentRow.intentType === "agent_create" && intentRow.agentId) { + yield* store + .activateAgent(userId, intentRow.agentId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to activate agent row:", error).pipe( + Effect.asVoid, + ), + ), + ); + } + }); + + return Effect.asVoid(Effect.flatMap(update, () => settled)); + }; + + return { + agentCreate: builder.agentCreate.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const owner = deriveOwner(context.near); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const generated = yield* prepareWithLedger({ + store, + client, + userId, + intentType: "agent_create", + idempotencyKeyInput: input.idempotencyKey, + buildFreshRequest: () => + Effect.succeed({ + request: { + type: "agent_create", + name: input.name, + external_user_id: userId, + owner, + policy: input.policy, + }, + }), + }); + + yield* store + .createAgentAccount({ + id: newId(), + userId, + agentId: generated.agent_id, + name: input.name, + ownerType: owner.type, + ownerAccountId: owner.account_id, + status: "PENDING", + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to persist agent state:", error), + ), + ); + + return generated; + }), + + agentList: builder.agentList.effect(function* ({ context }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + return yield* client.listAgents(userId); + }), + + agentGet: builder.agentGet.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const owned = yield* store.findAgentByUser(userId, input.agentId); + if (!owned) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Agent not found for this user" })); + } + return yield* client.getAgent(input.agentId); + }), + + prepareIntent: builder.prepareIntent.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + const vault = yield* GrantVault; + + if (input.type === "grant_issue" || input.type === "grant_revoke") { + const owned = yield* store.findAgentByUser(userId, input.agentId); + if (!owned) { + return yield* Effect.fail( + errors.NOT_FOUND({ message: "Agent not found for this user" }), + ); + } + } + + return yield* prepareWithLedger({ + store, + client, + userId, + intentType: input.type, + idempotencyKeyInput: input.idempotencyKey, + buildFreshRequest: () => { + if (input.type === "grant_issue") { + const requestedExpiry = input.expiresAt ? Date.parse(input.expiresAt) : Number.NaN; + const resolvedExpiryMs = Number.isNaN(requestedExpiry) + ? Date.now() + DEFAULT_GRANT_TTL_MS + : Math.min(requestedExpiry, Date.now() + MAX_GRANT_TTL_MS); + + return Effect.gen(function* () { + const { token, commitment } = createGrantCredential(); + const sealed = yield* vault.seal(token); + const grantRow = yield* store.insertGrantRow({ + id: newId(), + userId, + agentId: input.agentId, + label: input.label, + tokenCiphertext: sealed.ciphertext, + tokenIv: sealed.iv, + commitment, + expiresAt: new Date(resolvedExpiryMs), + }); + return { + request: { + type: "grant_issue", + agent_id: input.agentId, + label: input.label, + credential: commitment, + expires_at: new Date(resolvedExpiryMs).toISOString(), + }, + grantRowId: grantRow.id, + }; + }); + } + if (input.type === "grant_revoke") { + return Effect.succeed({ + request: { + type: "grant_revoke", + agent_id: input.agentId, + grant_id: input.grantId, + }, + }); + } + return Effect.fail( + new ORPCError("BAD_REQUEST", { message: "Unsupported intent type" }), + ); + }, + }); + }), + + intentSubmit: builder.intentSubmit.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const intentRow = yield* store.findIntentByUser(userId, input.correlationId); + if (!intentRow) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Unknown correlation id" })); + } + if (intentRow.intentType !== input.type) { + return yield* Effect.fail( + errors.BAD_REQUEST({ message: "Intent type does not match the issued correlation id" }), + ); + } + + const status = yield* client.submitIntent({ + type: input.type, + correlation_id: input.correlationId, + signed_data: input.signedData as never, + }); + + yield* reconcileIntent(store, userId, intentRow, status); + + return status; + }), + + intentStatus: builder.intentStatus.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const intentRow = yield* store.findIntentByUser(userId, input.correlationId); + if (!intentRow) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Unknown correlation id" })); + } + + const status = yield* client.getStatus(input.correlationId, input.waitMs); + + yield* reconcileIntent(store, userId, intentRow, status); + + return status; + }), + + grantList: builder.grantList.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const owned = yield* store.findAgentByUser(userId, input.agentId); + if (!owned) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Agent not found for this user" })); + } + const grants = yield* client.listGrants(input.agentId); + return { data: grants }; + }), + }; + }, +}); diff --git a/plugins/agent-connect/src/lib/context.ts b/plugins/agent-connect/src/lib/context.ts new file mode 100644 index 000000000..47f68a40f --- /dev/null +++ b/plugins/agent-connect/src/lib/context.ts @@ -0,0 +1,11 @@ +/** + * Request context schema — the auth context the host injects per request. + * Prefer upstream changes at https://github.com/nearbuilders/everything-dev + */ + +import { z } from "zod"; +import type { AuthPluginContext as AuthContext } from "./auth-types.gen"; + +export const ContextSchema = z.custom(); + +export type Context = AuthContext; diff --git a/plugins/agent-connect/src/services/agent-api.ts b/plugins/agent-connect/src/services/agent-api.ts new file mode 100644 index 000000000..28828db4a --- /dev/null +++ b/plugins/agent-connect/src/services/agent-api.ts @@ -0,0 +1,145 @@ +import type { + AgentApiOptions, + AgentPage, + AgentView, + GenerateIntentRequest, + GenerateIntentResponse, + GrantView, + IdempotentResult, + StatusResponse, + SubmitIntentRequest, +} from "@near-intents-agent-api/sdk"; +import { + AgentApi, + AgentApiError, + AgentApiRequestError, + DEFAULT_BASE_URL, +} from "@near-intents-agent-api/sdk"; +import { ORPCError } from "@orpc/server"; +import { Context, Effect, Layer } from "effect"; + +/** + * Typed wrapper over the hosted Agent API client (`@near-intents-agent-api/sdk`). + * + * The SDK is the only protocol dependency — no endpoint shapes, amount math, + * or retry semantics live here. This layer maps SDK failures onto the + * contract's error surface and nothing else. + */ + +export type AgentApiServiceError = ORPCError< + | "BAD_REQUEST" + | "NOT_FOUND" + | "CONFLICT" + | "TOO_MANY_REQUESTS" + | "BAD_GATEWAY" + | "SERVICE_UNAVAILABLE" + | "INTERNAL_SERVER_ERROR", + unknown +>; + +const STATUS_MAP: Record< + number, + | "BAD_REQUEST" + | "NOT_FOUND" + | "CONFLICT" + | "TOO_MANY_REQUESTS" + | "BAD_GATEWAY" + | "SERVICE_UNAVAILABLE" +> = { + 400: "BAD_REQUEST", + 401: "BAD_REQUEST", + 403: "BAD_REQUEST", + 404: "NOT_FOUND", + 409: "CONFLICT", + 410: "NOT_FOUND", + 429: "TOO_MANY_REQUESTS", + 502: "BAD_GATEWAY", + 503: "SERVICE_UNAVAILABLE", +}; + +function mapDownstreamError(error: unknown): AgentApiServiceError { + if (error instanceof AgentApiRequestError) { + return new ORPCError("SERVICE_UNAVAILABLE", { + message: "Agent API is unreachable — retry with the same request", + data: { retryable: true }, + }); + } + if (error instanceof AgentApiError) { + const code = STATUS_MAP[error.status] ?? "INTERNAL_SERVER_ERROR"; + return new ORPCError(code, { + message: error.detail || error.title, + data: { + code: error.code, + retryable: error.retryable, + ...(error.availableAt ? { availableAt: error.availableAt } : {}), + }, + }); + } + return new ORPCError("INTERNAL_SERVER_ERROR", { + message: error instanceof Error ? error.message : "Agent API call failed", + }); +} + +export interface AgentApiClientShape { + generateIntent( + request: GenerateIntentRequest, + options?: { idempotencyKey?: string }, + ): Effect.Effect, AgentApiServiceError>; + submitIntent(request: SubmitIntentRequest): Effect.Effect; + getStatus( + correlationId: string, + waitMs?: number, + ): Effect.Effect; + listAgents(externalUserId: string): Effect.Effect; + getAgent(agentId: string): Effect.Effect; + listGrants(agentId: string): Effect.Effect; +} + +export class AgentApiClient extends Context.Service()( + "agent-connect/AgentApiClient", +) {} + +export const agentApiClientLayer = (options: { + apiKey: string; + baseUrl?: string; + timeoutMs?: number; +}): Layer.Layer => { + const api = new AgentApi({ + apiKey: options.apiKey, + baseUrl: options.baseUrl ?? DEFAULT_BASE_URL, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + } satisfies AgentApiOptions); + + return Layer.succeed(AgentApiClient, { + generateIntent: (request, opts) => + Effect.tryPromise({ + try: () => api.generateIntent(request as never, opts as never), + catch: mapDownstreamError, + }), + submitIntent: (request) => + Effect.tryPromise({ + try: () => api.submitIntent(request), + catch: mapDownstreamError, + }), + getStatus: (correlationId, waitMs) => + Effect.tryPromise({ + try: () => api.getStatus(correlationId, waitMs !== undefined ? { waitMs } : undefined), + catch: mapDownstreamError, + }), + listAgents: (externalUserId) => + Effect.tryPromise({ + try: () => api.listAgents({ external_user_id: externalUserId }), + catch: mapDownstreamError, + }), + getAgent: (agentId) => + Effect.tryPromise({ + try: () => api.getAgent(agentId), + catch: mapDownstreamError, + }), + listGrants: (agentId) => + Effect.tryPromise({ + try: () => api.listGrants(agentId), + catch: mapDownstreamError, + }), + }); +}; diff --git a/plugins/agent-connect/src/services/store.ts b/plugins/agent-connect/src/services/store.ts new file mode 100644 index 000000000..7ac6ba815 --- /dev/null +++ b/plugins/agent-connect/src/services/store.ts @@ -0,0 +1,284 @@ +import { ORPCError } from "@orpc/server"; +import { and, eq } from "drizzle-orm"; +import { Context, Effect, Layer } from "effect"; +import { DatabaseTag } from "../db/layer"; +import { agentAccount, agentGrant, ownerIntent } from "../db/schema"; + +/** + * Control-plane store over the plugin's own schema. Every query that reads a + * user-owned row scopes by `userId` — the ownership invariant lives here, in + * the query, not in the caller. + */ + +export type StoreError = ORPCError< + "NOT_FOUND" | "CONFLICT" | "INTERNAL_SERVER_ERROR", + { message: string } +>; + +export interface AgentAccountRow { + id: string; + userId: string; + agentId: string; + name: string; + ownerType: string; + ownerAccountId: string | null; + status: string; + createdAt: Date; +} + +export interface OwnerIntentRow { + id: string; + correlationId: string; + idempotencyKey: string; + intentType: string; + agentId: string | null; + grantRowId: string | null; + userId: string; + status: string; + expiresAt: Date; + request: unknown; + response: unknown; + createdAt: Date; +} + +export interface AgentGrantRow { + id: string; + userId: string; + agentId: string; + grantId: string | null; + correlationId: string | null; + label: string; + tokenCiphertext: string; + tokenIv: string; + commitment: string; + expiresAt: Date; + revokedAt: Date | null; + createdAt: Date; +} + +const storeError = (message: string, cause: unknown): StoreError => + new ORPCError("INTERNAL_SERVER_ERROR", { message, data: { message } }); + +export class AgentStore extends Context.Service()( + "agent-connect/AgentStore", +) {} + +export interface AgentStoreShape { + createAgentAccount(input: { + id: string; + userId: string; + agentId: string; + name: string; + ownerType: string; + ownerAccountId: string | null; + status: string; + }): Effect.Effect; + findAgentByUser( + userId: string, + agentId: string, + ): Effect.Effect; + activateAgent(userId: string, agentId: string): Effect.Effect; + insertPendingIntent(input: { + id: string; + correlationId: string; + idempotencyKey: string; + intentType: string; + agentId: string | null; + grantRowId: string | null; + userId: string; + request: unknown; + expiresAt: Date; + }): Effect.Effect; + linkIntent(input: { + idempotencyKey: string; + correlationId: string; + status: string; + expiresAt: Date; + agentId?: string | null; + response?: unknown; + }): Effect.Effect; + findIntentByUser( + userId: string, + correlationId: string, + ): Effect.Effect; + findIntentByKey( + userId: string, + idempotencyKey: string, + ): Effect.Effect; + updateIntentStatus(correlationId: string, status: string): Effect.Effect; + insertGrantRow(input: { + id: string; + userId: string; + agentId: string; + label: string; + tokenCiphertext: string; + tokenIv: string; + commitment: string; + expiresAt: Date; + }): Effect.Effect; + linkGrantId(rowId: string, grantId: string): Effect.Effect; + markGrantRevoked(rowId: string): Effect.Effect; + findGrantById(rowId: string): Effect.Effect; + findGrantByUser( + userId: string, + agentId: string, + grantId: string, + ): Effect.Effect; +} + +export const agentStoreLayer = Layer.effect( + AgentStore, + Effect.gen(function* () { + const db = yield* DatabaseTag; + + const store: AgentStoreShape = { + createAgentAccount: (input) => + Effect.tryPromise({ + try: async () => { + const [row] = await db.insert(agentAccount).values(input).returning(); + return row!; + }, + catch: (cause) => storeError("Failed to persist agent account", cause), + }), + findAgentByUser: (userId, agentId) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(agentAccount) + .where(and(eq(agentAccount.agentId, agentId), eq(agentAccount.userId, userId))) + .limit(1) + .then((rows) => rows[0] ?? null)) as AgentAccountRow | null, + catch: (cause) => storeError("Failed to load agent account", cause), + }), + activateAgent: (userId, agentId) => + Effect.tryPromise({ + try: async () => { + await db + .update(agentAccount) + .set({ status: "ACTIVE" }) + .where(and(eq(agentAccount.agentId, agentId), eq(agentAccount.userId, userId))); + }, + catch: (cause) => storeError("Failed to activate agent account", cause), + }), + insertPendingIntent: (input) => + Effect.tryPromise({ + try: async () => { + const [row] = await db + .insert(ownerIntent) + .values({ ...input, status: "PENDING_SIGNATURE" }) + .returning(); + return row!; + }, + catch: (cause) => storeError("Failed to record pending intent", cause), + }), + linkIntent: ({ idempotencyKey, correlationId, status, expiresAt, agentId, response }) => + Effect.tryPromise({ + try: async () => { + await db + .update(ownerIntent) + .set({ + correlationId, + status, + expiresAt, + ...(agentId !== undefined ? { agentId } : {}), + ...(response !== undefined ? { response } : {}), + }) + .where(eq(ownerIntent.idempotencyKey, idempotencyKey)); + }, + catch: (cause) => storeError("Failed to link the generated intent to the ledger", cause), + }), + findIntentByUser: (userId, correlationId) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(ownerIntent) + .where( + and(eq(ownerIntent.correlationId, correlationId), eq(ownerIntent.userId, userId)), + ) + .limit(1) + .then((rows) => rows[0] ?? null)) as OwnerIntentRow | null, + catch: (cause) => storeError("Failed to load intent", cause), + }), + findIntentByKey: (userId, idempotencyKey) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(ownerIntent) + .where( + and(eq(ownerIntent.idempotencyKey, idempotencyKey), eq(ownerIntent.userId, userId)), + ) + .limit(1) + .then((rows) => rows[0] ?? null)) as OwnerIntentRow | null, + catch: (cause) => storeError("Failed to load intent by idempotency key", cause), + }), + updateIntentStatus: (correlationId, status) => + Effect.tryPromise({ + try: async () => { + await db + .update(ownerIntent) + .set({ status }) + .where(eq(ownerIntent.correlationId, correlationId)); + }, + catch: (cause) => storeError("Failed to update intent status", cause), + }), + insertGrantRow: (input) => + Effect.tryPromise({ + try: async () => { + const [row] = await db.insert(agentGrant).values(input).returning(); + return row!; + }, + catch: (cause) => storeError("Failed to persist grant", cause), + }), + linkGrantId: (rowId, grantId) => + Effect.tryPromise({ + try: async () => { + await db.update(agentGrant).set({ grantId }).where(eq(agentGrant.id, rowId)); + }, + catch: (cause) => storeError("Failed to link grant", cause), + }), + markGrantRevoked: (rowId) => + Effect.tryPromise({ + try: async () => { + await db + .update(agentGrant) + .set({ revokedAt: new Date() }) + .where(eq(agentGrant.id, rowId)); + }, + catch: (cause) => storeError("Failed to mark grant revoked", cause), + }), + findGrantById: (rowId) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(agentGrant) + .where(eq(agentGrant.id, rowId)) + .limit(1) + .then((rows) => rows[0] ?? null)) as AgentGrantRow | null, + catch: (cause) => storeError("Failed to load grant", cause), + }), + findGrantByUser: (userId, agentId, grantId) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(agentGrant) + .where( + and( + eq(agentGrant.agentId, agentId), + eq(agentGrant.grantId, grantId), + eq(agentGrant.userId, userId), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null)) as AgentGrantRow | null, + catch: (cause) => storeError("Failed to load grant", cause), + }), + }; + + return store; + }), +); diff --git a/plugins/agent-connect/src/services/vault.ts b/plugins/agent-connect/src/services/vault.ts new file mode 100644 index 000000000..1587db636 --- /dev/null +++ b/plugins/agent-connect/src/services/vault.ts @@ -0,0 +1,90 @@ +import { Context, Effect, Layer } from "effect"; + +/** + * Grant-token vault — `ngt_` tokens are encrypted at rest (HKDF-SHA256 → + * AES-256-GCM, plugin-specific salt) and never leave the server. The + * commitment (sha256 of the token) is the only thing that reaches the + * owner-signed intent. + */ + +const VAULT_SALT = "agent-connect-grant-vault"; + +export interface SealedToken { + ciphertext: string; + iv: string; +} + +export class GrantVault extends Context.Service()( + "agent-connect/GrantVault", +) {} + +export interface GrantVaultShape { + seal(token: string): Effect.Effect; + open(sealed: SealedToken): Effect.Effect; +} + +async function deriveAesKey(secret: string): Promise { + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { + name: "HKDF", + }, + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new TextEncoder().encode(VAULT_SALT), + info: new Uint8Array(0), + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +function bytesToBase64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} + +function base64ToBytes(base64: string): Uint8Array { + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); +} + +export const grantVaultLayer = (secret: string): Layer.Layer => { + const keyPromise = deriveAesKey(secret); + + const vault: GrantVaultShape = { + seal: (token) => + Effect.tryPromise({ + try: async (): Promise => { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + await keyPromise, + new TextEncoder().encode(token), + ); + return { ciphertext: bytesToBase64(new Uint8Array(encrypted)), iv: bytesToBase64(iv) }; + }, + catch: (cause) => new Error("Failed to seal grant token", { cause }), + }), + open: ({ ciphertext, iv }) => + Effect.tryPromise({ + try: async (): Promise => { + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(iv) as Uint8Array }, + await keyPromise, + base64ToBytes(ciphertext) as Uint8Array, + ); + return new TextDecoder().decode(decrypted); + }, + catch: (cause) => new Error("Failed to open grant token", { cause }), + }), + }; + + return Layer.succeed(GrantVault, vault); +}; diff --git a/plugins/agent-connect/tests/integration/agent-connect.test.ts b/plugins/agent-connect/tests/integration/agent-connect.test.ts new file mode 100644 index 000000000..bf6e3ec24 --- /dev/null +++ b/plugins/agent-connect/tests/integration/agent-connect.test.ts @@ -0,0 +1,381 @@ +import type { ContractRouterClient } from "@orpc/contract"; +import { Context, Effect } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import type { contract } from "@/contract"; +import { AgentStore } from "@/services/store"; + +const MOCK_ACCOUNT_ID = "test.near"; +const MOCK_PUBLIC_KEY = "ed25519:abcdefghijklmnopqrstuvwxyz0123456789ABCD"; + +const state = vi.hoisted(() => ({ + /** Status returned by the mocked submitIntent call. */ + submitStatus: "SUCCESS", + /** Status returned by the mocked getStatus call (polling). */ + pollStatus: "SUCCESS", + /** How many times the mock's generateIntent was invoked. */ + generateCalls: 0, +})); + +const TEST_POLICY = { + frozen: false, + actions: ["swap", "transfer", "withdraw"], + confidential: false, + owner_approval: false, + assets: "any", + limits: {}, + max_actions_per_hour: null, + destinations: { mode: "only", list: [] }, + budget: { daily_usd: "10", weekly_usd: null, monthly_usd: null }, + timelock_ms: 0, +}; + +const SIGNED_DATA = { + standard: "nep413", + payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, + public_key: MOCK_PUBLIC_KEY, + signature: "sig", +}; + +/** + * In-memory stand-in for the hosted Agent API — the SDK module is mocked so + * the plugin's control-plane invariants are tested against the same + * generate/submit/status surface without network. `state` drives the + * non-terminal/later-success paths. + */ +function makeAgentApiStub() { + const intents: Array<{ type: string; agent_id?: string }> = []; + const intentTypeByCorrelationId = new Map(); + return class AgentApiMock { + constructor(options?: { apiKey?: string }) { + if (!options?.apiKey) throw new Error("apiKey required"); + } + generateIntent( + request: { type: string; agent_id?: string }, + options?: { idempotencyKey?: string }, + ) { + state.generateCalls += 1; + intents.push(request); + const correlationId = `intent_${intents.length}`; + intentTypeByCorrelationId.set(correlationId, request.type); + const agentId = "agent_id" in request && request.agent_id ? request.agent_id : "ag_test1"; + return Promise.resolve({ + correlation_id: correlationId, + type: request.type, + agent_id: agentId, + status: "PENDING_SIGNATURE", + expires_at: new Date(Date.now() + 600_000).toISOString(), + signer: { type: "near", account_id: MOCK_ACCOUNT_ID, public_key: MOCK_PUBLIC_KEY }, + idempotencyKey: options?.idempotencyKey, + intent: { + standard: "nep413", + payload: { + message: "owner intent", + recipient: "api.agentsonintents.com", + nonce: "dGVzdA==", + }, + }, + preview: { summary: "Sign to continue" }, + }); + } + submitIntent(request: { type: string }) { + const settled = state.submitStatus === "SUCCESS"; + const details = + request.type === "grant_issue" && settled ? { grant: { grant_id: "64hexgrantid" } } : {}; + return Promise.resolve({ + correlation_id: "intent_1", + agent_id: "ag_test1", + status: state.submitStatus, + failure_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + dispatch_committed_at: null, + grant: null, + type: request.type, + details, + }); + } + getStatus(correlationId: string) { + const settled = state.pollStatus === "SUCCESS"; + const intentType = intentTypeByCorrelationId.get(correlationId) ?? "agent_create"; + const details = !settled + ? {} + : intentType === "grant_issue" + ? { grant: { grant_id: "64hexgrantid" } } + : { agent_id: "ag_test1", revision: 1, policy_hash: "hash", transaction_hash: null }; + return Promise.resolve({ + correlation_id: correlationId, + agent_id: "ag_test1", + status: state.pollStatus, + failure_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + dispatch_committed_at: null, + grant: null, + type: intentType, + details, + }); + } + listAgents() { + return Promise.resolve({ data: [{ id: "ag_test1" }], next_cursor: null }); + } + getAgent(agentId: string) { + return Promise.resolve({ id: agentId }); + } + listGrants() { + return Promise.resolve([]); + } + }; +} + +vi.mock("@near-intents-agent-api/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AgentApi: makeAgentApiStub(), + }; +}); + +const { getPluginClient, getPluginEffectContext, runtime } = await import("../setup"); +const { createPluginRuntime } = await import("every-plugin"); +const Plugin = (await import("@/index")).default; + +describe("agent connect routes", () => { + it("requires a session", async () => { + const client = await getPluginClient(); + await expect(client.agentCreate({ name: "my agent", policy: TEST_POLICY })).rejects.toThrow( + /Authentication required/, + ); + }); + + it("returns the generated intent without leaking the api key", async () => { + const client = await getPluginClient({ userId: "user1" }); + const generated = await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + expect(generated.correlation_id).toMatch(/^intent_/); + expect(generated.intent.standard).toBe("nep413"); + expect(JSON.stringify(generated)).not.toContain("naa_"); + }); + + it("derives the owner from the primary linked NEAR account or refuses", async () => { + const client = await getPluginClient({ + userId: "user1", + near: { primaryAccountId: null, linkedAccounts: [], hasNearAccount: false }, + }); + await expect(client.agentCreate({ name: "no wallet", policy: TEST_POLICY })).rejects.toThrow( + /No owner wallet available/, + ); + }); + + it("refuses submits for correlation ids not issued to the user", async () => { + const client = await getPluginClient({ userId: "user1" }); + await expect( + client.intentSubmit({ + type: "agent_create", + correlationId: "intent_not_issued", + signedData: SIGNED_DATA, + }), + ).rejects.toThrow(/Unknown correlation id/); + }); + + it("rejects a submit whose type does not match the issued intent", async () => { + const client = await getPluginClient({ userId: "user1" }); + const created = await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + await expect( + client.intentSubmit({ + type: "grant_revoke", + correlationId: created.correlation_id, + signedData: SIGNED_DATA, + }), + ).rejects.toThrow(/does not match the issued correlation id/); + }); + + it("keeps grant tokens server-side when issuing a grant", async () => { + const client = await getPluginClient({ userId: "user1" }); + await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + const generated = await client.prepareIntent({ + type: "grant_issue", + agentId: "ag_test1", + label: "assistant-session-42", + }); + expect(generated.type).toBe("grant_issue"); + expect(JSON.stringify(generated)).not.toContain("ngt_"); + }); + + it("refuses grant operations on agents the user does not own", async () => { + const client = await getPluginClient({ userId: "someone-else" }); + await expect( + client.prepareIntent({ type: "grant_issue", agentId: "ag_test1", label: "hijack" }), + ).rejects.toThrow(/Agent not found for this user/); + await expect(client.grantList({ agentId: "ag_test1" })).rejects.toThrow( + /Agent not found for this user/, + ); + }); + + it("completes the create → submit → owned-agent flow", async () => { + const client = await getPluginClient({ userId: "user1" }); + const created = await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + const status = await client.intentSubmit({ + type: "agent_create", + correlationId: created.correlation_id, + signedData: SIGNED_DATA, + }); + expect(status.status).toBe("SUCCESS"); + + const view = await client.agentGet({ agentId: "ag_test1" }); + expect(view.id).toBe("ag_test1"); + }); + + it("reuses the prepared intent for the same idempotency key", async () => { + const client = await getPluginClient({ userId: "user1" }); + const first = await client.agentCreate({ + name: "my agent", + policy: TEST_POLICY, + idempotencyKey: "retry-key-1234", + }); + const callsAfterFirst = state.generateCalls; + + const second = await client.agentCreate({ + name: "my agent", + policy: TEST_POLICY, + idempotencyKey: "retry-key-1234", + }); + + expect(second.correlation_id).toBe(first.correlation_id); + expect(state.generateCalls).toBe(callsAfterFirst); + }); + + it("conflicts when the same idempotency key is reused for a different request type", async () => { + const client = await getPluginClient({ userId: "user1" }); + await client.agentCreate({ + name: "my agent", + policy: TEST_POLICY, + idempotencyKey: "retry-key-1234", + }); + + await expect( + client.prepareIntent({ + type: "grant_issue", + agentId: "ag_test1", + label: "l", + idempotencyKey: "retry-key-1234", + }), + ).rejects.toThrow(/different request/); + }); + + it("reconciles local state when polling reveals a later success", async () => { + state.submitStatus = "PROCESSING"; + state.pollStatus = "SUCCESS"; + try { + const client = await getPluginClient({ userId: "user1" }); + await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + const grantIntent = await client.prepareIntent({ + type: "grant_issue", + agentId: "ag_test1", + label: "assistant-session-42", + }); + await client.intentSubmit({ + type: "grant_issue", + correlationId: grantIntent.correlation_id, + signedData: SIGNED_DATA, + }); + + const store = Context.get(getPluginEffectContext(), AgentStore); + const intentRow = await Effect.runPromise( + store.findIntentByUser("user1", grantIntent.correlation_id), + ); + const grantRowBefore = await Effect.runPromise( + intentRow?.grantRowId ? store.findGrantById(intentRow.grantRowId) : Effect.succeed(null), + ); + expect(grantRowBefore?.grantId).toBeNull(); + + await client.intentStatus({ correlationId: grantIntent.correlation_id }); + + const grantRowAfter = await Effect.runPromise( + intentRow?.grantRowId ? store.findGrantById(intentRow.grantRowId) : Effect.succeed(null), + ); + expect(grantRowAfter?.grantId).toBe("64hexgrantid"); + } finally { + state.submitStatus = "SUCCESS"; + state.pollStatus = "SUCCESS"; + } + }); + + it("activates the agent row when polling reveals a later success", async () => { + state.submitStatus = "PROCESSING"; + state.pollStatus = "SUCCESS"; + try { + const client = await getPluginClient({ userId: "user1" }); + const created = await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + await client.intentSubmit({ + type: "agent_create", + correlationId: created.correlation_id, + signedData: SIGNED_DATA, + }); + + const store = Context.get(getPluginEffectContext(), AgentStore); + const agentRow = await Effect.runPromise(store.findAgentByUser("user1", "ag_test1")); + expect(agentRow?.status).toBe("ACTIVE"); + } finally { + state.submitStatus = "SUCCESS"; + state.pollStatus = "SUCCESS"; + } + }); +}); + +describe("dormant plugin", () => { + it("fails with a not-configured error when no api key is set", async () => { + const { createServer } = await import("node:http"); + const dormantRuntime = createPluginRuntime({ + registry: { + "@everything-dev/agent-connect-plugin": { module: Plugin, description: "dormant" }, + }, + secrets: {}, + }); + const { router, initialized } = await dormantRuntime.usePlugin( + "@everything-dev/agent-connect-plugin", + { + variables: {}, + secrets: { AGENT_CONNECT_DATABASE_URL: "pglite:.bos/agent-connect-test/:memory:" }, + } as never, + ); + + const { RPCHandler } = await import("@orpc/server/node"); + const { createORPCClient } = await import("@orpc/client"); + const { RPCLink } = await import("@orpc/client/fetch"); + + const rpcHandler = new RPCHandler(router); + const server = createServer(async (req, res) => { + const url = new URL(req.url!, "http://localhost:0"); + if (url.pathname.startsWith("/rpc")) { + const result = await rpcHandler.handle(req, res, { + prefix: "/rpc", + context: { userId: "user1", "effect/context": initialized.effectContext }, + }); + if (result.matched) return; + } + res.statusCode = 404; + res.end("Route not found"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + const link = new RPCLink({ + origin: `http://127.0.0.1:${port}`, + url: "/rpc", + fetch: globalThis.fetch, + }); + const client: ContractRouterClient = createORPCClient(link); + + await expect(client.agentList()).rejects.toThrow(/not configured/); + + await new Promise((resolve) => server.close(() => resolve())); + await dormantRuntime.shutdown(); + await runtime.shutdown(); + }); +}); diff --git a/plugins/agent-connect/tests/integration/drift-gate.test.ts b/plugins/agent-connect/tests/integration/drift-gate.test.ts new file mode 100644 index 000000000..089e2925b --- /dev/null +++ b/plugins/agent-connect/tests/integration/drift-gate.test.ts @@ -0,0 +1,37 @@ +import { DEFAULT_BASE_URL } from "@near-intents-agent-api/sdk"; +import { describe, expect, it } from "vitest"; + +/** + * Drift gate — opt-in live check that the hosted Agent API still exposes the + * endpoints this plugin proxies. The SDK is generated from the API's + * OpenAPI spec, so drift between the SDK and the API is upstream's to fix; + * this test catches the case where the *deployment* the plugin points at + * (`AGENT_CONNECT_LIVE_BASE_URL`, default: the SDK's default) no longer + * matches the surface the plugin assumes. Run with: + * + * AGENT_CONNECT_LIVE_CHECK=1 pnpm test + */ +describe.skipIf(!process.env.AGENT_CONNECT_LIVE_CHECK)("live drift gate", () => { + const baseUrl = process.env.AGENT_CONNECT_LIVE_BASE_URL ?? DEFAULT_BASE_URL; + + it("exposes the paths the plugin proxies", async () => { + const response = await fetch(new URL("/openapi.json", baseUrl), { + headers: { accept: "application/json" }, + }); + expect(response.ok).toBe(true); + + const spec = (await response.json()) as { paths?: Record }; + const paths = Object.keys(spec.paths ?? {}); + + for (const expected of [ + "/v1/agents", + "/v1/agents/{agent_id}", + "/v1/generate-intent", + "/v1/submit-intent", + "/v1/status", + "/v1/agents/{agent_id}/grants", + ]) { + expect(paths, `missing ${expected}`).toContain(expected); + } + }); +}); diff --git a/plugins/agent-connect/tests/setup.ts b/plugins/agent-connect/tests/setup.ts new file mode 100644 index 000000000..3ce2a8385 --- /dev/null +++ b/plugins/agent-connect/tests/setup.ts @@ -0,0 +1,148 @@ +import { createServer } from "node:http"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import type { ContractRouterClient } from "@orpc/contract"; +import { RPCHandler } from "@orpc/server/node"; +import type { Context } from "effect"; + +import { createPluginRuntime } from "every-plugin"; +import type { contract } from "@/contract"; +import Plugin from "@/index"; +import packageJson from "../package.json" with { type: "json" }; +import pluginDevConfig from "../plugin.dev"; + +const TEST_PLUGIN_ID = packageJson.name; +const TEST_CONFIG = pluginDevConfig.config; + +const TEST_REGISTRY = { + [TEST_PLUGIN_ID]: { + module: Plugin, + description: "Integration test runtime", + }, +} as const; + +export const runtime = createPluginRuntime({ + registry: TEST_REGISTRY, + secrets: {}, +}); + +let server: ReturnType | null = null; +let baseUrl = ""; +let port = 0; +let pluginEffectContext: Context.Context | null = null; + +/** + * The plugin's initialized Effect context — services (AgentStore, …) can be + * read from it in tests via `Context.get`. + */ +export function getPluginEffectContext(): Context.Context { + if (!pluginEffectContext) { + throw new Error("getPluginClient() must run before getPluginEffectContext()"); + } + return pluginEffectContext; +} + +export async function getPluginClient(context?: { + userId?: string; + sessionId?: string; + near?: unknown; +}) { + if (!server) { + const { router, initialized } = await runtime.usePlugin(TEST_PLUGIN_ID, TEST_CONFIG); + const rpcHandler = new RPCHandler(router); + const effectContext = initialized.effectContext; + pluginEffectContext = effectContext; + + // Find an available port + const testPort = 3000 + Math.floor(Math.random() * 1000); + port = testPort; + baseUrl = `http://localhost:${port}`; + + server = createServer(async (req, res) => { + const url = new URL(req.url!, baseUrl); + + if (url.pathname.startsWith("/rpc")) { + // Initialize empty context for each request to prevent closure capture + let requestContext = {}; + + // Allow overriding context via headers for flexibility + if (req.headers["x-test-user"]) { + requestContext = { + ...requestContext, + userId: req.headers["x-test-user"] as string, + }; + } + if (req.headers["x-test-session"]) { + requestContext = { + ...requestContext, + sessionId: req.headers["x-test-session"] as string, + }; + } + if (req.headers["x-test-near"]) { + requestContext = { + ...requestContext, + near: JSON.parse(req.headers["x-test-near"] as string), + }; + } else { + requestContext = { + ...requestContext, + near: { + primaryAccountId: "test.near", + linkedAccounts: [ + { + accountId: "test.near", + network: "mainnet", + publicKey: "ed25519:abcdefghijklmnopqrstuvwxyz0123456789ABCD", + isPrimary: true, + }, + ], + hasNearAccount: true, + }, + }; + } + + const result = await rpcHandler.handle(req, res, { + prefix: "/rpc", + context: { ...requestContext, "effect/context": effectContext }, + }); + if (result.matched) return; + } + + res.statusCode = 404; + res.end("Route not found"); + }); + + await new Promise((resolve, reject) => { + server?.listen(port, "127.0.0.1", () => resolve()); + server?.on("error", reject); + }); + } + + const link = new RPCLink({ + origin: baseUrl, + url: "/rpc", + fetch: globalThis.fetch, + headers: context + ? { + ...(context.userId && { "x-test-user": context.userId }), + ...(context.sessionId && { "x-test-session": context.sessionId }), + ...(context.near !== undefined && { + "x-test-near": JSON.stringify(context.near), + }), + } + : {}, + }); + + const client: ContractRouterClient = createORPCClient(link); + return client; +} + +export async function teardown() { + if (server) { + await new Promise((resolve) => { + server?.close(() => resolve()); + }); + server = null; + } + await runtime.shutdown(); +} diff --git a/plugins/agent-connect/tests/types.d.ts b/plugins/agent-connect/tests/types.d.ts new file mode 100644 index 000000000..ce96ca4a1 --- /dev/null +++ b/plugins/agent-connect/tests/types.d.ts @@ -0,0 +1,8 @@ +import Plugin from "@/index"; +import packageJson from "../package.json" with { type: "json" }; + +declare module "every-plugin" { + interface RegisteredPlugins { + [packageJson.name]: typeof Plugin; + } +} diff --git a/plugins/agent-connect/tests/unit/contract-types.test.ts b/plugins/agent-connect/tests/unit/contract-types.test.ts new file mode 100644 index 000000000..fc6a9c4c0 --- /dev/null +++ b/plugins/agent-connect/tests/unit/contract-types.test.ts @@ -0,0 +1,24 @@ +import type { GenerateIntentResponse, StatusResponse } from "@near-intents-agent-api/sdk"; +import type { ContractRouterClient } from "@orpc/contract"; +import { describe, expectTypeOf, it } from "vitest"; +import type { contract } from "@/contract"; + +/** + * Type-level drift gate: the generated oRPC client's outputs must be the + * SDK's own types, so the wallet-signing helper (`signAgentIntent`) and the + * upcoming UI consume route responses without casts. + */ +describe("contract type fidelity", () => { + type Client = ContractRouterClient; + + it("agentCreate/prepareIntent return the SDK's GenerateIntentResponse", () => { + type Prepared = Awaited>; + expectTypeOf().toExtend(); + expectTypeOf>>().toExtend(); + }); + + it("intentSubmit/intentStatus return the SDK's StatusResponse", () => { + expectTypeOf>>().toExtend(); + expectTypeOf>>().toExtend(); + }); +}); diff --git a/plugins/agent-connect/tests/unit/vault.test.ts b/plugins/agent-connect/tests/unit/vault.test.ts new file mode 100644 index 000000000..2a6e015e5 --- /dev/null +++ b/plugins/agent-connect/tests/unit/vault.test.ts @@ -0,0 +1,40 @@ +import { createHash } from "node:crypto"; +import { createGrantCredential, grantCommitment } from "@near-intents-agent-api/sdk"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { GrantVault, grantVaultLayer } from "@/services/vault"; + +const withVault = (secret: string) => + Effect.gen(function* () { + return yield* GrantVault; + }).pipe(Effect.provide([grantVaultLayer(secret)])); + +describe("grant token vault", () => { + it("issues ngt_ tokens whose commitment is the sha256 of the token", () => { + const { token, commitment } = createGrantCredential(); + expect(token).toMatch(/^ngt_[A-Za-z0-9_-]{43}$/); + expect(commitment).toBe(createHash("sha256").update(token).digest("hex")); + expect(commitment).toBe(grantCommitment(token)); + }); + + it("seals tokens so neither ciphertext nor iv contains the token", async () => { + const vault = await Effect.runPromise(withVault("test-vault-secret-32-bytes-long!!")); + const { token } = createGrantCredential(); + + const sealed = await Effect.runPromise(vault.seal(token)); + expect(sealed.ciphertext).not.toContain(token); + expect(sealed.iv).not.toContain(token); + }); + + it("opens sealed tokens only under the same vault secret", async () => { + const vault = await Effect.runPromise(withVault("test-vault-secret-32-bytes-long!!")); + const { token } = createGrantCredential(); + const sealed = await Effect.runPromise(vault.seal(token)); + + const opened = await Effect.runPromise(vault.open(sealed)); + expect(opened).toBe(token); + + const otherVault = await Effect.runPromise(withVault("a-completely-different-secret-value")); + await expect(Effect.runPromise(otherVault.open(sealed))).rejects.toThrow(); + }); +}); diff --git a/plugins/agent-connect/tsconfig.json b/plugins/agent-connect/tsconfig.json new file mode 100644 index 000000000..a29780adc --- /dev/null +++ b/plugins/agent-connect/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "types": ["node"], + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "customConditions": ["development"], + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "declaration": false, + "declarationMap": false, + "plugins": [ + { + "name": "@effect/language-service", + "diagnostics": false + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "files": ["tests/types.d.ts"], + "include": ["src/**/*", "tests/**/*", "plugin.dev.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/plugins/agent-connect/types/contract.d.ts b/plugins/agent-connect/types/contract.d.ts new file mode 100644 index 000000000..1cb845c0d --- /dev/null +++ b/plugins/agent-connect/types/contract.d.ts @@ -0,0 +1,3034 @@ +import "@orpc/openapi/extensions/route"; +import { z } from "zod"; +export declare const contract: { + agentCreate: import("@orpc/contract").ProcedureContract; + idempotencyKey: z.ZodOptional; + }, z.core.$strip>, z.ZodCustom<{ + correlation_id: string; + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "grant_issue" | "grant_revoke" | "execution_cancel" | "agent_archive" | "agent_restore" | "agent_delete" | "approval_vote"; + agent_id: string; + status: "PENDING_SIGNATURE"; + expires_at: string; + signer: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + intent: { + standard: "nep413"; + payload: { + message: string; + recipient: string; + nonce: string; + }; + } | { + standard: "nep366"; + payload: { + receiverId: string; + actions: { + type: "FunctionCall"; + params: { + methodName: string; + args: { + [key: string]: unknown; + }; + gas: string; + deposit: string; + }; + }[]; + }; + } | { + standard: "eip712"; + payload: { + domain: { + [key: string]: unknown; + }; + types: { + [key: string]: { + name: string; + type: string; + }[]; + }; + primaryType: string; + message: { + [key: string]: unknown; + }; + }; + } | { + standard: "webauthn"; + payload: { + challenge: string; + rpId: string; + allowCredentials: { + id: string; + type: "public-key"; + }[]; + userVerification: "required"; + timeout: number; + }; + }; + preview: { + summary: string; + revision?: number; + previous_revision?: number; + policy_hash?: string; + policy?: { + frozen: boolean; + actions: ("swap" | "transfer" | "withdraw")[]; + confidential: boolean; + owner_approval: boolean; + assets: "any" | string[]; + limits: { + per_transaction?: { + [key: string]: string; + }; + hourly?: { + [key: string]: string; + }; + daily?: { + [key: string]: string; + }; + monthly?: { + [key: string]: string; + }; + }; + max_actions_per_hour: number | null; + destinations: { + mode: "any" | "only" | "except"; + list: ({ + action: "withdraw"; + chain: string; + address: string; + memo: string | null; + } | { + action: "transfer"; + address: string; + confidential: boolean; + })[]; + }; + budget: { + daily_usd: string | null; + weekly_usd: string | null; + monthly_usd: string | null; + }; + timelock_ms: number; + schedule?: { + mode: "only" | "except"; + time_zone: string; + windows: { + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[]; + start: string; + end: string; + }[]; + }; + sign?: { + recipients: string[]; + }; + }; + deletion?: { + near_account_id: string; + beneficiary: string; + native_balance: string; + public: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + confidential: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + assets_lost: boolean; + retirement: "provider_delete" | "credential_erase"; + }; + approval?: { + approval_id: string; + request_type: string; + request_hash: string; + verdict: "approve" | "reject"; + }; + }; + }, { + correlation_id: string; + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "grant_issue" | "grant_revoke" | "execution_cancel" | "agent_archive" | "agent_restore" | "agent_delete" | "approval_vote"; + agent_id: string; + status: "PENDING_SIGNATURE"; + expires_at: string; + signer: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + intent: { + standard: "nep413"; + payload: { + message: string; + recipient: string; + nonce: string; + }; + } | { + standard: "nep366"; + payload: { + receiverId: string; + actions: { + type: "FunctionCall"; + params: { + methodName: string; + args: { + [key: string]: unknown; + }; + gas: string; + deposit: string; + }; + }[]; + }; + } | { + standard: "eip712"; + payload: { + domain: { + [key: string]: unknown; + }; + types: { + [key: string]: { + name: string; + type: string; + }[]; + }; + primaryType: string; + message: { + [key: string]: unknown; + }; + }; + } | { + standard: "webauthn"; + payload: { + challenge: string; + rpId: string; + allowCredentials: { + id: string; + type: "public-key"; + }[]; + userVerification: "required"; + timeout: number; + }; + }; + preview: { + summary: string; + revision?: number; + previous_revision?: number; + policy_hash?: string; + policy?: { + frozen: boolean; + actions: ("swap" | "transfer" | "withdraw")[]; + confidential: boolean; + owner_approval: boolean; + assets: "any" | string[]; + limits: { + per_transaction?: { + [key: string]: string; + }; + hourly?: { + [key: string]: string; + }; + daily?: { + [key: string]: string; + }; + monthly?: { + [key: string]: string; + }; + }; + max_actions_per_hour: number | null; + destinations: { + mode: "any" | "only" | "except"; + list: ({ + action: "withdraw"; + chain: string; + address: string; + memo: string | null; + } | { + action: "transfer"; + address: string; + confidential: boolean; + })[]; + }; + budget: { + daily_usd: string | null; + weekly_usd: string | null; + monthly_usd: string | null; + }; + timelock_ms: number; + schedule?: { + mode: "only" | "except"; + time_zone: string; + windows: { + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[]; + start: string; + end: string; + }[]; + }; + sign?: { + recipients: string[]; + }; + }; + deletion?: { + near_account_id: string; + beneficiary: string; + native_balance: string; + public: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + confidential: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + assets_lost: boolean; + retirement: "provider_delete" | "credential_erase"; + }; + approval?: { + approval_id: string; + request_type: string; + request_hash: string; + verdict: "approve" | "reject"; + }; + }; + }>, { + UNAUTHORIZED: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + agentList: import("@orpc/contract").ProcedureContract, { + UNAUTHORIZED: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + agentGet: import("@orpc/contract").ProcedureContract, z.ZodCustom<{ + id: string; + name: string; + external_user_id: string | null; + status: "PENDING" | "ACTIVE" | "ARCHIVED" | "DELETED" | "ABANDONED"; + archived: boolean; + deleted: boolean; + owner: ({ + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }) | null; + owner_account: { + account_id: string; + public_key: string; + authority: "wallet"; + } | null; + wallet: { + wallet_id: string; + near_account_id: string; + } | null; + created_at: string; + cooldowns: { + policy_change_available_at: string | null; + unfreeze_available_at: string | null; + }; + }, { + id: string; + name: string; + external_user_id: string | null; + status: "PENDING" | "ACTIVE" | "ARCHIVED" | "DELETED" | "ABANDONED"; + archived: boolean; + deleted: boolean; + owner: ({ + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }) | null; + owner_account: { + account_id: string; + public_key: string; + authority: "wallet"; + } | null; + wallet: { + wallet_id: string; + near_account_id: string; + } | null; + created_at: string; + cooldowns: { + policy_change_available_at: string | null; + unfreeze_available_at: string | null; + }; + }>, { + UNAUTHORIZED: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + prepareIntent: import("@orpc/contract").ProcedureContract; + name: z.ZodString; + policy: z.ZodRecord; + idempotencyKey: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"grant_issue">; + agentId: z.ZodString; + label: z.ZodString; + expiresAt: z.ZodOptional; + idempotencyKey: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"grant_revoke">; + agentId: z.ZodString; + grantId: z.ZodString; + idempotencyKey: z.ZodOptional; + }, z.core.$strip>], "type">, z.ZodCustom<{ + correlation_id: string; + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "grant_issue" | "grant_revoke" | "execution_cancel" | "agent_archive" | "agent_restore" | "agent_delete" | "approval_vote"; + agent_id: string; + status: "PENDING_SIGNATURE"; + expires_at: string; + signer: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + intent: { + standard: "nep413"; + payload: { + message: string; + recipient: string; + nonce: string; + }; + } | { + standard: "nep366"; + payload: { + receiverId: string; + actions: { + type: "FunctionCall"; + params: { + methodName: string; + args: { + [key: string]: unknown; + }; + gas: string; + deposit: string; + }; + }[]; + }; + } | { + standard: "eip712"; + payload: { + domain: { + [key: string]: unknown; + }; + types: { + [key: string]: { + name: string; + type: string; + }[]; + }; + primaryType: string; + message: { + [key: string]: unknown; + }; + }; + } | { + standard: "webauthn"; + payload: { + challenge: string; + rpId: string; + allowCredentials: { + id: string; + type: "public-key"; + }[]; + userVerification: "required"; + timeout: number; + }; + }; + preview: { + summary: string; + revision?: number; + previous_revision?: number; + policy_hash?: string; + policy?: { + frozen: boolean; + actions: ("swap" | "transfer" | "withdraw")[]; + confidential: boolean; + owner_approval: boolean; + assets: "any" | string[]; + limits: { + per_transaction?: { + [key: string]: string; + }; + hourly?: { + [key: string]: string; + }; + daily?: { + [key: string]: string; + }; + monthly?: { + [key: string]: string; + }; + }; + max_actions_per_hour: number | null; + destinations: { + mode: "any" | "only" | "except"; + list: ({ + action: "withdraw"; + chain: string; + address: string; + memo: string | null; + } | { + action: "transfer"; + address: string; + confidential: boolean; + })[]; + }; + budget: { + daily_usd: string | null; + weekly_usd: string | null; + monthly_usd: string | null; + }; + timelock_ms: number; + schedule?: { + mode: "only" | "except"; + time_zone: string; + windows: { + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[]; + start: string; + end: string; + }[]; + }; + sign?: { + recipients: string[]; + }; + }; + deletion?: { + near_account_id: string; + beneficiary: string; + native_balance: string; + public: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + confidential: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + assets_lost: boolean; + retirement: "provider_delete" | "credential_erase"; + }; + approval?: { + approval_id: string; + request_type: string; + request_hash: string; + verdict: "approve" | "reject"; + }; + }; + }, { + correlation_id: string; + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "grant_issue" | "grant_revoke" | "execution_cancel" | "agent_archive" | "agent_restore" | "agent_delete" | "approval_vote"; + agent_id: string; + status: "PENDING_SIGNATURE"; + expires_at: string; + signer: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + intent: { + standard: "nep413"; + payload: { + message: string; + recipient: string; + nonce: string; + }; + } | { + standard: "nep366"; + payload: { + receiverId: string; + actions: { + type: "FunctionCall"; + params: { + methodName: string; + args: { + [key: string]: unknown; + }; + gas: string; + deposit: string; + }; + }[]; + }; + } | { + standard: "eip712"; + payload: { + domain: { + [key: string]: unknown; + }; + types: { + [key: string]: { + name: string; + type: string; + }[]; + }; + primaryType: string; + message: { + [key: string]: unknown; + }; + }; + } | { + standard: "webauthn"; + payload: { + challenge: string; + rpId: string; + allowCredentials: { + id: string; + type: "public-key"; + }[]; + userVerification: "required"; + timeout: number; + }; + }; + preview: { + summary: string; + revision?: number; + previous_revision?: number; + policy_hash?: string; + policy?: { + frozen: boolean; + actions: ("swap" | "transfer" | "withdraw")[]; + confidential: boolean; + owner_approval: boolean; + assets: "any" | string[]; + limits: { + per_transaction?: { + [key: string]: string; + }; + hourly?: { + [key: string]: string; + }; + daily?: { + [key: string]: string; + }; + monthly?: { + [key: string]: string; + }; + }; + max_actions_per_hour: number | null; + destinations: { + mode: "any" | "only" | "except"; + list: ({ + action: "withdraw"; + chain: string; + address: string; + memo: string | null; + } | { + action: "transfer"; + address: string; + confidential: boolean; + })[]; + }; + budget: { + daily_usd: string | null; + weekly_usd: string | null; + monthly_usd: string | null; + }; + timelock_ms: number; + schedule?: { + mode: "only" | "except"; + time_zone: string; + windows: { + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[]; + start: string; + end: string; + }[]; + }; + sign?: { + recipients: string[]; + }; + }; + deletion?: { + near_account_id: string; + beneficiary: string; + native_balance: string; + public: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + confidential: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + assets_lost: boolean; + retirement: "provider_delete" | "credential_erase"; + }; + approval?: { + approval_id: string; + request_type: string; + request_hash: string; + verdict: "approve" | "reject"; + }; + }; + }>, { + UNAUTHORIZED: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + intentSubmit: import("@orpc/contract").ProcedureContract; + correlationId: z.ZodString; + signedData: z.ZodRecord; + }, z.core.$strip>, z.ZodCustom<{ + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_create"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + agent_id: string; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "policy_update"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_freeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_unfreeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_issue"; + details: { + grant: { + grant_id: string; + agent_id: string; + wallet_id: string; + label: string; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revoked_reason: string | null; + owner_epoch: number; + owner_message: { + domain: "near-intents-agent-api.agent-grant.v6"; + owner: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + tenant_id: string; + agent_id: string; + wallet_id: string; + label: string; + credential: string; + network: "mainnet"; + issued_at_ms: number; + expires_at_ms: number; + owner_epoch: number; + nonce: string; + recipient: string; + }; + } | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_revoke"; + details: { + grant_id: string | null; + committed_correlation_ids: string[] | null; + committed_truncated: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "execution_cancel"; + details: { + cancelled_correlation_id: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_archive"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_restore"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_delete"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "approval_vote"; + details: { + approval_id: string | null; + verdict: ("approve" | "reject") | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "swap"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "withdraw"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "transfer"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "shield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "unshield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "deposit"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + }, { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_create"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + agent_id: string; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "policy_update"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_freeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_unfreeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_issue"; + details: { + grant: { + grant_id: string; + agent_id: string; + wallet_id: string; + label: string; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revoked_reason: string | null; + owner_epoch: number; + owner_message: { + domain: "near-intents-agent-api.agent-grant.v6"; + owner: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + tenant_id: string; + agent_id: string; + wallet_id: string; + label: string; + credential: string; + network: "mainnet"; + issued_at_ms: number; + expires_at_ms: number; + owner_epoch: number; + nonce: string; + recipient: string; + }; + } | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_revoke"; + details: { + grant_id: string | null; + committed_correlation_ids: string[] | null; + committed_truncated: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "execution_cancel"; + details: { + cancelled_correlation_id: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_archive"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_restore"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_delete"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "approval_vote"; + details: { + approval_id: string | null; + verdict: ("approve" | "reject") | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "swap"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "withdraw"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "transfer"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "shield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "unshield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "deposit"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + }>, { + UNAUTHORIZED: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + intentStatus: import("@orpc/contract").ProcedureContract; + }, z.core.$strip>, z.ZodCustom<{ + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_create"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + agent_id: string; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "policy_update"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_freeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_unfreeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_issue"; + details: { + grant: { + grant_id: string; + agent_id: string; + wallet_id: string; + label: string; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revoked_reason: string | null; + owner_epoch: number; + owner_message: { + domain: "near-intents-agent-api.agent-grant.v6"; + owner: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + tenant_id: string; + agent_id: string; + wallet_id: string; + label: string; + credential: string; + network: "mainnet"; + issued_at_ms: number; + expires_at_ms: number; + owner_epoch: number; + nonce: string; + recipient: string; + }; + } | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_revoke"; + details: { + grant_id: string | null; + committed_correlation_ids: string[] | null; + committed_truncated: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "execution_cancel"; + details: { + cancelled_correlation_id: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_archive"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_restore"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_delete"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "approval_vote"; + details: { + approval_id: string | null; + verdict: ("approve" | "reject") | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "swap"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "withdraw"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "transfer"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "shield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "unshield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "deposit"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + }, { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_create"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + agent_id: string; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "policy_update"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_freeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_unfreeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_issue"; + details: { + grant: { + grant_id: string; + agent_id: string; + wallet_id: string; + label: string; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revoked_reason: string | null; + owner_epoch: number; + owner_message: { + domain: "near-intents-agent-api.agent-grant.v6"; + owner: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + tenant_id: string; + agent_id: string; + wallet_id: string; + label: string; + credential: string; + network: "mainnet"; + issued_at_ms: number; + expires_at_ms: number; + owner_epoch: number; + nonce: string; + recipient: string; + }; + } | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_revoke"; + details: { + grant_id: string | null; + committed_correlation_ids: string[] | null; + committed_truncated: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "execution_cancel"; + details: { + cancelled_correlation_id: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_archive"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_restore"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_delete"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "approval_vote"; + details: { + approval_id: string | null; + verdict: ("approve" | "reject") | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "swap"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "withdraw"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "transfer"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "shield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "unshield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "deposit"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + }>, { + UNAUTHORIZED: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + grantList: import("@orpc/contract").ProcedureContract, z.ZodObject<{ + data: z.ZodArray>; + }, z.core.$strip>, { + UNAUTHORIZED: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; +}; +export type ContractType = typeof contract; diff --git a/plugins/agent-connect/vitest.config.ts b/plugins/agent-connect/vitest.config.ts new file mode 100644 index 000000000..f252254cf --- /dev/null +++ b/plugins/agent-connect/vitest.config.ts @@ -0,0 +1,19 @@ +import DrizzleORMMigrations from "@proj-airi/unplugin-drizzle-orm-migrations/vite"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + exclude: ["node_modules/**", "dist/**"], + testTimeout: 30000, + }, + plugins: [ + tsconfigPaths({ + projects: ["./tsconfig.json"], + }), + DrizzleORMMigrations(), + ], +}); diff --git a/plugins/auth/src/config-schemas.ts b/plugins/auth/src/config-schemas.ts index e74d458e9..4733714d2 100644 --- a/plugins/auth/src/config-schemas.ts +++ b/plugins/auth/src/config-schemas.ts @@ -162,6 +162,7 @@ export const authSecretsSchema = z.object({ TWILIO_PHONE_NUMBER: z.string().optional(), NEAR_RELAYER_PRIVATE_KEY_MAINNET: z.string().optional(), NEAR_RELAYER_PRIVATE_KEY_TESTNET: z.string().optional(), + NEAR_INTENTS_AGENT_API_KEY: z.string().optional(), RESEND_API_KEY: z.string().optional(), AUTH_BOOTSTRAP_ADMIN_API_KEY: z .string() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a80b4d6d0..575b3a3fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ catalogs: '@module-federation/runtime-tools': specifier: ^2.9.0 version: 2.9.2 + '@near-intents-agent-api/sdk': + specifier: ^0.4.1 + version: 0.4.1 '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.1 @@ -677,6 +680,9 @@ importers: packages/better-near-auth: dependencies: + '@near-intents-agent-api/sdk': + specifier: ^0.4.1 + version: 0.4.1 '@noble/curves': specifier: ^2.4.0 version: 2.4.0 @@ -689,6 +695,9 @@ importers: '@scure/base': specifier: ^2.2.0 version: 2.4.0 + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 nanostores: specifier: ^1.1.0 version: 1.5.4 @@ -1081,6 +1090,79 @@ importers: specifier: 'catalog:' version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(@vitest/ui@4.1.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.2(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + plugins/agent-connect: + dependencies: + '@electric-sql/pglite': + specifier: 'catalog:' + version: 0.4.6 + '@near-intents-agent-api/sdk': + specifier: 'catalog:' + version: 0.4.1 + '@orpc/contract': + specifier: 'catalog:' + version: 2.0.0-beta.35 + '@orpc/openapi': + specifier: 'catalog:' + version: 2.0.0-beta.35 + '@orpc/server': + specifier: 'catalog:' + version: 2.0.0-beta.35 + drizzle-orm: + specifier: 'catalog:' + version: 0.45.3(@cloudflare/workers-types@5.20261019.1)(@electric-sql/pglite@0.4.6)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(bun-types@1.4.4)(kysely@0.29.6)(pg@8.23.1) + effect: + specifier: 'catalog:' + version: 4.0.0-rc.117 + pg: + specifier: 'catalog:' + version: 8.23.1 + typescript: + specifier: 'catalog:' + version: 7.0.2 + zod: + specifier: 'catalog:' + version: 4.6.5 + devDependencies: + '@module-federation/node': + specifier: 'catalog:' + version: 2.7.52(@rspack/core@2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23))(typescript@7.0.2) + '@module-federation/runtime-tools': + specifier: 'catalog:' + version: 2.9.2 + '@proj-airi/unplugin-drizzle-orm-migrations': + specifier: ^0.1.4 + version: 0.1.6(vite@8.3.2(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + '@rspack/cli': + specifier: 'catalog:' + version: 2.2.6(@rspack/core@2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23)) + '@rspack/core': + specifier: 2.2.8 + version: 2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23) + '@types/node': + specifier: 'catalog:' + version: 25.9.9 + '@types/pg': + specifier: 'catalog:' + version: 8.23.1 + dotenv: + specifier: ^17.2.3 + version: 17.4.2 + drizzle-kit: + specifier: 'catalog:' + version: 0.31.11 + every-plugin: + specifier: workspace:^ + version: link:../../packages/every-plugin + everything-dev: + specifier: workspace:^ + version: link:../../packages/everything-dev + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@7.0.2)(vite@8.3.2(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + vitest: + specifier: 'catalog:' + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(@vitest/ui@4.1.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.2(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + plugins/ai: dependencies: '@base-ui/react': @@ -3758,6 +3840,10 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@near-intents-agent-api/sdk@0.4.1': + resolution: {integrity: sha512-1b9QlOK/j2jiFPfFuSfUS9ZRox+EHOuF8gd0bfwzasAYdGZ1S5QOOIAAYS6Vv6DAhdJHWwH7xk/rgVaQmoQCQQ==} + engines: {node: '>=24'} + '@neodrag/core@3.0.0-next.11': resolution: {integrity: sha512-3WQWxyrbxiaK9zS5JU2wJsW2gpoQlZBXVghduBh61JpqaeE0T0cte8R0qYK2RuJo3J2TYQYqxO19CpG/C1i5eg==} @@ -10932,6 +11018,12 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@near-intents-agent-api/sdk@0.4.1': + dependencies: + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@scure/base': 2.4.0 + '@neodrag/core@3.0.0-next.11': {} '@neodrag/solid@3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.15)': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d3c7a64f..70096d476 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,7 @@ catalog: "@orpc/tanstack-query": 2.0.0-beta.35 "@orpc/zod": 2.0.0-beta.35 "@outlayer/sdk": 0.1.0-alpha.4 + "@near-intents-agent-api/sdk": ^0.4.1 "@rsbuild/core": 2.2.8 "@rsbuild/plugin-react": 2.1.0 "@rspack/cli": 2.2.6 diff --git a/skills-lock.json b/skills-lock.json index 744432916..05987a1af 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -163,6 +163,12 @@ "skillPath": "skills/mobile-native/SKILL.md", "computedHash": "f221cea36642168df6d3d6d7f236237cbf1ae669138ff7df07775674223d49ad" }, + "near-intents-agent-api": { + "source": "NEAR-Intents-Agent-API/skills", + "sourceType": "github", + "skillPath": "near-intents-agent-api/SKILL.md", + "computedHash": "86d548e72ff84470b3875b17cc36887c0e24ce542ca1bf958689b105b84db750" + }, "pick-ui-library": { "source": "emilkowalski/skills", "sourceType": "github",