diff --git a/.changeset/agents-plugin-ui-dev-wiring.md b/.changeset/agents-plugin-ui-dev-wiring.md new file mode 100644 index 000000000..368ec310e --- /dev/null +++ b/.changeset/agents-plugin-ui-dev-wiring.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +Fix folder-form plugin UI dev wiring: a local plugin's associated `ui` development entry was stripped during config resolution (only entries with a nested bos.config.json kept local paths), so the dev harness never allocated a UI port or passed `BOS_UI_PORT` — the plugin's UI rsbuild then auto-ported into the plugin API's port and MF chunk loads returned HTML. A local plugin's associated ui now keeps its local development whenever the plugin entry itself resolves locally. Also declares the agents plugin (with its folder-form ui) in the authored citynode descriptor. diff --git a/.changeset/agents-signature-delivery.md b/.changeset/agents-signature-delivery.md new file mode 100644 index 000000000..5307d9782 --- /dev/null +++ b/.changeset/agents-signature-delivery.md @@ -0,0 +1,5 @@ +--- +"@everything-dev/agents-plugin": minor +--- + +Expose the upstream signature-delivery loop — `getSignature` (read a completed signature once, under the grant that authorized it) and `acknowledgeSignature` (erase it server-side, one-time-consume with idempotent acks) — grant-token-gated like `signMessage`, backed by the encrypted operation-artifacts store. Also variablize the runtime wiring: `agentsTrustedOrigins` (required; feeds the runtime's trusted origins) and `agentsServiceUrl` (optional; its hostname is the NEP-413 recipient owners sign into every owner message) replace the hardcoded citynode.app/agents.local placeholders. diff --git a/.env.example b/.env.example index 4ac1be04a..fedf9937d 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,12 @@ TWILIO_AUTH_TOKEN= TWILIO_PHONE_NUMBER= RESEND_API_KEY= +# plugins.agents +AGENTS_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db +AGENTS_SPONSOR_KEYS= +AGENTS_SECRET_ENCRYPTION_KEYS= +AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID= + # plugins.template TEMPLATE_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db diff --git a/.env.test b/.env.test index b0d6f72e4..5a1cf4316 100644 --- a/.env.test +++ b/.env.test @@ -8,6 +8,9 @@ API_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5434/api_test_ AUTH_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5435/auth_test_db BETTER_AUTH_SECRET=regression-test-secret-do-not-use-in-production +# plugins.agents +AGENTS_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5434/api_test_db + # plugins.template TEMPLATE_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5434/api_test_db diff --git a/biome.json b/biome.json index 9ab580b91..fe63340c5 100644 --- a/biome.json +++ b/biome.json @@ -57,7 +57,8 @@ "!!**/virtual-modules.d.ts", "!!bos.config.json", "!!plans", - "!!.agents" + "!!.agents", + "!!packages/database/drizzle" ], "ignoreUnknown": true }, diff --git a/bos.citynode.app.ts b/bos.citynode.app.ts index 09abd68eb..316516695 100644 --- a/bos.citynode.app.ts +++ b/bos.citynode.app.ts @@ -84,6 +84,24 @@ export default App({ }, }), plugins: { + agents: Plugin("agents").path("plugins/agents", { + name: "@everything-dev/agents-plugin", + ui: { path: "plugins/agents/ui" }, + variables: { + agentsNearRpcUrls: "https://near.drpc.org,https://free.rpc.fastnear.com", + agentsTrustedOrigins: "https://citynode.app", + agentsServiceUrl: "https://agents.local", + agentsSponsorDailyGlobalLimit: 100, + agentsSponsorDailyTenantLimit: 20, + agentsSponsorDailyAgentLimit: 10, + }, + secrets: [ + "AGENTS_DATABASE_URL", + "AGENTS_SPONSOR_KEYS", + "AGENTS_SECRET_ENCRYPTION_KEYS", + "AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID", + ], + }), template: Plugin("template").path("plugins/_template", { secrets: ["TEMPLATE_DATABASE_URL"], }), diff --git a/bos.config.json b/bos.config.json index e3097b65e..a5b69c6d9 100644 --- a/bos.config.json +++ b/bos.config.json @@ -134,6 +134,27 @@ } }, "plugins": { + "agents": { + "development": "local:plugins/agents", + "variables": { + "agentsNearRpcUrls": "https://near.drpc.org,https://free.rpc.fastnear.com", + "agentsTrustedOrigins": "https://citynode.app", + "agentsServiceUrl": "https://agents.local", + "agentsSponsorDailyGlobalLimit": 100, + "agentsSponsorDailyTenantLimit": 20, + "agentsSponsorDailyAgentLimit": 10 + }, + "secrets": [ + "AGENTS_DATABASE_URL", + "AGENTS_SPONSOR_KEYS", + "AGENTS_SECRET_ENCRYPTION_KEYS", + "AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID" + ], + "ui": { + "name": "_everything_dev_agents_plugin", + "development": "local:plugins/agents/ui" + } + }, "template": { "development": "local:plugins/_template", "variables": {}, diff --git a/bun.lock b/bun.lock index 6c3b0de73..5a982845d 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,23 @@ "vitest": "catalog:", }, }, + "packages/agents-core": { + "name": "@near-intents-agent-api/agents-core", + "version": "0.1.0", + "dependencies": { + "@near-intents-agent-api/contracts": "workspace:*", + "@near-intents-agent-api/database": "workspace:*", + "@near-intents-agent-api/outlayer": "workspace:*", + "@near-intents-agent-api/owner-auth": "workspace:*", + "@near-intents-agent-api/relayer": "workspace:*", + "drizzle-orm": "catalog:", + "near-api-js": "7.2.0", + "zod": "catalog:", + }, + "devDependencies": { + "@types/node": "catalog:", + }, + }, "packages/better-near-auth": { "name": "better-near-auth", "version": "1.10.2", @@ -152,6 +169,33 @@ "react", ], }, + "packages/contracts": { + "name": "@near-intents-agent-api/contracts", + "version": "0.1.0", + "dependencies": { + "@noble/hashes": "2.4.0", + "@scure/base": "catalog:", + "zod": "catalog:", + }, + "devDependencies": { + "@types/node": "catalog:", + }, + }, + "packages/database": { + "name": "@near-intents-agent-api/database", + "version": "0.1.0", + "dependencies": { + "@near-intents-agent-api/contracts": "workspace:*", + "drizzle-orm": "catalog:", + "pg": "catalog:", + }, + "devDependencies": { + "@electric-sql/pglite": "catalog:", + "@types/node": "catalog:", + "@types/pg": "catalog:", + "drizzle-kit": "catalog:", + }, + }, "packages/every-plugin": { "name": "every-plugin", "version": "2.10.1", @@ -287,6 +331,43 @@ "sonner", ], }, + "packages/outlayer": { + "name": "@near-intents-agent-api/outlayer", + "version": "0.1.0", + "dependencies": { + "zod": "catalog:", + }, + "devDependencies": { + "@types/node": "catalog:", + }, + }, + "packages/owner-auth": { + "name": "@near-intents-agent-api/owner-auth", + "version": "0.1.0", + "dependencies": { + "@near-intents-agent-api/contracts": "workspace:*", + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "catalog:", + "@simplewebauthn/server": "14.0.2", + }, + "devDependencies": { + "@types/node": "catalog:", + }, + }, + "packages/relayer": { + "name": "@near-intents-agent-api/relayer", + "version": "0.1.0", + "dependencies": { + "@near-intents-agent-api/contracts": "workspace:*", + "@scure/base": "catalog:", + "near-api-js": "7.2.0", + "zod": "catalog:", + }, + "devDependencies": { + "@types/node": "catalog:", + }, + }, "plugins/_template": { "name": "@every-plugin/template", "version": "1.3.0", @@ -319,6 +400,65 @@ "typescript": "catalog:", }, }, + "plugins/agents": { + "name": "@everything-dev/agents-plugin", + "version": "0.1.0", + "dependencies": { + "@base-ui/react": "^1.8.0", + "@electric-sql/pglite": "catalog:", + "@near-intents-agent-api/agents-core": "workspace:*", + "@near-intents-agent-api/contracts": "workspace:*", + "@near-intents-agent-api/database": "workspace:*", + "@near-intents-agent-api/outlayer": "workspace:*", + "@orpc/contract": "catalog:", + "@orpc/server": "catalog:", + "@orpc/tanstack-query": "catalog:", + "@phosphor-icons/react": "^2.1.10", + "@tanstack/react-query": "catalog:", + "@tanstack/react-router": "catalog:", + "class-variance-authority": "^0.7.1", + "cn": "^0.4.0", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "every-plugin": "catalog:", + "everything-dev": "catalog:", + "framer-motion": "^12.37.0", + "pg": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7", + "tw-animate-css": "^1.4.0", + "zod": "catalog:", + }, + "devDependencies": { + "@module-federation/enhanced": "catalog:", + "@module-federation/node": "catalog:", + "@module-federation/rsbuild-plugin": "catalog:", + "@module-federation/runtime-tools": "catalog:", + "@proj-airi/unplugin-drizzle-orm-migrations": "^0.1.4", + "@rsbuild/core": "catalog:", + "@rsbuild/plugin-react": "catalog:", + "@rspack/cli": "catalog:", + "@rspack/core": "catalog:", + "@tailwindcss/postcss": "^4.1.17", + "@tanstack/router-plugin": "catalog:", + "@types/node": "catalog:", + "@types/pg": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "dotenv": "^17.2.3", + "drizzle-kit": "catalog:", + "every-plugin": "catalog:", + "everything-dev": "catalog:", + "shadcn": "^4.21.0", + "tailwindcss": "^4.1.17", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "catalog:", + }, + "peerDependencies": { + "typescript": "catalog:", + }, + }, "plugins/apps": { "name": "@everything-dev/apps-plugin", "version": "1.4.2", @@ -896,6 +1036,8 @@ "@every-plugin/template": ["@every-plugin/template@workspace:plugins/_template"], + "@everything-dev/agents-plugin": ["@everything-dev/agents-plugin@workspace:plugins/agents"], + "@everything-dev/apps-plugin": ["@everything-dev/apps-plugin@workspace:plugins/apps"], "@everything-dev/auth-plugin": ["@everything-dev/auth-plugin@workspace:plugins/auth"], @@ -1098,6 +1240,18 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@near-intents-agent-api/agents-core": ["@near-intents-agent-api/agents-core@workspace:packages/agents-core"], + + "@near-intents-agent-api/contracts": ["@near-intents-agent-api/contracts@workspace:packages/contracts"], + + "@near-intents-agent-api/database": ["@near-intents-agent-api/database@workspace:packages/database"], + + "@near-intents-agent-api/outlayer": ["@near-intents-agent-api/outlayer@workspace:packages/outlayer"], + + "@near-intents-agent-api/owner-auth": ["@near-intents-agent-api/owner-auth@workspace:packages/owner-auth"], + + "@near-intents-agent-api/relayer": ["@near-intents-agent-api/relayer@workspace:packages/relayer"], + "@neodrag/core": ["@neodrag/core@3.0.0-next.11", "", {}, "sha512-3WQWxyrbxiaK9zS5JU2wJsW2gpoQlZBXVghduBh61JpqaeE0T0cte8R0qYK2RuJo3J2TYQYqxO19CpG/C1i5eg=="], "@neodrag/solid": ["@neodrag/solid@3.0.0-next.11", "", { "peerDependencies": { "@neodrag/core": "3.0.0-next.11", "solid-js": "^1.0.0" } }, "sha512-vCBIn/pimjWMQ6vhTS2/O1XNAwzVtc4eUhdbQ91WykbZWWqQ5NocDXt/1OdYrEkeRzJcpCv8wEz5PnMkgKP81Q=="], @@ -1238,6 +1392,8 @@ "@peculiar/asn1-android": ["@peculiar/asn1-android@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ=="], + "@peculiar/asn1-asym-key": ["@peculiar/asn1-asym-key@2.10.0", "", { "dependencies": { "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg=="], + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-attr": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw=="], "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw=="], @@ -1258,6 +1414,8 @@ "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg=="], + "@peculiar/asn1-x509-post-quantum": ["@peculiar/asn1-x509-post-quantum@2.10.0", "", { "dependencies": { "@peculiar/asn1-asym-key": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w=="], + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], @@ -1680,6 +1838,8 @@ "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -1688,6 +1848,8 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg=="], "better-auth": ["better-auth@1.6.25", "", { "dependencies": { "@better-auth/core": "1.6.25", "@better-auth/drizzle-adapter": "1.6.25", "@better-auth/kysely-adapter": "1.6.25", "@better-auth/memory-adapter": "1.6.25", "@better-auth/mongo-adapter": "1.6.25", "@better-auth/prisma-adapter": "1.6.25", "@better-auth/telemetry": "1.6.25", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw=="], @@ -1702,14 +1864,22 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "bip39": ["bip39@3.0.2", "", { "dependencies": { "@types/node": "11.11.6", "create-hash": "^1.1.0", "pbkdf2": "^3.0.9", "randombytes": "^2.0.1" } }, "sha512-J4E1r2N0tUylTKt07ibXvhpT2c5pyAFgvuA5q1H9uDy6dEGpjV8jmymh3MTYJDLCNbIVClSB9FbND49I6N24MQ=="], + + "bip39-light": ["bip39-light@1.0.7", "", { "dependencies": { "create-hash": "^1.1.0", "pbkdf2": "^3.0.9" } }, "sha512-WDTmLRQUsiioBdTs9BmSEmkJza+8xfJmptsNJjxnoq3EydSa/ZBXT6rm66KoT3PJIRYMnhSKNR7S9YL1l7R40Q=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "borsh": ["borsh@2.0.0", "", {}, "sha512-kc9+BgR3zz9+cjbwM8ODoUB4fs3X3I5A/HtX7LZKxCLaMrEeDFoBpnhZY//DTS1VZBSs6S5v46RZRbZjRFspEg=="], + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="], + "bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], @@ -1722,6 +1892,8 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -1752,6 +1924,8 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "cipher-base": ["cipher-base@1.0.7", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.2" } }, "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -1790,10 +1964,16 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + "create-hash": ["create-hash@1.2.0", "", { "dependencies": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", "md5.js": "^1.3.4", "ripemd160": "^2.0.1", "sha.js": "^2.4.0" } }, "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg=="], + + "create-hmac": ["create-hmac@1.1.7", "", { "dependencies": { "cipher-base": "^1.0.3", "create-hash": "^1.1.0", "inherits": "^2.0.1", "ripemd160": "^2.0.0", "safe-buffer": "^5.0.1", "sha.js": "^2.4.8" } }, "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg=="], + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1830,6 +2010,8 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], @@ -1962,6 +2144,8 @@ "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -1978,6 +2162,10 @@ "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "generate-object-property": ["generate-object-property@1.2.0", "", { "dependencies": { "is-property": "^1.0.0" } }, "sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -2012,8 +2200,14 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="], @@ -2072,6 +2266,8 @@ "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -2090,6 +2286,10 @@ "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + "is-my-ip-valid": ["is-my-ip-valid@1.0.1", "", {}, "sha512-jxc8cBcOWbNK2i2aTkCZP6i7wkHF1bqKFrwEHuN5Jtg5BSaZHUZQ/JTOJwoV41YvHnOaRyWWh72T/KvfNz9DJg=="], + + "is-my-json-valid": ["is-my-json-valid@2.20.6", "", { "dependencies": { "generate-function": "^2.0.0", "generate-object-property": "^1.1.0", "is-my-ip-valid": "^1.0.0", "jsonpointer": "^5.0.0", "xtend": "^4.0.0" } }, "sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], @@ -2100,18 +2300,24 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isbot": ["isbot@5.2.2", "", {}, "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -2148,6 +2354,8 @@ "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], @@ -2202,6 +2410,8 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "md5.js": ["md5.js@1.3.5", "", { "dependencies": { "hash-base": "^3.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], @@ -2334,8 +2544,14 @@ "nanostores": ["nanostores@1.5.3", "", {}, "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA=="], + "near-api-js": ["near-api-js@7.2.0", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "borsh": "2.0.0", "is-my-json-valid": "2.20.6", "near-seed-phrase": "0.2.1" } }, "sha512-byLJC+VBXe8SDCABqy5A2wXE+oHzDrn8JKoIKUnpilg1Tm9v3hejYv047hF2KUmSC5Ll7Rl5I5CrU2lk/u4ocQ=="], + + "near-hd-key": ["near-hd-key@1.2.1", "", { "dependencies": { "bip39": "3.0.2", "create-hmac": "1.1.7", "tweetnacl": "1.0.3" } }, "sha512-SIrthcL5Wc0sps+2e1xGj3zceEa68TgNZDLuCx0daxmfTP7sFTB3/mtE2pYhlFsCxWoMn+JfID5E1NlzvvbRJg=="], + "near-kit": ["near-kit@0.20.2", "", { "dependencies": { "@napi-rs/keyring": "^1.3.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.0.1", "@noble/post-quantum": "^0.6.1", "@scure/base": "^2.2.0", "@scure/bip32": "^2.2.0", "@scure/bip39": "^2.2.0", "@zorsh/zorsh": "^0.5.1", "tar": "^7.5.20", "zod": "^4.4.3" }, "peerDependencies": { "@hot-labs/near-connect": ">=0.11.0", "@near-wallet-selector/core": ">=8.0.0" }, "optionalPeers": ["@hot-labs/near-connect", "@near-wallet-selector/core"] }, "sha512-434on2oFgimsUfvqLSvM3bDntQRnZvoD9QlWbjiORvbGIycrC84//Dol7tq0g2S0VcMySctU+ooljxggmnTkMw=="], + "near-seed-phrase": ["near-seed-phrase@0.2.1", "", { "dependencies": { "bip39-light": "^1.0.7", "bs58": "^4.0.1", "near-hd-key": "^1.2.1", "tweetnacl": "^1.0.2" } }, "sha512-feMuums+kVL3LSuPcP4ld07xHCb2mu6z48SGfP3W+8tl1Qm5xIcjiQzY2IDPBvFgajRDxWSb8GzsRHoInazByw=="], + "near-social-js": ["near-social-js@2.0.4", "", { "dependencies": { "bignumber.js": "^9.3.1", "near-kit": "^0.10.0" } }, "sha512-CObpWWuE73jd0phsswAI9biA9pt0nYA3CD35EXvD6Y8FhyXlACmb2BQlfL46ZYR+Q9mGTwWJoic//zRvaI5z1A=="], "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], @@ -2418,6 +2634,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pbkdf2": ["pbkdf2@3.1.7", "", { "dependencies": { "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", "sha.js": "^2.4.12", "to-buffer": "^1.2.2" } }, "sha512-nS4mvFgVwUrecPTRdpdseM2fwpdfnX0/HibA8DgBRQtihUAzHQYJFU2l+OlEtiXtpqkrMvCPDwyXMUnASDt6Qw=="], + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], @@ -2454,6 +2672,8 @@ "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], @@ -2474,6 +2694,8 @@ "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -2498,6 +2720,8 @@ "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -2516,6 +2740,8 @@ "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], "recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="], @@ -2552,6 +2778,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "ripemd160": ["ripemd160@2.0.3", "", { "dependencies": { "hash-base": "^3.1.2", "inherits": "^2.0.4" } }, "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA=="], + "rolldown": ["rolldown@1.2.8", "", { "dependencies": { "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.8", "@rolldown/binding-android-arm64": "1.2.8", "@rolldown/binding-darwin-arm64": "1.2.8", "@rolldown/binding-darwin-x64": "1.2.8", "@rolldown/binding-freebsd-x64": "1.2.8", "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", "@rolldown/binding-linux-arm64-gnu": "1.2.8", "@rolldown/binding-linux-arm64-musl": "1.2.8", "@rolldown/binding-linux-ppc64-gnu": "1.2.8", "@rolldown/binding-linux-s390x-gnu": "1.2.8", "@rolldown/binding-linux-x64-gnu": "1.2.8", "@rolldown/binding-linux-x64-musl": "1.2.8", "@rolldown/binding-openharmony-arm64": "1.2.8", "@rolldown/binding-win32-arm64-msvc": "1.2.8", "@rolldown/binding-win32-x64-msvc": "1.2.8" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ=="], "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.14", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.4", "yuku-ast": "^0.8.0", "yuku-codegen": "^0.8.0", "yuku-parser": "^0.8.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw=="], @@ -2564,6 +2792,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], @@ -2586,8 +2816,12 @@ "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="], + "shadcn": ["shadcn@4.21.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "cn": "^0.2.4", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "socks": "^2.8.8", "stringify-object": "^5.0.0", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-UU2mFNusW8C5rvadKdH69vERYZqUlOOlXBcf0MYhYLdTGP6DPti7X4qovCu+RTfCqsAgq/T+YfE0Vnttxh9aiw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -2646,6 +2880,8 @@ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], @@ -2688,6 +2924,8 @@ "tldts-core": ["tldts-core@7.4.13", "", {}, "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg=="], + "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -2720,10 +2958,14 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "ui": ["ui@workspace:ui"], @@ -2732,7 +2974,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -2798,6 +3040,8 @@ "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.24", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -2868,12 +3112,12 @@ "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - "@dotenvx/dotenvx/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@every-plugin/template/vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + "@everything-dev/agents-plugin/vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + "@everything-dev/apps-plugin/vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], "@everything-dev/auth-plugin/vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], @@ -2900,7 +3144,7 @@ "@module-federation/cli/jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], - "@module-federation/dts-plugin/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "@near-intents-agent-api/owner-auth/@simplewebauthn/server": ["@simplewebauthn/server@14.0.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "@peculiar/x509": "^2.1.0", "reflect-metadata": "^0.2.2" } }, "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg=="], "@noble/post-quantum/@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -2908,6 +3152,16 @@ "@noble/post-quantum/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@peculiar/asn1-asym-key/@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw=="], + + "@peculiar/asn1-asym-key/@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.10.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw=="], + + "@peculiar/asn1-asym-key/@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA=="], + + "@peculiar/asn1-x509-post-quantum/@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.10.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw=="], + + "@peculiar/asn1-x509-post-quantum/@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA=="], + "@pingpay/onramp-sdk/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "@pingpay/onramp-sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2960,6 +3214,8 @@ "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], + "bip39/@types/node": ["@types/node@11.11.6", "", {}, "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2990,12 +3246,20 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "jsdom/undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "near-api-js/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "near-api-js/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "near-api-js/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + "near-social-js/near-kit": ["near-kit@0.10.0", "", { "dependencies": { "@napi-rs/keyring": "^1.2.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1", "@scure/bip39": "^2.0.1", "@zorsh/zorsh": "^0.4.0", "tar": "^7.5.6", "zod": "^4.3.6" }, "peerDependencies": { "@hot-labs/near-connect": ">=0.6.0", "@near-wallet-selector/core": ">=8.0.0" }, "optionalPeers": ["@hot-labs/near-connect", "@near-wallet-selector/core"] }, "sha512-P/UEXT8gSbdZgeEFc7lMIOmUvkCwczmypDS5vU/7gUdcFG8S77ZymBzn8dkyxxLeVA2wFkwVpAPmzJEEX9m4ug=="], "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -3030,6 +3294,10 @@ "read-yaml-file/js-yaml": ["js-yaml@3.15.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w=="], + "readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "rolldown/@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="], @@ -3040,12 +3308,12 @@ "shadcn/fs-extra": ["fs-extra@11.4.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA=="], - "shadcn/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "tsdown/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -3138,6 +3406,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@near-intents-agent-api/owner-auth/@simplewebauthn/server/@peculiar/x509": ["@peculiar/x509@2.1.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-csr": "^2.9.4", "@peculiar/asn1-ecc": "^2.9.4", "@peculiar/asn1-pkcs9": "^2.9.4", "@peculiar/asn1-rsa": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], @@ -3258,6 +3528,10 @@ "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@near-intents-agent-api/owner-auth/@simplewebauthn/server/@peculiar/x509/@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.10.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw=="], + + "@near-intents-agent-api/owner-auth/@simplewebauthn/server/@peculiar/x509/@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA=="], + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], diff --git a/docs/agents/agents-plugin.md b/docs/agents/agents-plugin.md new file mode 100644 index 000000000..342183c39 --- /dev/null +++ b/docs/agents/agents-plugin.md @@ -0,0 +1,81 @@ +# Agents plugin — usage guide + +The `agents` plugin re-hosts the NEAR Intents Agent control plane as an +everything.dev plugin: intents custody wallets operated by the platform's +sponsor under the owner's signed policy. The wire contract is generated from +the vendored `@near-intents-agent-api/contracts` schemas, so the public API +surface matches the upstream control plane. + +## The three actors + +| Actor | Authenticates with | Can do | +|-------|--------------------|--------| +| Owner | NEAR SIWN session | Create agents, sign governance intents, read everything about their agents | +| Grant holder | `x-grant-token` header | Execute within the grant's scope: swap, withdraw, transfer, sign messages | +| MCP client | `x-api-key` (Settings → API Keys) | Everything the API exposes, as generated tools | + +## Owner flow + +1. Sign in with your NEAR wallet (SIWN). The session binds you as an owner. +2. Open **Agents** in the sidebar (or `POST /api/rpc/agents/generateIntent` + with `type: "agent_create"`). The first intent creates the agent and its + custody wallet. +3. The server builds every byte the owner signs and returns it as + `intent: { standard, payload }`: + - **Policy intents** (`agent_create`, `policy_update`, freeze/unfreeze) + sign as **NEP-366 delegate actions** — the UI calls + `authClient.near.buildSignedDelegateAction(...)`. + - **Consent intents** (`grant_issue`, `grant_revoke`, `budget_set`, + `timelock_set`, …) sign as **NEP-413 messages** — the UI calls + `Near.signMessage({ message, recipient, nonce })` with the base64 + nonce decoded to bytes. +4. Submit the wallet's output unchanged as + `submitIntent({ type, correlationId, signedData })` and poll + `intentStatus` for settlement. Submitting the same signature twice is + idempotent. + +The sponsor relays the wallet's delegate on-chain (gasless for the owner); +sponsor keys come from the `AGENTS_SPONSOR_KEYS` plugin secret. Without +them, sponsor features are disabled and the log explains what is missing. + +## Delegated flow (grants) + +A grant is the owner's delegation of bounded execution authority to one +token. The token is created **client-side**; only its SHA-256 commitment is +ever sent: + +1. `createGrantCredential()` → `{ token: "ngt_…", commitment: }` +2. `generateIntent({ type: "grant_issue", label, credential, actions, + recipients, signingAudiences, expiresAt })` → sign (NEP-413) → submit. +3. Keep the token in your secret manager. Execute with the header: + `x-grant-token: ngt_…` against the execution routes (`/agents/swap`, + `/agents/withdraw`, `/agents/transfer`, `/agents/sign-message`, …). + +Every execution is checked against three layers at dispatch: the grant's +actions and destinations, the owner's USD spend budget, and the provider +policy. `dry: true` previews a swap or withdraw (provider quote) without +executing. Revoking a grant (`grant_revoke` intent) retracts future work; +work that already reached its dispatch commitment is reported in +`committedCorrelationIds`. + +## MCP usage + +Every route is generated as an MCP tool from the OpenAPI spec +(`POST /api/mcp`, discovery at `/.well-known/mcp.json`). Owner routes +require a session cookie or `x-api-key`; delegated routes additionally +require the `x-grant-token` header — pass it per call. + +## Local development + +```bash +bun run dev # local bos CLI — boots the citynode runtime incl. agents on :3010 +``` + +- `.env` needs `BETTER_AUTH_SECRET` set (auth validate-secrets) and, for the + submit leg, `AGENTS_SPONSOR_KEYS` (testnet sponsor full-access key). +- The plugin's UI is folder-form (`plugins/agents/ui/`) — the dev harness + builds it automatically and composes its routes under the authenticated + mount; the sidebar entry comes from the route's `staticData.nav`. +- Secrets: `AGENTS_DATABASE_URL` (auto-generated from resolved ports in + dev), `AGENTS_SPONSOR_KEYS`, `AGENTS_SECRET_ENCRYPTION_KEYS`, + `AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID`. diff --git a/packages/agents-core/package.json b/packages/agents-core/package.json new file mode 100644 index 000000000..25fe57e95 --- /dev/null +++ b/packages/agents-core/package.json @@ -0,0 +1,37 @@ +{ + "name": "@near-intents-agent-api/agents-core", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./config": { + "types": "./src/config/env.ts", + "import": "./src/config/env.ts" + }, + "./views": { + "types": "./src/api/views.ts", + "import": "./src/api/views.ts" + } + }, + "dependencies": { + "@near-intents-agent-api/contracts": "workspace:*", + "@near-intents-agent-api/database": "workspace:*", + "@near-intents-agent-api/outlayer": "workspace:*", + "@near-intents-agent-api/owner-auth": "workspace:*", + "@near-intents-agent-api/relayer": "workspace:*", + "drizzle-orm": "catalog:", + "near-api-js": "7.2.0", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/agents-core/src/api/camel.ts b/packages/agents-core/src/api/camel.ts new file mode 100644 index 000000000..49de7c128 --- /dev/null +++ b/packages/agents-core/src/api/camel.ts @@ -0,0 +1,30 @@ +/** + * Provider evidence and pass-through records reach the wire camelCased. Signed protocol objects + * (policies, owner messages, wallet requests) never pass through here: their field names are + * part of what the owner signed. + */ + +export function camelKey(key: string): string { + return key.replace(/_([a-z0-9])/g, (_, letter: string) => letter.toUpperCase()); +} + +const maxDepth = 8; + +export function camelize(value: unknown, depth = 0): unknown { + if (depth > maxDepth) return value; + if (Array.isArray(value)) return value.map((entry) => camelize(entry, depth + 1)); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + camelKey(key), + camelize(entry, depth + 1), + ]), + ); +} + +export function camelRecord(value: unknown): Record { + const result = camelize(value); + return result && typeof result === "object" && !Array.isArray(result) + ? (result as Record) + : {}; +} diff --git a/packages/agents-core/src/api/executions.ts b/packages/agents-core/src/api/executions.ts new file mode 100644 index 000000000..5f560f064 --- /dev/null +++ b/packages/agents-core/src/api/executions.ts @@ -0,0 +1,168 @@ +import type { ExecutionRequest } from "@near-intents-agent-api/contracts"; +import type { + balanceMoveRequestSchema, + depositRequestSchema, + QuoteResponse, + recoverRequestSchema, + StatusResponse, + swapRequestSchema, + transferRequestSchema, + withdrawRequestSchema, +} from "@near-intents-agent-api/contracts/api"; +import type { z } from "zod"; +import { operationStatus } from "../modules/intents/status.js"; +import { execute, recoverExecution } from "../modules/operations/execution-service.js"; +import { findOperation } from "../modules/operations/repository.js"; +import { queryWallet } from "../modules/wallet/query-service.js"; +import type { Actor } from "../shared/actor.js"; +import { ApiError } from "../shared/errors.js"; +import { camelRecord } from "./camel.js"; + +/** + * Public execution bodies (1Click field names) mapped onto the internal typed execution request + * that grant checks, timelocks and provider dispatch already enforce. + */ + +type Swap = z.output; +type Withdraw = z.output; +type Transfer = z.output; +type BalanceMove = z.output; +type Deposit = z.output; +type Recover = z.output; + +export function swapExecution(body: Swap, idempotencyKey: string): ExecutionRequest { + return { + action: "swap", + request: { + token_in: body.originAsset, + token_out: body.destinationAsset, + amount_in: body.amount, + ...(body.minAmountOut === undefined ? {} : { min_amount_out: body.minAmountOut }), + ...(body.confidential ? { confidential: true } : {}), + idempotencyKey, + }, + }; +} + +export function withdrawExecution(body: Withdraw, idempotencyKey: string): ExecutionRequest { + return { + action: "withdraw", + request: { + token: body.asset, + amount: body.amount, + chain: body.chain, + to: body.recipient, + ...(body.memo === undefined ? {} : { memo: body.memo }), + ...(body.confidential ? { confidential: true } : {}), + ...(body.async ? { async: true } : {}), + idempotencyKey, + }, + }; +} + +export function transferExecution(body: Transfer, idempotencyKey: string): ExecutionRequest { + return { + action: body.confidential ? "confidential_transfer" : "intents_transfer", + request: { token: body.asset, amount: body.amount, to: body.recipient, idempotencyKey }, + }; +} + +export function balanceMoveExecution( + action: "shield" | "unshield" | "confidential_deposit", + body: BalanceMove, + idempotencyKey: string, +): ExecutionRequest { + return { action, request: { token: body.asset, amount: body.amount, idempotencyKey } }; +} + +export function depositExecution(body: Deposit, idempotencyKey: string): ExecutionRequest { + return { + action: "cross_chain_deposit", + request: { + ...(body.originAsset === undefined ? {} : { source_asset: body.originAsset }), + ...(body.destinationAsset === undefined ? {} : { destination_asset: body.destinationAsset }), + amount: body.amount, + ...(body.chain === undefined ? {} : { chain: body.chain }), + ...(body.asset === undefined ? {} : { token: body.asset }), + ...(body.refundTo === undefined ? {} : { refund_address: body.refundTo }), + ...(body.confidential ? { confidential: true } : {}), + idempotencyKey, + }, + }; +} + +export function recoverExecutionRequest(body: Recover, idempotencyKey: string): ExecutionRequest { + const { type, ...request } = body.request; + switch (type) { + case "swap": + return swapExecution(request as Swap, idempotencyKey); + case "withdraw": + return withdrawExecution(request as Withdraw, idempotencyKey); + case "transfer": + return transferExecution(request as Transfer, idempotencyKey); + case "shield": + case "unshield": + case "confidential_deposit": + return balanceMoveExecution(type, request as BalanceMove, idempotencyKey); + } +} + +async function statusOf(actor: Actor, agentId: string, operationId: string) { + const operation = await findOperation(actor.tenantId, agentId, operationId); + if (!operation) throw new ApiError("operation_not_found", 404); + return operationStatus(operation); +} + +export async function runExecution( + actor: Actor, + agentId: string, + request: ExecutionRequest, + grantToken: string | undefined, +): Promise { + const operation = await execute(actor, agentId, request, { token: grantToken }); + return statusOf(actor, agentId, operation.id); +} + +export async function runRecovery( + actor: Actor, + agentId: string, + correlationId: string, + request: ExecutionRequest, + grantToken: string | undefined, +): Promise { + await recoverExecution(actor, agentId, correlationId, request, grantToken); + return statusOf(actor, agentId, correlationId); +} + +export async function swapQuote(actor: Actor, agentId: string, body: Swap): Promise { + const result = await queryWallet(actor, agentId, { + query: "swap_quote", + request: { + token_in: body.originAsset, + token_out: body.destinationAsset, + amount_in: body.amount, + ...(body.minAmountOut === undefined ? {} : { min_amount_out: body.minAmountOut }), + confidential: body.confidential, + }, + }); + return { dry: true, type: "swap", quote: camelRecord(result.data) }; +} + +export async function withdrawQuote( + actor: Actor, + agentId: string, + body: Withdraw, +): Promise { + const result = await queryWallet(actor, agentId, { + query: "withdraw_preview", + request: { + token: body.asset, + amount: body.amount, + chain: body.chain, + to: body.recipient, + ...(body.memo === undefined ? {} : { memo: body.memo }), + confidential: body.confidential, + }, + }); + return { dry: true, type: "withdraw", quote: camelRecord(result.data) }; +} diff --git a/packages/agents-core/src/api/views.ts b/packages/agents-core/src/api/views.ts new file mode 100644 index 000000000..2b65f484c --- /dev/null +++ b/packages/agents-core/src/api/views.ts @@ -0,0 +1,211 @@ +import type { + AgentDeletePreview, + ApprovalDetail, + AgentView as DomainAgentView, + BalanceEntry as DomainBalanceEntry, + BudgetView as DomainBudgetView, + AgentGrantView as DomainGrantView, + LimitsView as DomainLimitsView, + PolicyHistory as DomainPolicyHistory, + PolicyView as DomainPolicyView, + ScheduledExecution as DomainScheduledExecution, + TimelockView as DomainTimelockView, + SigningArtifactDelivery, + Wallet, +} from "@near-intents-agent-api/contracts"; +import type { + AgentView, + ApprovalView, + BalanceEntry, + BudgetView, + DeletionPreview, + GrantView, + LimitsView, + PolicyHistoryView, + PolicyView, + ScheduledPage, + SignatureDelivery, + TimelockView, + WalletView, +} from "@near-intents-agent-api/contracts/api"; +import { camelRecord } from "./camel.js"; + +/** Domain read models → public camelCase views. */ + +export function walletView(wallet: Wallet): WalletView { + return { + walletId: wallet.wallet_id, + nearAccountId: wallet.near_account_id, + evmAddress: wallet.evm_address, + }; +} + +export function agentView(agent: DomainAgentView): AgentView { + return { + id: agent.id, + name: agent.name, + externalUserId: agent.externalUserId, + status: agent.status.toUpperCase() as AgentView["status"], + archived: agent.archived ?? agent.status === "archived", + deleted: agent.deleted, + owner: agent.ownerWallet, + ownerAccount: agent.ownerNear, + wallet: agent.wallet ? walletView(agent.wallet) : null, + createdAt: agent.createdAt, + }; +} + +export function balanceEntry(entry: DomainBalanceEntry): BalanceEntry { + return entry; +} + +export function policyView(view: DomainPolicyView): PolicyView { + return { + walletId: view.wallet_id, + revision: view.revision, + policyHash: view.policy_hash, + status: view.status.toUpperCase() as PolicyView["status"], + appliedAt: view.applied_at, + transactionHash: view.transaction_hash, + providerPolicySynced: view.provider_policy_synced, + policy: view.policy, + }; +} + +export function policyHistoryView(history: DomainPolicyHistory): PolicyHistoryView { + return { + data: history.policies.map((entry) => ({ + walletId: entry.wallet_id, + revision: entry.revision, + policyHash: entry.policy_hash, + status: entry.status.toUpperCase() as PolicyView["status"], + appliedAt: entry.applied_at, + transactionHash: entry.transaction_hash, + policy: entry.policy, + })), + nextCursor: history.next_before_revision, + }; +} + +export function limitsView(view: DomainLimitsView): LimitsView { + return { + walletId: view.wallet_id, + frozen: view.frozen, + capabilities: view.capabilities, + limits: view.limits, + rateLimit: view.rate_limit, + addresses: view.addresses, + allowedTokens: view.allowed_tokens, + transactionTypes: view.transaction_types, + approval: view.approval, + policySynced: view.policy_synced, + }; +} + +export function grantView(grant: DomainGrantView): GrantView { + return { + grantId: grant.grant_id, + agentId: grant.agent_id, + walletId: grant.wallet_id, + label: grant.label, + actions: grant.actions, + recipients: grant.recipients, + signingAudiences: grant.signing_audiences, + issuedAt: grant.issued_at, + expiresAt: grant.expires_at, + revokedAt: grant.revoked_at, + revokedReason: grant.revoked_reason, + ownerEpoch: grant.owner_epoch, + ownerMessage: grant.owner_message, + }; +} + +export function timelockView(view: DomainTimelockView): TimelockView { + return { + delaySeconds: view.delay_seconds, + revision: view.revision, + scheduledCount: view.scheduled_count, + enforcedBy: view.enforced_by, + }; +} + +export function scheduledPage(page: { + scheduled: DomainScheduledExecution[]; + next_cursor: string | null; +}): ScheduledPage { + return { + data: page.scheduled.map((entry) => ({ + correlationId: entry.operation_id, + executeAfter: entry.execute_after, + state: entry.state, + action: entry.action, + })), + nextCursor: page.next_cursor, + }; +} + +export function budgetView(view: DomainBudgetView): BudgetView { + const window = (usage: DomainBudgetView["daily"]): BudgetView["daily"] => ({ + limitUsd: usage.limit_usd, + spentUsd: usage.spent_usd, + remainingUsd: usage.remaining_usd, + resetsAt: usage.resets_at, + }); + return { + daily: window(view.daily), + weekly: window(view.weekly), + monthly: window(view.monthly), + revision: view.revision, + enforcedBy: view.enforced_by, + }; +} + +export function approvalView(approval: ApprovalDetail): ApprovalView { + return { + approvalId: approval.id, + walletId: approval.wallet_id, + walletPubkey: approval.wallet_pubkey, + requestHash: approval.request_hash, + status: approval.status.toUpperCase() as ApprovalView["status"], + requestType: approval.request_type, + requestData: camelRecord(approval.request_data), + requiredApprovals: approval.required_approvals, + expiresAt: approval.expires_at, + }; +} + +export function deletionPreview(preview: AgentDeletePreview): DeletionPreview { + return { + nearAccountId: preview.near_account_id, + beneficiary: preview.beneficiary, + nativeBalance: preview.native_balance, + public: preview.public.map(balanceEntry), + confidential: preview.confidential.map(balanceEntry), + assetsLost: preview.assets_lost, + retirement: preview.retirement, + policyAllowsDelete: preview.policy_allows_delete, + }; +} + +export function signatureDelivery(delivery: SigningArtifactDelivery): SignatureDelivery { + const artifact = delivery.artifact; + return { + correlationId: delivery.operation_id, + artifact: + "near_account_id" in artifact + ? { + chain: "near", + nearAccountId: artifact.near_account_id, + publicKey: artifact.public_key, + recipient: artifact.recipient, + nonce: artifact.nonce, + signature: artifact.signature, + } + : { + chain: "evm", + evmAddress: artifact.evm_address, + signature: artifact.signature, + }, + expiresAt: delivery.expires_at, + }; +} diff --git a/packages/agents-core/src/config/env.ts b/packages/agents-core/src/config/env.ts new file mode 100644 index 000000000..d59816b39 --- /dev/null +++ b/packages/agents-core/src/config/env.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { sponsorKeysSchema } from "./sponsor-keys.js"; + +const emptyToUndefined = (value: unknown) => (value === "" ? undefined : value); +const rpcUrls = z + .string() + .default("https://near.drpc.org,https://free.rpc.fastnear.com") + .transform((value, context) => { + const urls = value.split(",").map((url) => url.trim()); + if (urls.some((url) => !z.string().url().safeParse(url).success)) { + context.addIssue({ + code: "custom", + message: "NEAR_RPC_URLS must contain comma-separated URLs", + }); + return z.NEVER; + } + return urls; + }); +const encryptionKeyId = z.string().regex(/^env:v[1-9][0-9]*$/); +const encryptionKeys = z.preprocess( + emptyToUndefined, + z + .string() + .optional() + .transform((value, context) => { + if (value === undefined) return undefined; + try { + return JSON.parse(value) as unknown; + } catch { + context.addIssue({ code: "custom", message: "SECRET_ENCRYPTION_KEYS must be JSON" }); + return z.NEVER; + } + }) + .pipe(z.record(encryptionKeyId, z.string().min(32)).optional()), +); + +export const envSchema = z + .object({ + DATABASE_URL: z.string().url(), + BETTER_AUTH_URL: z.string().url(), + BETTER_AUTH_SECRET: z.string().min(32), + TRUSTED_ORIGINS: z + .string() + .transform((v) => v.split(",").map((s) => z.string().url().parse(s.trim()))), + NODE_ENV: z.string().optional(), + LOG_LEVEL: z.preprocess( + emptyToUndefined, + z.enum(["debug", "info", "warn", "error"]).optional(), + ), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + METRICS_TOKEN: z.preprocess( + emptyToUndefined, + z.string().min(32).max(256).regex(/^\S+$/).optional(), + ), + SECRET_ENCRYPTION_KEY: z.preprocess(emptyToUndefined, z.string().min(32).optional()), + SECRET_ENCRYPTION_KEYS: encryptionKeys, + SECRET_ENCRYPTION_ACTIVE_KEY_ID: z.preprocess(emptyToUndefined, encryptionKeyId.optional()), + NEAR_RPC_URLS: rpcUrls, + BETTER_AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).default(30), + NEAR_SPONSOR_KEYS: sponsorKeysSchema, + SPONSOR_QUEUE_WAIT_MS: z.coerce.number().int().min(1).max(60_000).default(5_000), + SPONSOR_QUEUE_MAX: z.coerce.number().int().min(1).max(10_000).default(100), + // Spendable sponsor balance below this logs `sponsor_balance_low`; default 1 NEAR. + SPONSOR_BALANCE_WARN_YOCTO: z + .string() + .regex(/^(0|[1-9][0-9]{0,32})$/) + .default("1000000000000000000000000"), + NEAR_RELAYER_ALLOWED_RECEIVERS: z.string().default(""), + NEAR_POLICY_GAS: z + .string() + .regex(/^(0|[1-9][0-9]{0,23})$/) + .default("100000000000000"), + NEAR_POLICY_STORAGE_DEPOSIT_YOCTO: z + .string() + .regex(/^(0|[1-9][0-9]{0,77})$/) + .default("100000000000000000000000"), + SPONSOR_DAILY_GLOBAL_LIMIT: z.coerce.number().int().min(1).default(100), + SPONSOR_DAILY_TENANT_LIMIT: z.coerce.number().int().min(1).default(20), + SPONSOR_DAILY_AGENT_LIMIT: z.coerce.number().int().min(1).default(10), + TENANT_REQUESTS_PER_MINUTE: z.coerce.number().int().min(1).optional(), + API_KEY_REQUESTS_PER_MINUTE: z.coerce.number().int().min(1).optional(), + MAX_AGENTS_PER_TENANT: z.coerce.number().int().min(1).optional(), + // Lifetime admissions per tenant, abandoned and deleted agents included; operator-approved 200. + MAX_CREATED_AGENTS_PER_TENANT: z.preprocess( + emptyToUndefined, + z.coerce.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(200), + ), + MAX_API_KEYS_PER_TENANT: z.coerce.number().int().min(1).optional(), + // Comma-separated, in priority order. Defaults to a header Railway's edge proxy overwrites + // itself (a client cannot set it), with X-Forwarded-For only as a fallback: see createAuth. + TRUSTED_CLIENT_IP_HEADER: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + REQUIRE_EMAIL_VERIFICATION: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true"), + }) + .superRefine((config, context) => { + const keys = { ...config.SECRET_ENCRYPTION_KEYS }; + if (config.SECRET_ENCRYPTION_KEY) { + if (keys["env:v1"] && keys["env:v1"] !== config.SECRET_ENCRYPTION_KEY) + context.addIssue({ + code: "custom", + path: ["SECRET_ENCRYPTION_KEYS"], + message: "env:v1 conflicts with SECRET_ENCRYPTION_KEY", + }); + keys["env:v1"] = config.SECRET_ENCRYPTION_KEY; + } + if (!keys[config.SECRET_ENCRYPTION_ACTIVE_KEY_ID ?? "env:v1"]) + context.addIssue({ + code: "custom", + path: ["SECRET_ENCRYPTION_ACTIVE_KEY_ID"], + message: "Active encryption key must be configured", + }); + }) + .transform((config) => ({ + ...config, + // Defaults here so every consumer shares one rule: verbose locally, quieter in production. + LOG_LEVEL: config.LOG_LEVEL ?? (config.NODE_ENV === "production" ? "info" : "debug"), + })); +export type Config = z.infer; diff --git a/packages/agents-core/src/config/runtime.ts b/packages/agents-core/src/config/runtime.ts new file mode 100644 index 000000000..9ce599d63 --- /dev/null +++ b/packages/agents-core/src/config/runtime.ts @@ -0,0 +1,44 @@ +import type { OutlayerNetwork } from "@near-intents-agent-api/outlayer"; +import { requiredSlot } from "../lib/slot.js"; +import { configureLogger, type LogLevel } from "../shared/logger.js"; + +export type AppRuntime = { + trustedOrigins: readonly string[]; + metricsToken?: string; + /** Legacy env:v1 key; retained until every ciphertext using it is re-encrypted. */ + secretEncryptionKey?: string; + secretEncryptionKeys?: Record; + secretEncryptionActiveKeyId?: string; + nearRpcUrls: string[]; + network: OutlayerNetwork; + serviceUrl: string; + /** Per-24h sponsorship ceilings for wallet initialization, policy writes and relay. */ + sponsorDailyGlobalLimit: number; + sponsorDailyTenantLimit: number; + sponsorDailyAgentLimit: number; + walletPolicyStorageLimitYocto?: string; + /** Sponsor account; receives a deleted custody account's native NEAR. Deletion needs it. */ + sponsorAccountId?: string; + /** Authenticated requests per minute across all of a tenant's keys; defaults to 600. */ + tenantRequestsPerMinute?: number; + /** Authenticated requests per minute for each API key; defaults to 120. */ + keyRequestsPerMinute?: number; + /** Live agents (pending/active/archived) a tenant may hold at once; defaults to 50. */ + maxAgentsPerTenant?: number; + /** Lifetime agent creations, including abandoned/deleted records; unset disables this cap. */ + maxCreatedAgentsPerTenant?: number; + /** Developer-created API keys a tenant may have active; defaults to 50. */ + maxApiKeysPerTenant?: number; + logLevel: LogLevel; +}; + +const slot = requiredSlot("Server runtime"); + +export function configureRuntime(next: AppRuntime) { + slot.set(next); + configureLogger(next.logLevel); +} + +export function getRuntime(): AppRuntime { + return slot.get(); +} diff --git a/packages/agents-core/src/config/sponsor-keys.ts b/packages/agents-core/src/config/sponsor-keys.ts new file mode 100644 index 000000000..258151b85 --- /dev/null +++ b/packages/agents-core/src/config/sponsor-keys.ts @@ -0,0 +1,51 @@ +import { KeyPair, type KeyPairString } from "near-api-js"; +import { z } from "zod"; + +/** One balance/budget, multiple independent access-key nonces. No optional/legacy signer. */ +export const sponsorKeysSchema = z + .string() + .transform((value, context) => { + try { + return JSON.parse(value) as unknown; + } catch { + context.addIssue({ code: "custom", message: "Sponsor keys must be a JSON array" }); + return z.NEVER; + } + }) + .pipe( + z + .array( + z.strictObject({ + accountId: z + .string() + .min(2) + .max(64) + .regex(/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/) + .refine((value) => !value.endsWith(".testnet"), "Account/network mismatch"), + privateKey: z.string().startsWith("ed25519:"), + }), + ) + .min(1) + .max(32), + ) + .superRefine((keys, context) => { + if (new Set(keys.map((key) => key.accountId)).size !== 1) + context.addIssue({ code: "custom", message: "Sponsor keys must share one account" }); + const publicKeys = new Set(); + for (const [index, key] of keys.entries()) { + try { + const publicKey = KeyPair.fromString(key.privateKey as KeyPairString) + .getPublicKey() + .toString(); + if (publicKeys.has(publicKey)) + context.addIssue({ code: "custom", path: [index], message: "Duplicate sponsor key" }); + publicKeys.add(publicKey); + } catch { + context.addIssue({ + code: "custom", + path: [index, "privateKey"], + message: "Invalid sponsor key", + }); + } + } + }); diff --git a/packages/agents-core/src/index.ts b/packages/agents-core/src/index.ts new file mode 100644 index 000000000..c2c91ac7f --- /dev/null +++ b/packages/agents-core/src/index.ts @@ -0,0 +1,84 @@ +export { + balanceMoveExecution, + depositExecution, + recoverExecutionRequest, + runExecution, + runRecovery, + swapExecution, + swapQuote, + transferExecution, + withdrawExecution, + withdrawQuote, +} from "./api/executions.js"; +export * from "./config/env.js"; +export * from "./config/runtime.js"; +export * from "./config/sponsor-keys.js"; +export * from "./lib/db.js"; +export * from "./lib/outlayer.js"; +export * from "./lib/owner-policy-sponsor.js"; +export * from "./lib/prices.js"; +export * from "./lib/relayer.js"; +export * from "./lib/slot.js"; +export * from "./lib/sponsor-balance.js"; +export * from "./lib/sponsor-clients.js"; +export * from "./lib/sponsor-pool.js"; +export * from "./lib/token-catalog.js"; +export { getTokenCatalog } from "./lib/token-catalog.js"; +export * from "./lib/wallet-sponsor.js"; +export { listAgentGrants } from "./modules/agents/grant-service.js"; +export { + createAgent, + getOnboarding, + refreshOnboarding, + submitOnboarding, +} from "./modules/agents/onboarding-service.js"; +export { getAgentView, listAgents, loadAgent } from "./modules/agents/service.js"; +export { generateIntent, generateResponse } from "./modules/intents/generate.js"; +export { updateOwnerIntent } from "./modules/intents/repository.js"; +export { operationStatus, readHistory, readStatus } from "./modules/intents/status.js"; +export { submitIntent } from "./modules/intents/submit.js"; +export { readBudget } from "./modules/operations/budget-policy-service.js"; +export { commitExecutionDispatch } from "./modules/operations/execution-dispatch.js"; +export { execute, recoverExecution } from "./modules/operations/execution-service.js"; +export { + claimOperationDispatch, + createPendingOperation, + findOperation, + sweepUncommittedOperations, +} from "./modules/operations/repository.js"; +export { + acknowledgeSigningArtifact, + readSigningArtifact, + requirePrivilegedArtifactDeliveryAllowed, +} from "./modules/operations/signing-artifact-service.js"; +export { signEvmMessage, signMessage } from "./modules/operations/signing-service.js"; +export { readBudgetFor } from "./modules/operations/spend-budget.js"; +export { + listScheduledExecutions, + readTimelock, +} from "./modules/operations/timelock-policy-service.js"; +export { balanceList, walletView } from "./modules/wallet/balance-service.js"; +// Service surface for the agents plugin shell (named exports only — the module +// families collide under star re-export; the shell imports exactly these). +export { configurePolicyStorageEstimator } from "./modules/wallet/owner-policy-preparation.js"; +export { finalizePolicyOperation } from "./modules/wallet/policy-finalization.js"; +export { requireReadyPolicy } from "./modules/wallet/policy-readiness.js"; +export { readLimits, readPolicy, readPolicyHistory } from "./modules/wallet/policy-service.js"; +export { sweepUnsettledPolicies } from "./modules/wallet/policy-settlement.js"; +export type { Actor } from "./shared/actor.js"; +export * from "./shared/actor.js"; +export * from "./shared/audit-retention.js"; +export * from "./shared/commit-effect.js"; +export * from "./shared/crypto.js"; +export * from "./shared/errors.js"; +export * from "./shared/history-retention.js"; +export * from "./shared/logger.js"; +export * from "./shared/metrics.js"; +export * from "./shared/near.js"; +export * from "./shared/near-rpc.js"; +export * from "./shared/nonces.js"; +export * from "./shared/owner-message.js"; +export * from "./shared/owner-proof.js"; +export * from "./shared/secret-rotation.js"; +export * from "./shared/secrets.js"; +export * from "./shared/wallet-authorization.js"; diff --git a/packages/agents-core/src/lib/db.ts b/packages/agents-core/src/lib/db.ts new file mode 100644 index 000000000..a7dce7732 --- /dev/null +++ b/packages/agents-core/src/lib/db.ts @@ -0,0 +1,30 @@ +import type { DatabaseHandle, Db, Tx } from "@near-intents-agent-api/database"; +import { sql } from "drizzle-orm"; +import { requiredSlot } from "./slot.js"; + +type DatabaseAccess = Pick; + +const slot = requiredSlot("Database"); + +export function configureDatabase(database: DatabaseAccess) { + slot.set(database); +} + +export function getDatabase(): Db { + return slot.get().db; +} + +/** Serializes work across replicas under a Postgres advisory lock. See `DatabaseHandle`. */ +export function withAdvisoryLock(key: string, run: () => Promise): Promise { + return slot.get().withAdvisoryLock(key, run); +} + +/** + * Wall-clock database time for an authorization decision. PostgreSQL `now()` is fixed at + * transaction start, so it predates any row-lock wait; read this after the locks are held. + */ +export async function databaseClock(tx: Tx): Promise { + const [row] = (await tx.execute<{ now: Date }>(sql`select clock_timestamp() as now`)).rows; + if (!row) throw new Error("database_clock_unavailable"); + return new Date(row.now); +} diff --git a/packages/agents-core/src/lib/outlayer.ts b/packages/agents-core/src/lib/outlayer.ts new file mode 100644 index 000000000..47f89b90c --- /dev/null +++ b/packages/agents-core/src/lib/outlayer.ts @@ -0,0 +1,12 @@ +import type { OutlayerWalletClient } from "@near-intents-agent-api/outlayer"; +import { requiredSlot } from "./slot.js"; + +const slot = requiredSlot("OutLayer provider"); + +export function configureOutlayer(client: OutlayerWalletClient | undefined) { + slot.set(client); +} + +export function getOutlayer(): OutlayerWalletClient { + return slot.get(); +} diff --git a/packages/agents-core/src/lib/owner-policy-sponsor.ts b/packages/agents-core/src/lib/owner-policy-sponsor.ts new file mode 100644 index 000000000..a41bbba2f --- /dev/null +++ b/packages/agents-core/src/lib/owner-policy-sponsor.ts @@ -0,0 +1,22 @@ +import type { createOwnerPolicySponsor } from "@near-intents-agent-api/relayer"; +import { ApiError } from "../shared/errors.js"; +import { optionalSlot } from "./slot.js"; + +export type OwnerPolicySponsorClient = ReturnType; + +const slot = optionalSlot(); + +export function configureOwnerPolicySponsor(next: OwnerPolicySponsorClient | undefined) { + slot.set(next); +} + +export function getOwnerPolicySponsor() { + return slot.get(); +} + +/** A NEAR-owner policy write needs the sponsor key; the wallet sponsor cannot relay it. */ +export function requireOwnerPolicySponsor(): OwnerPolicySponsorClient { + const sponsor = slot.get(); + if (!sponsor) throw new ApiError("wallet_sponsor_disabled", 503); + return sponsor; +} diff --git a/packages/agents-core/src/lib/prices.ts b/packages/agents-core/src/lib/prices.ts new file mode 100644 index 000000000..bc9e1940e --- /dev/null +++ b/packages/agents-core/src/lib/prices.ts @@ -0,0 +1,231 @@ +import { z } from "zod"; + +/** + * USD prices for the owner's spend budget. OutLayer's token catalog carries no prices, so they come + * from the public 1Click token list (`assetId`, `decimals`, `price`), which is keyed by the same + * NEAR Intents asset ids the agent's executions name. An asset with no fresh price is refused, not + * guessed: a budget that silently counts an unpriced asset as free is no budget. + */ + +export type AssetPrice = { + /** Token decimals, to turn an atomic amount into whole tokens. */ + decimals: number; + /** USD per whole token, exactly `coefficient / 10^scale`. */ + coefficient: bigint; + scale: number; + /** When the feed last updated this price, in epoch milliseconds. */ + updatedAtMs: number; +}; + +export type PriceSource = { + /** `undefined` when the asset is unknown or its price is missing or stale. */ + price(asset: string): Promise; +}; + +const catalogUrl = "https://1click.chaindefuser.com/v0/tokens"; +const cacheTtlMs = 60_000; +/** + * A price this old or older, by the feed's own timestamp, is unavailable everywhere: in every read + * that shows one and in the budget. The feed updates live assets within a minute, so an older + * quote means 1Click stopped pricing the asset, and no refetch can make it current. + */ +export const maxPriceAgeMs = 10 * 60_000; +/** Clock disagreement tolerated between the feed's timestamps and ours. */ +const maxPriceFutureSkewMs = 60_000; +const fetchTimeoutMs = 5_000; + +/** Each item is validated alone: one malformed entry leaves its asset unpriced, not the list. */ +const catalogItemSchema = z.looseObject({ + assetId: z.string().min(1), + symbol: z.string().min(1).max(64).optional(), + decimals: z.number().int().nonnegative().max(36), + blockchain: z.string().optional(), + contractAddress: z.string().optional(), + price: z.unknown(), + priceUpdatedAt: z.string().nullish(), +}); + +const maxPriceDigits = 128; +const maxPriceScale = 160; +const maxPriceExponent = 100; +/** A whole token above this is a corrupt feed, not a price. */ +const maxPriceUsd = 1_000_000_000n; +const maxAmountDigits = 78; + +/** + * Parses a positive decimal such as `4.5` or `1.49e-12` exactly, with no binary-float arithmetic. + * Undefined for anything that is not a bounded positive price. + */ +export function parseDecimalPrice( + text: string, +): Pick | undefined { + const match = /^(0|[1-9][0-9]*)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]{1,3}))?$/.exec(text); + if (!match) return undefined; + const fraction = match[2] ?? ""; + const digits = `${match[1]}${fraction}`; + const exponent = Number(match[3] ?? "0"); + if (digits.length > maxPriceDigits || Math.abs(exponent) > maxPriceExponent) return undefined; + let coefficient = BigInt(digits); + let scale = fraction.length - exponent; + if (scale > maxPriceScale) return undefined; + if (scale < 0) { + coefficient *= 10n ** BigInt(-scale); + scale = 0; + } + if (coefficient === 0n || coefficient >= maxPriceUsd * 10n ** BigInt(scale)) return undefined; + while (scale > 0 && coefficient % 10n === 0n) { + coefficient /= 10n; + scale -= 1; + } + return { coefficient, scale }; +} + +/** + * A price is usable from the feed's own timestamp, not from when we happened to fetch the list: + * a quote already past `maxPriceAgeMs` is refused however recently it was cached, and one dated + * ahead of our clock by more than a small skew is a bad timestamp rather than a fresh price. + */ +export function isPriceFresh(price: AssetPrice, nowMs: number): boolean { + return ( + price.updatedAtMs <= nowMs + maxPriceFutureSkewMs && nowMs - price.updatedAtMs < maxPriceAgeMs + ); +} + +type CatalogItem = z.infer; + +/** One 1Click catalog entry: its market metadata and, when usable, its exact price. */ +export type MarketItem = { + assetId: string; + symbol: string | null; + decimals: number; + blockchain: string | null; + /** + * The feed's price as published, for display; budget math uses `price`. Neither is usable + * until `usablePrice` accepts the entry. + */ + priceUsd: number | null; + priceUpdatedAt: string | null; + price: AssetPrice | undefined; +}; + +/** The 1Click catalog keyed by asset id, and by bare contract id for NEAR-native tokens. */ +export type MarketFeed = { + items(): Promise | undefined>; +}; + +/** A numeric token constructed only by our JSON reviver, never a quoted or object feed price. */ +class PriceNumber { + constructor(readonly source: string) {} +} + +function parseCatalogBody(text: string): unknown { + return JSON.parse(text, (key: string, value: unknown, context?: { source?: string }): unknown => { + if (key !== "price" || typeof value !== "number") return value; + // Node >=24 exposes the original numeric lexeme. Missing support fails closed; + // String(value) would already have lost any source digits rounded by JSON.parse. + return typeof context?.source === "string" ? new PriceNumber(context.source) : null; + }); +} + +/** The price of one catalog item, or undefined when it is missing, undated or absurd. */ +function priceOf(item: CatalogItem): AssetPrice | undefined { + const updatedAtMs = item.priceUpdatedAt ? Date.parse(item.priceUpdatedAt) : Number.NaN; + if (!Number.isFinite(updatedAtMs) || !(item.price instanceof PriceNumber)) return undefined; + const decimal = parseDecimalPrice(item.price.source); + return decimal ? { decimals: item.decimals, ...decimal, updatedAtMs } : undefined; +} + +function marketItem(item: CatalogItem): MarketItem { + const price = priceOf(item); + return { + assetId: item.assetId, + symbol: item.symbol ?? null, + decimals: item.decimals, + blockchain: item.blockchain ?? null, + priceUsd: price && item.price instanceof PriceNumber ? Number(item.price.source) : null, + priceUpdatedAt: price ? (item.priceUpdatedAt ?? null) : null, + price, + }; +} + +function indexCatalog(items: CatalogItem[]): Map { + const byAsset = new Map(); + for (const item of items) { + const entry = marketItem(item); + byAsset.set(item.assetId, entry); + // A NEAR-native token is also named by its bare contract id in OutLayer's catalog. + if (item.blockchain === "near" && item.contractAddress) + byAsset.set(item.contractAddress, entry); + } + return byAsset; +} + +/** Reads the 1Click catalog with a short cache. A failed refresh keeps no stale entry alive. */ +export function oneClickFeed( + options: { url?: string; fetch?: typeof fetch; now?: () => number } = {}, +): MarketFeed { + const now = options.now ?? Date.now; + const request = options.fetch ?? fetch; + let cached: { fetchedAt: number; byAsset: Map } | undefined; + let inflight: Promise | undefined; + + async function load(): Promise { + try { + const response = await request(options.url ?? catalogUrl, { + signal: AbortSignal.timeout(fetchTimeoutMs), + }); + if (!response.ok) return undefined; + const body: unknown = parseCatalogBody(await response.text()); + if (!Array.isArray(body)) return undefined; + const items = body.flatMap((raw) => { + const parsed = catalogItemSchema.safeParse(raw); + return parsed.success ? [parsed.data] : []; + }); + return { fetchedAt: now(), byAsset: indexCatalog(items) }; + } catch { + return undefined; + } + } + + return { + async items() { + if (cached && now() - cached.fetchedAt < cacheTtlMs) return cached.byAsset; + inflight ??= load().finally(() => { + inflight = undefined; + }); + cached = await inflight; + return cached?.byAsset; + }, + }; +} + +/** + * The one rule for a usable USD price, shared by every read that shows a price and by the budget. + * The quote must be fresh, and 1Click must count the asset in the same decimals as custody: + * one symbol spans chains with different decimals (USDC is 6 on most, 18 on BSC), so a quote in + * other units would misvalue an amount by orders of magnitude. No custody token, no price. + */ +export function usablePrice( + item: MarketItem | undefined, + custodyDecimals: number | undefined, + nowMs: number, +): AssetPrice | undefined { + const price = item?.price; + if (!price || price.decimals !== custodyDecimals) return undefined; + return isPriceFresh(price, nowMs) ? price : undefined; +} + +/** + * USD value of an atomic amount in millionths of a dollar: `amount / 10^decimals * price`, with a + * single rounding up at the end so a cap is never under-counted. Throws `usd_value_out_of_range` + * when the value exceeds what a cap can hold, which no cap can admit. + */ +export function usdMicros(amount: string, price: AssetPrice): number { + if (!new RegExp(`^[0-9]{1,${maxAmountDigits}}$`).test(amount)) + throw new RangeError("invalid_amount"); + const numerator = BigInt(amount) * price.coefficient * 1_000_000n; + const denominator = 10n ** BigInt(price.decimals + price.scale); + const value = (numerator + denominator - 1n) / denominator; + if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError("usd_value_out_of_range"); + return Number(value); +} diff --git a/packages/agents-core/src/lib/relayer.ts b/packages/agents-core/src/lib/relayer.ts new file mode 100644 index 000000000..53ecb6a8c --- /dev/null +++ b/packages/agents-core/src/lib/relayer.ts @@ -0,0 +1,34 @@ +import type { RelayInput } from "@near-intents-agent-api/relayer/schema"; +import { ApiError } from "../shared/errors.js"; +import { optionalSlot } from "./slot.js"; +import { withSponsorKey } from "./sponsor-pool.js"; + +/** + * `beforeBroadcast` is mandatory: it runs after signing and before broadcast and is the final + * dispatch fence and sponsor-budget commit. A relayer that resolves without invoking it is treated + * as possibly broadcast. + */ +export type Relayer = ( + input: RelayInput, + ownerPublicKey: string, + beforeBroadcast: (transactionHash: string) => Promise, +) => Promise; + +const slot = optionalSlot(); + +export function configureRelayer(next: Relayer | undefined) { + slot.set(next); +} + +/** Keep the final dispatch fence inside the sponsor lock and before broadcast. */ +export function withSponsorLock(relayer: Relayer, sponsorAccountId: string): Relayer { + return (input, ownerPublicKey, beforeBroadcast) => + withSponsorKey(sponsorAccountId, () => relayer(input, ownerPublicKey, beforeBroadcast)); +} + +/** Relay is opt-in: without a configured sponsor key the route has nothing to submit with. */ +export function requireRelayer(): Relayer { + const relayer = slot.get(); + if (!relayer) throw new ApiError("relay_disabled", 503); + return relayer; +} diff --git a/packages/agents-core/src/lib/slot.ts b/packages/agents-core/src/lib/slot.ts new file mode 100644 index 000000000..65bb326f8 --- /dev/null +++ b/packages/agents-core/src/lib/slot.ts @@ -0,0 +1,59 @@ +/** + * Module-scope dependency slot. + * + * The server wires its infrastructure once at startup (`index.ts`) and reads it from anywhere in a + * request. These factories are that mechanism, so each `lib/*` module does not hand-roll its own + * mutable binding. `set` always accepts `undefined` so a caller can clear a slot; what differs is + * what `get` does about it. + */ + +export type Slot = { + set(next: T | undefined): void; + get(): T; +}; + +/** Fails closed when startup did not configure it. Used for dependencies with no safe default. */ +export function requiredSlot(name: string): Slot { + let value: T | undefined; + return { + set(next) { + value = next; + }, + get() { + if (value === undefined) throw new Error(`${name} is not configured`); + return value; + }, + }; +} + +/** A capability a deployment may legitimately leave off; absence is represented, not an error. */ +export function optionalSlot(): { + set(next: T | undefined): void; + get(): T | undefined; +} { + let value: T | undefined; + return { + set(next) { + value = next; + }, + get() { + return value; + }, + }; +} + +/** A slot with a production default that a test may swap. `undefined` restores the default. */ +export function defaultedSlot(fallback: T): { + set(next: T | undefined): void; + get(): T; +} { + let value: T | undefined; + return { + set(next) { + value = next; + }, + get() { + return value ?? fallback; + }, + }; +} diff --git a/packages/agents-core/src/lib/sponsor-balance.ts b/packages/agents-core/src/lib/sponsor-balance.ts new file mode 100644 index 000000000..d03674c51 --- /dev/null +++ b/packages/agents-core/src/lib/sponsor-balance.ts @@ -0,0 +1,55 @@ +import { + assertSponsorBalance, + observeSponsorBalance, + type SponsorBalanceObservation, +} from "@near-intents-agent-api/relayer"; +import type { Provider } from "near-api-js"; +import { logger } from "../shared/logger.js"; +import { recordCounter } from "../shared/metrics.js"; + +/** At most one low-balance warning per interval; every sponsored write reads the balance. */ +const warnIntervalMs = 60_000; + +let latest: (SponsorBalanceObservation & { observedAt: string }) | undefined; +let lastWarnedAt = 0; + +/** + * Exports every sponsor balance read and warns while spendable balance is below `warnYocto`. + * Transactions the sponsor cannot pay for are already refused before broadcast; this is the + * operator's signal to top up before that happens. + */ +export function watchSponsorBalance(warnYocto: bigint) { + observeSponsorBalance((observation) => { + latest = { ...observation, observedAt: new Date().toISOString() }; + if (observation.availableYocto >= warnYocto) return; + recordCounter("sponsor_balance_low"); + if (Date.now() - lastWarnedAt < warnIntervalMs) return; + lastWarnedAt = Date.now(); + logger.warn("sponsor_balance_low", { + sponsor_account_id: observation.accountId, + available_yocto: observation.availableYocto.toString(), + warn_yocto: warnYocto.toString(), + }); + }); +} + +/** Startup read so `/metrics` and the warning reflect the balance before the first write. */ +export async function readSponsorBalance( + provider: Pick, + accountId: string, +) { + // Observation only: an empty or unreachable sponsor must not stop the API from starting. + await assertSponsorBalance(provider, accountId, { depositYocto: 0n, gas: 0n }).catch( + () => undefined, + ); +} + +export function sponsorBalanceMetrics() { + if (!latest) return undefined; + return { + account_id: latest.accountId, + available_yocto: latest.availableYocto.toString(), + last_required_yocto: latest.requiredYocto.toString(), + observed_at: latest.observedAt, + }; +} diff --git a/packages/agents-core/src/lib/sponsor-clients.ts b/packages/agents-core/src/lib/sponsor-clients.ts new file mode 100644 index 000000000..5229b1099 --- /dev/null +++ b/packages/agents-core/src/lib/sponsor-clients.ts @@ -0,0 +1,123 @@ +import type { TryAdvisoryLock } from "@near-intents-agent-api/database"; +import { outlayerNetworks } from "@near-intents-agent-api/outlayer"; +import { + createDeterministicWalletInitializer, + createNearRelayer, + createNearRpcProvider, + createOwnerPolicySponsor, + createSignedWalletRelayer, + createWalletPolicyFunder, +} from "@near-intents-agent-api/relayer"; +import { JsonRpcProvider, KeyPair, type KeyPairString } from "near-api-js"; +import type { Config } from "../config/env.js"; +import { configureOwnerPolicySponsor } from "./owner-policy-sponsor.js"; +import { configureRelayer, withSponsorLock } from "./relayer.js"; +import { readSponsorBalance, watchSponsorBalance } from "./sponsor-balance.js"; +import { configureSponsorPool, SponsorPool, sponsorIdentity } from "./sponsor-pool.js"; +import { configureWalletSponsor } from "./wallet-sponsor.js"; + +/** Validate every registered FullAccess key before accepting requests. Never log private keys. */ +export async function configureSponsorClients(config: Config, lock: TryAdvisoryLock) { + const accountId = config.NEAR_SPONSOR_KEYS[0]?.accountId; + if (!accountId) throw new Error("sponsor_pool_keys_required"); + const secrets = config.NEAR_SPONSOR_KEYS.map((key) => key.privateKey); + const keys = await verifiedKeys(accountId, secrets, config.NEAR_RPC_URLS); + watchSponsorBalance(BigInt(config.SPONSOR_BALANCE_WARN_YOCTO)); + await readSponsorBalance(createNearRpcProvider(config.NEAR_RPC_URLS), accountId); + const entries = keys.map((key) => { + const signer = { + accountId, + privateKey: key.privateKey, + rpcUrls: config.NEAR_RPC_URLS, + beforeBroadcast: async () => { + sponsorIdentity(); + }, + }; + return { + publicKey: key.publicKey, + value: { + policy: createOwnerPolicySponsor({ + ...signer, + receiverId: outlayerNetworks.mainnet.contractId, + gas: config.NEAR_POLICY_GAS, + storageDepositYocto: config.NEAR_POLICY_STORAGE_DEPOSIT_YOCTO, + }), + fund: createWalletPolicyFunder(signer), + initialize: createDeterministicWalletInitializer(signer), + signed: createSignedWalletRelayer(signer), + relay: createNearRelayer({ + ...signer, + allowedReceivers: config.NEAR_RELAYER_ALLOWED_RECEIVERS.split(",").map((value) => + value.trim(), + ), + }), + }, + }; + }); + const pool = new SponsorPool( + accountId, + entries, + lock, + config.SPONSOR_QUEUE_WAIT_MS, + config.SPONSOR_QUEUE_MAX, + ); + configureSponsorPool(pool); + const first = entries[0]; + if (!first) throw new Error("sponsor_pool_keys_invalid"); + configureOwnerPolicySponsor({ + ...first.value.policy, + submit: (...args) => pool.current().policy.submit(...args), + }); + configureWalletSponsor({ + accountId, + initialize: (...args) => pool.current().initialize(...args), + fundPolicyStorage: (...args) => pool.current().fund(...args), + relaySigned: Object.assign( + (...args: Parameters) => pool.current().signed(...args), + { status: first.value.signed.status, assertBalance: first.value.signed.assertBalance }, + ), + }); + configureRelayer( + config.NEAR_RELAYER_ALLOWED_RECEIVERS.trim() + ? withSponsorLock((...args) => pool.current().relay(...args), accountId) + : undefined, + ); +} + +async function verifiedKeys(accountId: string, secrets: string[], rpcUrls: string[]) { + const keys = secrets.map((secret) => { + try { + const key = KeyPair.fromString(secret as KeyPairString); + return { publicKey: key.getPublicKey().toString(), privateKey: secret as KeyPairString }; + } catch { + throw new Error("sponsor_pool_key_invalid"); + } + }); + if (new Set(keys.map((key) => key.publicKey)).size !== keys.length) + throw new Error("sponsor_pool_duplicate_key"); + for (const key of keys) await verifyAccessKey(accountId, key.publicKey, rpcUrls); + return keys; +} + +async function verifyAccessKey(accountId: string, publicKey: string, rpcUrls: string[]) { + for (const url of rpcUrls) { + try { + const access = await new JsonRpcProvider({ url }).viewAccessKey({ + accountId, + publicKey, + finalityQuery: { finality: "final" }, + }); + if (access.permission !== "FullAccess") throw new Error("sponsor_pool_key_restricted"); + return; + } catch (error) { + if (error instanceof Error && error.message === "sponsor_pool_key_restricted") throw error; + } + } + // An implicit sponsor account does not exist on chain until its first transfer lands, so an + // unfunded key surfaces here as "unavailable". Point at the README flow instead of failing + // opaquely; never include private key material. + throw new Error( + `sponsor key ${publicKey} is not registered on ${accountId}: fund the account via ` + + "https://near.com (see README#sponsor-account) or regenerate with pnpm sponsor:generate", + ); +} diff --git a/packages/agents-core/src/lib/sponsor-pool.ts b/packages/agents-core/src/lib/sponsor-pool.ts new file mode 100644 index 000000000..6203a4e8f --- /dev/null +++ b/packages/agents-core/src/lib/sponsor-pool.ts @@ -0,0 +1,142 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomInt } from "node:crypto"; +import type { TryAdvisoryLock } from "@near-intents-agent-api/database"; +import { ApiError } from "../shared/errors.js"; +import { withAdvisoryLock } from "./db.js"; + +export type SponsorIdentity = { sponsor_account_id: string; sponsor_public_key: string }; +const identity = new AsyncLocalStorage<{ identity: SponsorIdentity; assertHeld: () => void }>(); +export const sponsorIdentity = () => { + const lease = identity.getStore(); + lease?.assertHeld(); + return lease?.identity; +}; + +/** Admission has a deadline; once admitted, signing/finality has no queue timeout. */ +export class SponsorPool { + private readonly context = new AsyncLocalStorage<{ value: T; assertHeld: () => void }>(); + private readonly active = new Set(); + private cursor: number; + private readonly nextProbe = new Map(); + private waiting = 0; + private rejected = 0; + private waits = 0; + private totalWaitMs = 0; + private maxWaitMs = 0; + + constructor( + readonly accountId: string, + private readonly keys: { publicKey: string; value: T }[], + private readonly lock: TryAdvisoryLock, + private readonly waitMs: number, + private readonly maxQueue: number, + ) { + if (!keys.length || new Set(keys.map((key) => key.publicKey)).size !== keys.length) + throw new Error("sponsor_pool_keys_invalid"); + if (!Number.isInteger(waitMs) || waitMs < 1 || !Number.isInteger(maxQueue) || maxQueue < 1) + throw new Error("sponsor_pool_limits_invalid"); + this.cursor = randomInt(keys.length); + } + + current(): T { + const value = this.context.getStore(); + if (!value) throw new Error("sponsor_key_lease_required"); + value.assertHeld(); + return value.value; + } + + snapshot() { + return { + size: this.keys.length, + queue_depth: this.waiting, + active: this.active.size, + rejected: this.rejected, + lock_wait_count: this.waits, + lock_wait_total_ms: this.totalWaitMs, + lock_wait_max_ms: this.maxWaitMs, + }; + } + + private canProbe(publicKey: string) { + return !this.active.has(publicKey) && (this.nextProbe.get(publicKey) ?? 0) <= Date.now(); + } + + async run(run: () => Promise): Promise { + const current = this.context.getStore(); + if (current) { + current.assertHeld(); + return run(); + } + if (this.waiting >= this.maxQueue) { + this.rejected++; + throw new ApiError("sponsor_busy", 503); + } + const started = Date.now(); + const deadline = started + this.waitMs; + this.waiting++; + let queued = true; + try { + while (Date.now() < deadline) { + const offset = this.cursor++ % this.keys.length; + for (let index = 0; index < this.keys.length; index++) { + const key = this.keys[(offset + index) % this.keys.length]; + if (!key || !this.canProbe(key.publicKey)) continue; + this.active.add(key.publicKey); + try { + const result = await this.lock( + `sponsor:${this.accountId}:${key.publicKey}`, + deadline, + async (assertHeld = () => {}) => { + // Also fence a lock adapter that finishes acquisition after admission expired. + if (Date.now() >= deadline) { + this.rejected++; + throw new ApiError("sponsor_busy", 503); + } + this.waiting--; + queued = false; + const waited = Date.now() - started; + this.waits++; + this.totalWaitMs += waited; + this.maxWaitMs = Math.max(this.maxWaitMs, waited); + return identity.run( + { + identity: { + sponsor_account_id: this.accountId, + sponsor_public_key: key.publicKey, + }, + assertHeld, + }, + () => this.context.run({ value: key.value, assertHeld }, run), + ); + }, + ); + if (result.acquired) return result.value; + this.nextProbe.set(key.publicKey, Date.now() + 25); + } finally { + this.active.delete(key.publicKey); + } + } + await new Promise((resolve) => + setTimeout(resolve, Math.min(25, Math.max(0, deadline - Date.now()))), + ); + } + this.rejected++; + throw new ApiError("sponsor_busy", 503); + } finally { + if (queued) this.waiting--; + } + } +} + +let configured: SponsorPool | undefined; +export function configureSponsorPool(pool: SponsorPool | undefined) { + configured = pool; +} +export function sponsorPoolMetrics() { + return configured?.snapshot() ?? { size: 0, queue_depth: 0, active: 0 }; +} +export function withSponsorKey(accountId: string, run: () => Promise): Promise { + if (!configured) return withAdvisoryLock(accountId, run); + if (configured.accountId !== accountId) throw new Error("sponsor_account_mismatch"); + return configured.run(run); +} diff --git a/packages/agents-core/src/lib/token-catalog.ts b/packages/agents-core/src/lib/token-catalog.ts new file mode 100644 index 000000000..44cacf00e --- /dev/null +++ b/packages/agents-core/src/lib/token-catalog.ts @@ -0,0 +1,186 @@ +import type { OutlayerWalletClient } from "@near-intents-agent-api/outlayer"; +import { logger } from "../shared/logger.js"; +import { getOutlayer } from "./outlayer.js"; +import { + type AssetPrice, + type MarketFeed, + type MarketItem, + maxPriceAgeMs, + oneClickFeed, + type PriceSource, + usablePrice, +} from "./prices.js"; +import { defaultedSlot } from "./slot.js"; + +/** + * The tokens agents can use: OutLayer's catalog, and nothing more, enriched with 1Click market + * data (chain, USD price). + * + * OutLayer is the allow-list because it is what custody can actually route; 1Click lists the + * same NEAR Intents assets with prices, but anything it has that OutLayer lacks would be refused + * on execution, so it never reaches the list. Custody's decimals are authoritative, and a price is + * shown or charged only through `usablePrice`, so the list, balances and the budget never + * disagree about an asset's value. OutLayer's `/wallet/v1/tokens` needs a wallet + * credential although the catalog is global, so the API registers one throwaway wallet per + * process, on first use, and reads the catalog with it. + */ + +const providerTtlMs = 30 * 60_000; + +/** One asset as the public list shows it; the shape follows the 1Click token list. */ +export type CatalogToken = { + /** NEAR Intents asset id (`nep141:…`, `1cs_v1:…`): the value every execution takes. */ + assetId: string; + symbol: string; + decimals: number; + /** Chain the asset originates on, in 1Click's naming (`near`, `eth`, `sol`, …). */ + blockchain: string; + /** USD per whole token from the 1Click feed; null when it has no usable price. */ + price: number | null; + /** When 1Click last updated the quote, even one too old to use. */ + priceUpdatedAt: string | null; + /** When `price` stops being usable; null with no price. Never show it after this. */ + priceExpiresAt: string | null; +}; + +export type TokenCatalog = PriceSource & { + list(): Promise; + /** Whether custody supports this asset, by asset id or bare contract id. */ + supports(asset: string): Promise; +}; + +type ProviderToken = Awaited>["tokens"][number]; + +/** OutLayer names assets by bare contract; its `defuse_asset_id` is the NEAR Intents id. */ +function assetIdOf(token: ProviderToken): string { + return token.defuse_asset_id ?? `nep141:${token.id}`; +} + +export function providerTokenCatalog(options: { + outlayer: () => OutlayerWalletClient; + market: MarketFeed; + now?: () => number; +}): TokenCatalog { + const now = options.now ?? Date.now; + let credential: Promise | undefined; + let cached: + | { fetchedAt: number; tokens: ProviderToken[]; byId: Map } + | undefined; + const mismatched = new Set(); + let inflight: Promise> | undefined; + + async function load() { + credential ??= options + .outlayer() + .registerWallet() + .then((wallet) => wallet.api_key); + // A failed registration is retried on the next read rather than cached forever. + credential.catch(() => { + credential = undefined; + }); + const { tokens } = await options.outlayer().tokens(await credential); + const byId = new Map( + tokens.flatMap((token) => [ + [assetIdOf(token), token], + [token.id, token], + ]), + ); + return { fetchedAt: now(), tokens, byId }; + } + + /** The provider list; a failed refresh keeps serving the last good one. */ + async function provider() { + if (cached && now() - cached.fetchedAt < providerTtlMs) return cached; + inflight ??= load().finally(() => { + inflight = undefined; + }); + try { + cached = await inflight; + } catch (error) { + if (!cached) throw error; + } + return cached; + } + + /** The usable price of one custody token, reporting a unit disagreement once per asset. */ + function priceOf(token: ProviderToken, item: MarketItem | undefined): AssetPrice | undefined { + if (item?.price && item.price.decimals !== token.decimals && !mismatched.has(token.id)) { + mismatched.add(token.id); + logger.warn("price_decimals_mismatch", { + asset: assetIdOf(token), + custody_decimals: token.decimals, + feed_decimals: item.price.decimals, + }); + } + return usablePrice(item, token.decimals, now()); + } + + const lookup = (byId: Map, asset: string) => + byId.get(asset) ?? byId.get(asset.replace(/^nep141:/, "")); + + return { + async list() { + const [{ tokens }, market] = await Promise.all([provider(), options.market.items()]); + return tokens.map((token) => { + const assetId = assetIdOf(token); + const item = market?.get(assetId); + const price = priceOf(token, item); + return { + assetId, + symbol: token.symbol, + decimals: token.decimals, + blockchain: item?.blockchain ?? token.chains[0] ?? "near", + price: price ? (item?.priceUsd ?? null) : null, + priceUpdatedAt: item?.priceUpdatedAt ?? null, + priceExpiresAt: price ? new Date(price.updatedAtMs + maxPriceAgeMs).toISOString() : null, + }; + }); + }, + async supports(asset) { + return lookup((await provider()).byId, asset) !== undefined; + }, + /** Budget pricing. An unsupported asset, or one whose support cannot be checked, has none. */ + async price(asset) { + const [token, market] = await Promise.all([ + provider().then( + ({ byId }) => lookup(byId, asset), + () => undefined, + ), + options.market.items(), + ]); + if (!token) return undefined; + return priceOf(token, market?.get(assetIdOf(token))); + }, + }; +} + +/** The process-wide 1Click feed, shared by the token list and budget pricing. */ +const market = oneClickFeed(); + +const catalogSlot = defaultedSlot( + providerTokenCatalog({ outlayer: getOutlayer, market }), +); + +export function configureTokenCatalog(catalog: TokenCatalog | undefined) { + catalogSlot.set(catalog); +} + +export function getTokenCatalog(): TokenCatalog { + return catalogSlot.get(); +} + +/** + * Budget pricing: the catalog's own usable price, so the budget charges exactly what the token + * list shows. Tests may swap it. + */ +const pricesSlot = defaultedSlot({ + price: (asset) => getTokenCatalog().price(asset), +}); + +export function configurePrices(source: PriceSource | undefined) { + pricesSlot.set(source); +} + +export function getPrices(): PriceSource { + return pricesSlot.get(); +} diff --git a/packages/agents-core/src/lib/wallet-sponsor.ts b/packages/agents-core/src/lib/wallet-sponsor.ts new file mode 100644 index 000000000..460e6cf5c --- /dev/null +++ b/packages/agents-core/src/lib/wallet-sponsor.ts @@ -0,0 +1,31 @@ +import type { + createDeterministicWalletInitializer, + createSignedWalletRelayer, + createWalletPolicyFunder, +} from "@near-intents-agent-api/relayer"; +import { ApiError } from "../shared/errors.js"; +import { optionalSlot } from "./slot.js"; + +export type WalletSponsorClient = { + accountId: string; + initialize: ReturnType; + relaySigned: ReturnType; + fundPolicyStorage: ReturnType; +}; + +const slot = optionalSlot(); + +export function configureWalletSponsor(next: WalletSponsorClient | undefined) { + slot.set(next); +} + +export function getWalletSponsor() { + return slot.get(); +} + +/** A deployment without a sponsor key cannot fund or relay an owner wallet write. */ +export function requireWalletSponsor(): WalletSponsorClient { + const sponsor = slot.get(); + if (!sponsor) throw new ApiError("wallet_sponsor_disabled", 503); + return sponsor; +} diff --git a/packages/agents-core/src/modules/agents/containment-service.ts b/packages/agents-core/src/modules/agents/containment-service.ts new file mode 100644 index 000000000..0447b3b81 --- /dev/null +++ b/packages/agents-core/src/modules/agents/containment-service.ts @@ -0,0 +1,170 @@ +import { + agentGrantMessageSchema, + executionRequestSchema, + idSchema, +} from "@near-intents-agent-api/contracts"; +import { agentGrants, operations } from "@near-intents-agent-api/database"; +import { and, desc, eq, inArray, lt } from "drizzle-orm"; +import { z } from "zod"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { findCustodyWallet } from "../wallet/repository.js"; +import { findAgent } from "./repository.js"; + +const inventoryLimit = 100; + +export const containmentStatusQuerySchema = z.strictObject({ + grants_after: idSchema.optional(), + operations_after: idSchema.optional(), +}); + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function operationDisposition(amountUnknown: boolean | null) { + if (amountUnknown) return "effect_amount_unverifiable_reconcile"; + return "provider_reconciliation_required"; +} + +function grantInventoryEntry( + grant: typeof agentGrants.$inferSelect, + currentOwnerEpoch: number, + now: number, +) { + const findings: string[] = []; + const signedMessage = agentGrantMessageSchema.safeParse(grant.ownerMessage); + const grantRecordCurrent = + grant.revokedAt === null && + grant.expiresAt.getTime() > now && + grant.ownerEpoch === currentOwnerEpoch && + signedMessage.success && + signedMessage.data.owner_epoch === grant.ownerEpoch; + + if (grant.revokedAt) findings.push("revoked"); + if (grant.expiresAt.getTime() <= now) findings.push("expired"); + if (grant.ownerEpoch !== currentOwnerEpoch) findings.push("owner_epoch_stale"); + if (!signedMessage.success || signedMessage.data.owner_epoch !== grant.ownerEpoch) + findings.push("owner_message_unverifiable"); + if (grant.actions.includes("*")) findings.push("wildcard_actions"); + if (grant.actions.some((action) => ["sign:near_message", "sign:evm_message"].includes(action))) + findings.push("identity_signing_capability"); + if ( + grant.actions.some( + (action) => + action.startsWith("sign:") && !["sign:near_message", "sign:evm_message"].includes(action), + ) + ) + findings.push("unsupported_signing_capability"); + + return { + grant_id: grant.id, + label: grant.label, + actions: grant.actions, + owner_epoch: grant.ownerEpoch, + issued_at: grant.issuedAt.toISOString(), + expires_at: grant.expiresAt.toISOString(), + revoked_at: grant.revokedAt?.toISOString() ?? null, + grant_record_current: grantRecordCurrent, + findings, + }; +} + +function operationInventoryEntry(operation: typeof operations.$inferSelect) { + const result = isRecord(operation.result) ? operation.result : {}; + const request = isRecord(result.request) ? result.request : null; + const requestAction = request && typeof request.action === "string" ? request.action : null; + const resultAction = typeof result.action === "string" ? result.action : null; + const action = requestAction ?? resultAction; + const parsedRequest = request ? executionRequestSchema.safeParse(request) : null; + const amountUnknown = parsedRequest?.success ? true : null; + const disposition = operationDisposition(amountUnknown); + + return { + operation_id: operation.id, + kind: operation.kind, + status: operation.status, + action, + disposition, + created_at: operation.createdAt.toISOString(), + updated_at: operation.updatedAt.toISOString(), + }; +} + +/** Read-only, tenant-scoped snapshot of API containment and existing authority candidates. */ +export async function containmentStatus( + actor: Actor, + agentId: string, + after: z.infer = {}, +) { + const agent = await findAgent(actor.tenantId, agentId); + if (!agent) throw new ApiError("agent_not_found", 404); + + const [wallet, grantRows, operationRows] = await Promise.all([ + findCustodyWallet(actor.tenantId, agentId), + getDatabase() + .select() + .from(agentGrants) + .where( + and( + eq(agentGrants.tenantId, actor.tenantId), + eq(agentGrants.agentId, agentId), + after.grants_after ? lt(agentGrants.id, after.grants_after) : undefined, + ), + ) + .orderBy(desc(agentGrants.id)) + .limit(inventoryLimit + 1), + getDatabase() + .select() + .from(operations) + .where( + and( + eq(operations.tenantId, actor.tenantId), + eq(operations.agentId, agentId), + inArray(operations.status, ["pending", "uncertain"]), + after.operations_after ? lt(operations.id, after.operations_after) : undefined, + ), + ) + .orderBy(desc(operations.id)) + .limit(inventoryLimit + 1), + ]); + const now = Date.now(); + const grants = grantRows.slice(0, inventoryLimit); + const pendingOperations = operationRows.slice(0, inventoryLimit); + + return { + profile: "provider_policy" as const, + generated_at: new Date(now).toISOString(), + server_enforcement: { + amount_unknown_actions: "provider_policy", + execution_owner_review: "not_required", + }, + provider_boundary: { + enforcement: "unverified" as const, + direct_provider_key_bypass: "unverified" as const, + }, + wallet: wallet + ? { + wallet_id: wallet.providerWalletId || null, + near_account_id: wallet.nearAccountId || null, + evm_address: wallet.evmAddress || null, + status: wallet.status, + } + : null, + grants: { + returned: grants.length, + has_more: grantRows.length > inventoryLimit, + next_cursor: grants.at(-1)?.id ?? null, + items: grants.map((grant) => grantInventoryEntry(grant, agent.ownerEpoch, now)), + }, + pending_operations: { + returned: pendingOperations.length, + has_more: operationRows.length > inventoryLimit, + next_cursor: pendingOperations.at(-1)?.id ?? null, + items: pendingOperations.map(operationInventoryEntry), + }, + }; +} diff --git a/packages/agents-core/src/modules/agents/deletion-service.ts b/packages/agents-core/src/modules/agents/deletion-service.ts new file mode 100644 index 000000000..d599520c4 --- /dev/null +++ b/packages/agents-core/src/modules/agents/deletion-service.ts @@ -0,0 +1,357 @@ +import { + type AgentDelete, + type AgentDeletionChallenge, + agentDeletionChallengeSchema, + canonical, + executionResultSchema, + policySchema, +} from "@near-intents-agent-api/contracts"; +import { getRuntime } from "../../config/runtime.js"; +import { getOutlayer } from "../../lib/outlayer.js"; +import type { Actor } from "../../shared/actor.js"; +import type { CommitEffect } from "../../shared/commit-effect.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { nearAccountExists, nearPolicyOwnerMatches } from "../../shared/near.js"; +import { consumeOwnerNonceInTransaction, issueOwnerNonce } from "../../shared/nonces.js"; +import { + ownerEnvelopeMismatch, + ownerKeyMismatch, + ownerMessageMaxLifetimeMs, + ownerMessageWindowInvalid, +} from "../../shared/owner-message.js"; +import { + assertAuthorizationEpochsCurrent, + commitDispatch, + type DispatchEpochs, +} from "../operations/dispatch-fence.js"; +import { toOperationDto } from "../operations/mapper.js"; +import { providerResult, readString } from "../operations/provider-result.js"; +import { + findOperation, + reservePendingDeletion, + updateOperationResult, +} from "../operations/repository.js"; +import { + type Operation, + operationIdFor, + operationStatusFor, + readOperation, + runOperation, +} from "../operations/service.js"; +import { balanceList, custodyNativeBalance } from "../wallet/balance-service.js"; +import { + latestAppliedPolicy, + markCustodyWalletDeletedInTransaction, +} from "../wallet/repository.js"; +import { custodyCredential, requireActiveWallet } from "../wallet/service.js"; +import { archiveForDeletion, isDeleted } from "./lifecycle-repository.js"; +import { verifyBoundOwner } from "./owner-authorization.js"; +import { findAgent } from "./repository.js"; +import { ownerMessageRecipient, requireBoundAgent } from "./service.js"; + +type DeletionOperation = NonNullable>>; + +/** + * Persist one observed or retried deletion outcome. Marking the custody wallet deleted is + * conditional on the classified status, so a partial or pending provider answer never retires a + * wallet that may still hold funds. + */ +async function persistDeletionOutcome( + actor: Actor, + agentId: string, + operationId: string, + operation: DeletionOperation, + wallet: Awaited>, + result: unknown, +) { + const status = operationStatusFor(operation.kind, result); + const persisted = await updateOperationResult( + actor.tenantId, + agentId, + operationId, + "execute", + status, + result, + status === "completed" + ? (tx) => + markCustodyWalletDeletedInTransaction( + tx, + actor.tenantId, + agentId, + wallet.providerWalletId, + ) + : undefined, + ); + if (!persisted) throw new ApiError("operation_not_found", 404); + return toOperationDto(actor, persisted); +} + +/** The sponsor account that receives a deleted custody account's native NEAR. */ +function deletionBeneficiary(): string { + const beneficiary = getRuntime().sponsorAccountId; + if (!beneficiary) throw new ApiError("deletion_unavailable", 503); + return beneficiary; +} + +/** + * Issues the owner's deletion message together with what deletion does to the wallet's funds. + * Deleting at the provider returns only native NEAR (to the sponsor); public and confidential + * balances are destroyed. The owner empties the wallet first, for example by asking the agent to + * withdraw, and signs only once the preview shows nothing left to lose. + */ +export async function deletionChallenge( + actor: Actor, + agentId: string, +): Promise { + const { agent, wallet } = await requireBoundAgent(actor, agentId); + const beneficiary = deletionBeneficiary(); + const [native, intents, confidential, allowsDelete, onChain] = await Promise.all([ + custodyNativeBalance(actor, agentId), + balanceList(actor, agentId, { source: "public" }), + balanceList(actor, agentId, { source: "confidential" }), + policyAllowsDelete(actor.tenantId, agentId), + nearAccountExists(wallet.nearAccountId), + ]); + const issuedAtMs = Date.now(); + return agentDeletionChallengeSchema.parse({ + message: { + domain: "near-intents-agent-api.agent-delete.v3", + owner: agent.ownerIdentity, + tenant_id: actor.tenantId, + agent_id: agentId, + network: getRuntime().network, + account_id: agent.ownerAccountId, + public_key: agent.ownerPublicKey, + recipient: ownerMessageRecipient(), + nonce: await issueOwnerNonce(actor.tenantId, agentId), + issued_at_ms: issuedAtMs, + expires_at_ms: issuedAtMs + ownerMessageMaxLifetimeMs, + beneficiary, + chain: "near", + confirm_asset_loss: true, + }, + preview: { + near_account_id: wallet.nearAccountId, + beneficiary, + native_balance: native, + public: intents.balances, + confidential: confidential.balances, + assets_lost: intents.balances.length > 0 || confidential.balances.length > 0, + retirement: onChain ? "provider_delete" : "credential_erase", + policy_allows_delete: !onChain || allowsDelete, + }, + }); +} + +/** + * Retires the custody wallet. An account that exists on chain is deleted by OutLayer, which sends + * its native NEAR to the beneficiary. With no account there is nothing to delete: the result + * records that the API erases its only credential when the outcome is persisted. + */ +async function retireWallet( + wallet: Awaited>, + target: { beneficiary: string; chain: "near"; onChain: boolean }, + operationId: string, +) { + const base = { + action: "delete", + near_account_id: wallet.nearAccountId, + beneficiary: target.beneficiary, + chain: target.chain, + confirm_asset_loss: true, + }; + if (!target.onChain) + return executionResultSchema.parse({ + ...base, + provider_request_id: null, + status: "success", + credential_erased: true, + }); + const response = await getOutlayer().deleteWallet( + custodyCredential(wallet), + { beneficiary: target.beneficiary, chain: target.chain }, + operationId, + ); + return executionResultSchema.parse({ + ...base, + provider_request_id: readString(response, "request_id"), + ...providerResult(response), + }); +} + +/** OutLayer refuses `/wallet/v1/delete` unless the applied policy allows the `delete` type. */ +async function policyAllowsDelete(tenantId: string, agentId: string): Promise { + const applied = await latestAppliedPolicy(tenantId, agentId); + if (!applied) return false; + return policySchema.parse(applied.rules).rules.transaction_types.includes("delete"); +} + +function assertDeleteMessage( + actor: Actor, + agent: Awaited>, + agentId: string, + input: AgentDelete, +) { + const message = input.message; + if ( + !agent || + ownerEnvelopeMismatch(message, actor.tenantId, agentId) || + ownerKeyMismatch(message, agent) || + message.recipient !== ownerMessageRecipient() + ) + throw new ApiError("owner_mismatch", 409); + if (message.beneficiary !== deletionBeneficiary()) + throw new ApiError("delete_beneficiary_invalid", 400); + if (ownerMessageWindowInvalid(message, Date.now())) + throw new ApiError("owner_delete_expired", 409); +} + +/** + * Deletes an agent under one owner signature. The agent does not need to be paused or archived + * first, so it can keep moving funds out until the owner signs. Accepting the deletion archives + * the agent in the same transaction that reserves the operation: from then on no agent request is + * admitted, and any request admitted earlier fails its dispatch fence. + */ +export async function deleteAgent( + actor: Actor, + agentId: string, + input: AgentDelete, + onAccepted?: CommitEffect, +) { + const agent = await findAgent(actor.tenantId, agentId); + if (!agent) throw new ApiError("agent_not_found", 404); + const operationId = operationIdFor(actor.tenantId, agentId, "execute", input.idempotencyKey); + const requestHash = hashSecret(canonical(input)); + const existing = await findOperation(actor.tenantId, agentId, operationId); + if (existing) { + if (existing.requestHash !== requestHash) throw new ApiError("idempotency_conflict", 409); + return (await toOperationDto(actor, existing)) as Operation; + } + if (await isDeleted(actor.tenantId, agentId)) throw new ApiError("agent_deleted", 409); + const wallet = await requireActiveWallet(actor.tenantId, agentId); + assertDeleteMessage(actor, agent, agentId, input); + if ( + !(await nearPolicyOwnerMatches({ + contractId: getOutlayer().contractId, + nearAccountId: wallet.nearAccountId, + expectedOwner: agent.ownerAccountId, + })) + ) + throw new ApiError("policy_owner_mismatch", 409); + // With no on-chain account there is nothing for the provider to delete, and nothing its policy + // governs: the API retires the wallet by erasing its credential. Otherwise OutLayer deletes the + // account, and it would refuse that under a policy without the `delete` type. + // An Intents-only wallet is never funded with native NEAR, so its implicit account usually does + // not exist and OutLayer's `DeleteAccount` cannot run on it. + const onChain = await nearAccountExists(wallet.nearAccountId); + if (onChain && !(await policyAllowsDelete(actor.tenantId, agentId))) + throw new ApiError("policy_blocks_delete", 409); + const authorizationEpochs: DispatchEpochs = { + ownerEpoch: agent.ownerEpoch, + policyEpoch: agent.policyEpoch, + lifecycleEpoch: agent.lifecycleEpoch, + }; + await verifyBoundOwner(agent, input.message, input.proof); + await assertAuthorizationEpochsCurrent(actor.tenantId, agentId, authorizationEpochs); + const reservation = await reservePendingDeletion({ + id: operationId, + tenantId: actor.tenantId, + agentId, + actorKeyId: actor.keyId, + requestHash, + authorize: async (tx) => { + const archived = await archiveForDeletion(tx, actor.tenantId, agentId, authorizationEpochs, { + actorKeyId: actor.keyId, + ownerEpoch: agent.ownerEpoch, + lifecycleEpoch: agent.lifecycleEpoch, + requestHash, + }); + if ("refusal" in archived) + throw new ApiError(archived.refusal, archived.refusal === "agent_not_found" ? 404 : 409); + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, input.message.nonce); + await onAccepted?.(tx, operationId); + return archived.epochs; + }, + initialResult: { + action: "delete", + beneficiary: input.message.beneficiary, + chain: input.message.chain, + confirm_asset_loss: true, + near_account_id: wallet.nearAccountId, + provider_request_id: null, + }, + }); + if (reservation.kind === "existing") { + if (reservation.operation.requestHash !== requestHash) + throw new ApiError("idempotency_conflict", 409); + return (await toOperationDto(actor, reservation.operation)) as Operation; + } + if (reservation.kind === "pending") throw new ApiError("agent_deletion_pending", 409); + if (reservation.kind === "retired") throw new ApiError("operation_retired", 409); + + return runOperation({ + actor, + agentId, + kind: "execute", + action: "delete", + idempotencyKey: input.idempotencyKey, + request: input, + reserved: true, + reservedOperationId: operationId, + onPersist: async (tx, status) => { + if (status === "completed") + await markCustodyWalletDeletedInTransaction( + tx, + actor.tenantId, + agentId, + wallet.providerWalletId, + ); + }, + run: async (operationIdForProvider) => { + await commitDispatch(actor.tenantId, agentId, operationIdForProvider); + return retireWallet( + wallet, + { beneficiary: input.message.beneficiary, chain: input.message.chain, onChain }, + operationIdForProvider, + ); + }, + }); +} + +/** + * Deletion observation. It never re-dispatches: a deletion with no provider request id stays + * uncertain for reconciliation, because a second submission of an irreversible mutation cannot + * prove the first one had no effect. + */ +export async function refreshDeletion(actor: Actor, agentId: string, operationId: string) { + const operation = await readOperation(actor, agentId, operationId); + if (operation?.kind !== "execute") throw new ApiError("operation_not_found", 404); + const result = operation.result as { + action?: string; + beneficiary?: string; + chain?: "near"; + provider_request_id?: string | null; + } | null; + if (result?.action !== "delete") throw new ApiError("operation_kind_mismatch", 409); + if (!result.beneficiary) throw new ApiError("operation_result_invalid", 409); + if (operation.status === "completed" || operation.status === "failed") + return toOperationDto(actor, operation); + if (!result.provider_request_id) throw new ApiError("delete_submission_uncertain", 409); + const agent = await findAgent(actor.tenantId, agentId); + if (!agent) throw new ApiError("agent_not_found", 404); + const wallet = await requireActiveWallet(actor.tenantId, agentId); + const response = await getOutlayer().requestStatus( + custodyCredential(wallet), + result.provider_request_id, + ); + if (readString(response, "request_id") !== result.provider_request_id) + throw new ApiError("provider_request_mismatch", 502); + const observed = executionResultSchema.parse({ + ...result, + action: "delete", + near_account_id: wallet.nearAccountId, + ...providerResult(response), + }); + return persistDeletionOutcome(actor, agentId, operationId, operation, wallet, observed); +} diff --git a/packages/agents-core/src/modules/agents/grant-constraints.ts b/packages/agents-core/src/modules/agents/grant-constraints.ts new file mode 100644 index 000000000..a068d4bd1 --- /dev/null +++ b/packages/agents-core/src/modules/agents/grant-constraints.ts @@ -0,0 +1,189 @@ +import { + canonicalDestination, + type ExecutionRequest, + type GrantDestination, +} from "@near-intents-agent-api/contracts"; +import { ApiError } from "../../shared/errors.js"; + +type GrantSpendBucket = { asset: string; amount: string }; + +/** Extract destinations for API access checks and value metadata for the containment inventory. + * Spending policy is enforced by OutLayer, not by this module. + */ + +/** One counted amount: the token bucket and the exact integer amount in that token. */ +export type { GrantSpendBucket }; + +export type GrantCheck = { + /** Decoded external destinations, including refunds. Every entry must be allowlisted. */ + recipients: GrantDestination[]; + audience?: string; + /** + * True when value stays with the wallet itself (swap, shield, a deposit without a refund + * address), so an empty `recipients` is known rather than undecoded. + */ + selfDirected: boolean; + /** Observed value effects; not an API spending ceiling. */ + spend: GrantSpendBucket[]; + /** + * True when one or more value effects are not represented by `spend`. OutLayer policy + * determines whether the operation is permitted. + */ + amountUnknown: boolean; +}; + +const nativeAsset = "native"; + +type Fields = Record; + +function stringField(source: Fields, key: string): string | undefined { + const value = source[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function amountOf(source: Fields, key: string): string | undefined { + const value = source[key]; + return typeof value === "string" && /^[0-9]{1,78}$/.test(value) ? value : undefined; +} + +/** Recipients named by a plain `to`, plus the amount sent there. */ +function destinationCheck(request: Fields, amountKey: string): GrantCheck { + const amount = amountOf(request, amountKey); + + return { + recipients: [], + selfDirected: false, + spend: amount ? [{ asset: stringField(request, "token") ?? nativeAsset, amount }] : [], + // Intents routes settle through the provider gateway; the amount is reported, not bound. + amountUnknown: true, + }; +} + +/** Shield-style actions that name an amount but move it within the account itself. */ +function selfDepositCheck(request: Fields): GrantCheck { + const amount = amountOf(request, "amount"); + return { + recipients: [], + selfDirected: true, + spend: amount ? [{ asset: stringField(request, "token") ?? nativeAsset, amount }] : [], + amountUnknown: true, + }; +} + +/** Deposit-intent style actions where a refund address receives value if the bridge fails. */ +function refundableCheck(request: Fields): GrantCheck { + const amount = amountOf(request, "amount"); + + const asset = + stringField(request, "token") ?? stringField(request, "source_asset") ?? nativeAsset; + return { + recipients: [], + selfDirected: true, + spend: amount ? [{ asset, amount }] : [], + amountUnknown: true, + }; +} + +function swapCheck(request: Fields): GrantCheck { + const amount = amountOf(request, "amount_in"); + return { + recipients: [], + selfDirected: true, + spend: amount ? [{ asset: stringField(request, "token_in") ?? nativeAsset, amount }] : [], + // The input amount does not bound provider-selected route, output, or fees. Keep its known + // input bucket for reporting; provider policy handles the remaining effects. + amountUnknown: true, + }; +} + +/** The extractor for one action. A missing entry means the action cannot be constrained. */ +const extractors: Record GrantCheck> = { + withdraw: (request) => destinationCheck(request, "amount"), + intents_transfer: (request) => destinationCheck(request, "amount"), + confidential_transfer: (request) => destinationCheck(request, "amount"), + swap: swapCheck, + shield: selfDepositCheck, + unshield: selfDepositCheck, + confidential_deposit: selfDepositCheck, + cross_chain_deposit: refundableCheck, +}; + +/** + * The recipient/amount/asset shape of one execution action. An action whose destination cannot be + * decoded fails a recipient allowlist; `amountUnknown` marks value effects that need provider + * reconciliation. + */ +export function grantCheckForExecution( + input: ExecutionRequest, + network: "mainnet" = "mainnet", + sourceChain?: string, +): GrantCheck { + const extractor = extractors[input.action]; + // An action with no extractor cannot be constrained, so it must not pass a grant. `delete` is + // intentionally absent: it is owner-only and never reaches this path. + if (!extractor) throw new ApiError("grant_action_unsupported", 403); + const check = extractor(input.request as unknown as Fields); + const destination = destinationForExecution(input, network, sourceChain); + if (destination) { + try { + check.recipients = [canonicalDestination(destination)]; + } catch { + throw new ApiError("grant_destination_unresolved", 403); + } + } + return check; +} + +/** An identity signature has no spend effects, but its relying-party audience is a recipient. */ +export function grantCheckForIdentitySigning(audience: string): GrantCheck { + return { audience, recipients: [], selfDirected: false, spend: [], amountUnknown: false }; +} + +function destinationForExecution( + input: ExecutionRequest, + network: "mainnet", + sourceChain?: string, +): GrantDestination | undefined { + switch (input.action) { + case "withdraw": + return { + action: input.action, + kind: "chain-address", + chain: input.request.chain, + network, + address: input.request.to, + memo: + input.request.memo === undefined + ? { kind: "none" } + : { kind: "exact", value: input.request.memo }, + purpose: "payout", + }; + case "intents_transfer": + case "confidential_transfer": + return { + action: input.action, + kind: input.action === "intents_transfer" ? "intents-account" : "confidential-account", + chain: "near", + network, + address: input.request.to, + memo: { kind: "none" }, + purpose: "payout", + }; + case "cross_chain_deposit": + if (input.request.refund_address !== undefined) { + const chain = + sourceChain ?? (!input.request.source_asset ? input.request.chain : undefined); + if (!chain) throw new ApiError("grant_destination_unresolved", 403); + return { + action: input.action, + kind: "chain-address", + chain, + network, + address: input.request.refund_address, + memo: { kind: "none" }, + purpose: "refund", + }; + } + } + return undefined; +} diff --git a/packages/agents-core/src/modules/agents/grant-integrity.ts b/packages/agents-core/src/modules/agents/grant-integrity.ts new file mode 100644 index 000000000..e2e4b1133 --- /dev/null +++ b/packages/agents-core/src/modules/agents/grant-integrity.ts @@ -0,0 +1,29 @@ +import { agentGrantMessageSchema, canonical } from "@near-intents-agent-api/contracts"; +import type { agentGrants } from "@near-intents-agent-api/database"; + +const sameList = (left: readonly string[], right: readonly string[]) => + left.length === right.length && left.every((value, index) => value === right[index]); + +/** + * Authorization reads a grant's permission columns, so they must restate the owner-signed message + * they were copied from at issuance. This detects inconsistent edits, but a database writer can + * change both columns and message. It does not re-verify the owner's proof. + */ +export function grantRestatesOwnerMessage(grant: typeof agentGrants.$inferSelect): boolean { + const parsed = agentGrantMessageSchema.safeParse(grant.ownerMessage); + if (!parsed.success) return false; + const message = parsed.data; + return ( + message.tenant_id === grant.tenantId && + message.agent_id === grant.agentId && + message.wallet_id === grant.walletId && + message.label === grant.label && + message.credential === grant.credentialHash && + message.owner_epoch === grant.ownerEpoch && + message.issued_at_ms === grant.issuedAt.getTime() && + message.expires_at_ms === grant.expiresAt.getTime() && + sameList(message.actions, grant.actions) && + canonical(message.recipients) === canonical(grant.recipients) && + sameList(message.signing_audiences, grant.signingAudiences) + ); +} diff --git a/packages/agents-core/src/modules/agents/grant-service.ts b/packages/agents-core/src/modules/agents/grant-service.ts new file mode 100644 index 000000000..ef7a7750e --- /dev/null +++ b/packages/agents-core/src/modules/agents/grant-service.ts @@ -0,0 +1,445 @@ +import { + type AgentGrantMessage, + type AgentGrantRevocation, + type AgentGrantView, + type AgentGrantWrite, + agentGrantMessageSchema, + canonical, + executionActionSchema, + grantTokenPattern, + historicalGrantMessageSchema, +} from "@near-intents-agent-api/contracts"; +import { agentGrants, agents, auditEvents } from "@near-intents-agent-api/database"; +import { and, count, desc, eq, gt, isNull, sql } from "drizzle-orm"; +import { getRuntime } from "../../config/runtime.js"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import type { CommitEffect } from "../../shared/commit-effect.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { consumeOwnerNonceInTransaction, issueOwnerNonce } from "../../shared/nonces.js"; +import { ownerMessageExpired } from "../../shared/owner-message.js"; +import { committedDelegatedOperations, type DispatchEpochs } from "../operations/dispatch-fence.js"; +import type { GrantCheck } from "./grant-constraints.js"; +import { grantRestatesOwnerMessage } from "./grant-integrity.js"; +import { + assertOwnerAdminCommand, + ownerAdminCommandChallenge, + ownerPrincipalId, + verifyOwnerAdminCommand, +} from "./owner-admin-command.js"; +import { verifyBoundOwner } from "./owner-authorization.js"; +import { recordOwnerReceipt } from "./owner-receipts.js"; +import { ownerMessageRecipient, requireBoundAgent, requireBoundOwnerAgent } from "./service.js"; + +export type GrantRecord = typeof agentGrants.$inferSelect; +/** The grant an admitted delegate action runs under, recorded in operation provenance. */ +export type GrantAuthorizationDecision = { + grantId: string; + label: string; + authorizationEpochs: DispatchEpochs; +}; + +/** + * How a delegated request names its grant. Callers present the grant token; only an internal + * resume of work the grant already admitted (a timelocked execution) names the grant by id. + */ +export type GrantSelector = { token: string | undefined } | { admittedGrantId: string }; + +/** How long an owner-issued grant may live. A harness credential must not be permanent. */ +const maxGrantLifetimeMs = 90 * 24 * 60 * 60 * 1000; +/** Live grants one agent may hold at once: one per session, assistant or bot. */ +export const maxLiveGrantsPerAgent = 50; + +export function grantRecordView(record: GrantRecord): AgentGrantView { + return { + grant_id: record.id, + agent_id: record.agentId, + wallet_id: record.walletId, + label: record.label, + actions: record.actions, + recipients: agentGrantMessageSchema.safeParse(record.ownerMessage).success + ? record.recipients + : [], + signing_audiences: record.signingAudiences, + issued_at: record.issuedAt.toISOString(), + expires_at: record.expiresAt.toISOString(), + revoked_at: record.revokedAt?.toISOString() ?? null, + revoked_reason: record.revokedReason ?? null, + owner_epoch: record.ownerEpoch, + owner_message: historicalGrantMessageSchema.parse(record.ownerMessage), + }; +} + +/** + * Every action a grant can delegate: the Intents executions and the two identity-signing + * purposes. Approval votes need no grant; the approver's signature authorizes them. A signed grant + * naming anything else would never authorize a request, so it is refused at issue instead of + * silently doing nothing. + */ +const delegableActions = new Set([ + "*", + "sign:near_message", + "sign:evm_message", + ...executionActionSchema.options.filter((action) => action !== "delete"), +]); + +export function assertGrantMessage( + actor: Actor, + message: AgentGrantMessage, + agent: Awaited>["agent"], + walletId: string, +) { + if (message.network !== getRuntime().network) throw new ApiError("owner_mismatch", 409); + if (message.tenant_id !== actor.tenantId) throw new ApiError("owner_mismatch", 409); + if (message.agent_id !== agent.id) throw new ApiError("owner_mismatch", 409); + if (message.wallet_id !== walletId) throw new ApiError("wallet_mismatch", 409); + if (message.owner_epoch !== agent.ownerEpoch) throw new ApiError("owner_epoch_invalid", 409); + const now = Date.now(); + if ( + ownerMessageExpired(message, now) || + message.expires_at_ms <= message.issued_at_ms || + message.expires_at_ms - now > maxGrantLifetimeMs + ) + throw new ApiError("grant_expiry_invalid", 409); + // Owner-only administrative actions can never be delegated to an agent grant. + const ownerOnlyActions = ["add_key", "unfreeze", "delete"]; + if (message.actions.some((action) => ownerOnlyActions.includes(action))) + throw new ApiError("grant_action_forbidden", 409); + if (message.actions.some((action) => !delegableActions.has(action))) + throw new ApiError("grant_action_unknown", 400); +} + +/** + * Installs one owner-signed grant. It is independent of every other grant on the agent and of + * the API key that submitted it: only its token can use it, and nothing else is revoked or fenced. + */ +export async function issueAgentGrant( + actor: Actor, + agentId: string, + input: AgentGrantWrite, + onCommit?: CommitEffect, + original?: unknown, +) { + const { agent, wallet } = await requireBoundAgent(actor, agentId); + const message = input.message; + assertGrantMessage(actor, message, agent, wallet.providerWalletId); + const verification = await verifyBoundOwner(agent, message, input.proof); + + // The owner nonce is single-use, so it names exactly one issuance. + const grantId = hashSecret( + canonical({ tenant: actor.tenantId, agent: agentId, nonce: message.nonce }), + ); + const record = await getDatabase().transaction(async (tx) => { + // The agent row serializes issuance for the live-grant cap, and an owner transition that + // committed first makes this proof stale. + const [locked] = await tx + .select({ id: agents.id }) + .from(agents) + .where( + and( + eq(agents.tenantId, actor.tenantId), + eq(agents.id, agentId), + eq(agents.ownerEpoch, message.owner_epoch), + ), + ) + .for("update"); + if (!locked) throw new ApiError("owner_epoch_invalid", 409); + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, message.nonce); + const [live] = await tx + .select({ count: count() }) + .from(agentGrants) + .where( + and( + eq(agentGrants.tenantId, actor.tenantId), + eq(agentGrants.agentId, agentId), + isNull(agentGrants.revokedAt), + gt(agentGrants.expiresAt, sql`now()`), + ), + ); + if ((live?.count ?? 0) >= maxLiveGrantsPerAgent) throw new ApiError("grant_limit_reached", 409); + const [row] = await tx + .insert(agentGrants) + .values({ + id: grantId, + tenantId: actor.tenantId, + agentId, + walletId: wallet.providerWalletId, + label: message.label, + credentialHash: message.credential, + actions: message.actions, + recipients: message.recipients, + signingAudiences: message.signing_audiences, + ownerEpoch: message.owner_epoch, + issuedAt: new Date(message.issued_at_ms), + expiresAt: new Date(message.expires_at_ms), + ownerMessage: message, + }) + .onConflictDoNothing() + .returning(); + await recordOwnerReceipt(tx, { + tenantId: actor.tenantId, + agentId, + action: "grant_issue", + targetId: grantId, + ownerEpoch: message.owner_epoch, + message, + proof: input.proof, + verification, + original, + }); + // A token commits to one grant only; reusing it would make the token ambiguous. + if (!row) throw new ApiError("grant_credential_reused", 409); + await tx.insert(auditEvents).values({ + tenantId: actor.tenantId, + agentId, + action: "agent.grant_issued", + resourceId: grantId, + actorKeyId: actor.keyId, + actorOwnerId: ownerPrincipalId(message.owner), + grantId, + ownerEpoch: message.owner_epoch, + requestHash: hashSecret(canonical(message)), + }); + await onCommit?.(tx, grantRecordView(row)); + return row; + }); + return grantRecordView(record); +} + +export async function grantRevocationChallenge(actor: Actor, agentId: string, grantId: string) { + const agent = await requireBoundOwnerAgent(actor.tenantId, agentId); + const [grant] = await getDatabase() + .select({ id: agentGrants.id }) + .from(agentGrants) + .where( + and( + eq(agentGrants.tenantId, actor.tenantId), + eq(agentGrants.agentId, agentId), + eq(agentGrants.id, grantId), + eq(agentGrants.ownerEpoch, agent.ownerEpoch), + isNull(agentGrants.revokedAt), + ), + ) + .limit(1); + if (!grant) throw new ApiError("grant_not_found", 404); + return ownerAdminCommandChallenge(actor, agent, "revoke_grant", grantId); +} + +/** Revokes one grant only under a fresh command signed by the agent's current owner. */ +export async function revokeAgentGrant( + actor: Actor, + agentId: string, + grantId: string, + input: AgentGrantRevocation, + onCommit?: CommitEffect<{ + revoked: boolean; + committed_operation_ids: string[]; + committed_truncated: boolean; + }>, + original?: unknown, +) { + const agent = await requireBoundOwnerAgent(actor.tenantId, agentId); + const { message, proof } = input; + assertOwnerAdminCommand(actor, agent, "revoke_grant", grantId, message); + const verification = await verifyOwnerAdminCommand(agent, message, proof); + // Revoking the grant row waits for every in-flight dispatch commitment that share-locks it. + // Later commitments are refused; earlier ones are reported. Other grants are untouched. + const committed = await getDatabase().transaction(async (tx) => { + const [current] = await tx + .select({ id: agents.id }) + .from(agents) + .where( + and( + eq(agents.tenantId, actor.tenantId), + eq(agents.id, agentId), + eq(agents.ownerEpoch, message.owner_epoch), + ), + ) + .for("share"); + if (!current) throw new ApiError("owner_command_stale", 409); + const rows = await tx + .update(agentGrants) + .set({ revokedAt: new Date(), revokedReason: "owner_revoked" }) + .where( + and( + eq(agentGrants.tenantId, actor.tenantId), + eq(agentGrants.agentId, agentId), + eq(agentGrants.id, grantId), + eq(agentGrants.ownerEpoch, message.owner_epoch), + isNull(agentGrants.revokedAt), + ), + ) + .returning({ id: agentGrants.id }); + if (rows.length !== 1) throw new ApiError("grant_not_found", 404); + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, message.nonce); + await tx.insert(auditEvents).values({ + tenantId: actor.tenantId, + agentId, + action: "agent.grant_revoked", + resourceId: grantId, + actorKeyId: actor.keyId, + actorOwnerId: ownerPrincipalId(message.owner), + grantId, + ownerEpoch: message.owner_epoch, + requestHash: hashSecret(canonical(message)), + }); + await recordOwnerReceipt(tx, { + tenantId: actor.tenantId, + agentId, + action: "grant_revoke", + targetId: grantId, + ownerEpoch: message.owner_epoch, + message, + proof, + verification, + original, + }); + const { ids, truncated } = await committedDelegatedOperations(tx, actor.tenantId, { + agentId, + grantId, + }); + const report = { committed_operation_ids: ids, committed_truncated: truncated }; + await onCommit?.(tx, { revoked: true, ...report }); + return report; + }); + return { revoked: true, ...committed }; +} + +export async function listAgentGrants(actor: Actor, agentId: string): Promise { + await requireBoundAgent(actor, agentId); + const rows = await getDatabase() + .select() + .from(agentGrants) + .where(and(eq(agentGrants.tenantId, actor.tenantId), eq(agentGrants.agentId, agentId))) + .orderBy(desc(agentGrants.createdAt)); + return rows.map(grantRecordView); +} + +/** A grant ceremony challenge: the owner signs a fresh nonce under the current owner epoch. */ +export async function grantChallenge(actor: Actor, agentId: string) { + const { agent } = await requireBoundAgent(actor, agentId); + return { + nonce: await issueOwnerNonce(actor.tenantId, agentId), + recipient: ownerMessageRecipient(), + owner_epoch: agent.ownerEpoch, + }; +} + +/** + * The live grant a selector names, with the epochs admission is recorded under. A token that is + * malformed, unknown, revoked, expired or bound to another agent resolves to nothing, the same way. + */ +async function liveGrantSnapshot( + tenantId: string, + agentId: string, + selector: GrantSelector, +): Promise<{ grant: GrantRecord; authorizationEpochs: DispatchEpochs } | null> { + if ("token" in selector && !(selector.token && grantTokenPattern.test(selector.token))) + return null; + const [row] = await getDatabase() + .select({ + grant: agentGrants, + ownerEpoch: agents.ownerEpoch, + policyEpoch: agents.policyEpoch, + lifecycleEpoch: agents.lifecycleEpoch, + }) + .from(agentGrants) + .innerJoin( + agents, + and(eq(agents.tenantId, agentGrants.tenantId), eq(agents.id, agentGrants.agentId)), + ) + .where( + and( + eq(agentGrants.tenantId, tenantId), + eq(agentGrants.agentId, agentId), + "token" in selector + ? eq(agentGrants.credentialHash, grantCredentialHash(selector.token ?? "")) + : eq(agentGrants.id, selector.admittedGrantId), + eq(agentGrants.ownerEpoch, agents.ownerEpoch), + isNull(agentGrants.revokedAt), + ), + ) + .limit(1); + if (!row || row.grant.expiresAt.getTime() <= Date.now()) return null; + if (!grantRestatesOwnerMessage(row.grant)) return null; + return { + grant: row.grant, + authorizationEpochs: { + ownerEpoch: row.ownerEpoch, + policyEpoch: row.policyEpoch, + lifecycleEpoch: row.lifecycleEpoch, + }, + }; +} + +/** The stored commitment of a grant token: SHA-256 hex of its UTF-8 bytes. */ +export function grantCredentialHash(token: string): string { + return hashSecret(token); +} + +export type GrantAuthorizationInput = { + actor: Actor; + agentId: string; + grant: GrantSelector; + action: string; + /** + * Action-specific destinations and spend, extracted by `grant-constraints.ts`. Recipient allowlists are checked locally; amounts remain provider-policy inputs. + */ + check: GrantCheck; + /** Provider wallet the grant is bound to, so a wallet swap invalidates the delegation. */ + walletId: string; +}; + +function assertActionAllowed(grant: AgentGrantView, action: string): void { + // A wildcard grant is useful for typed execution, but it cannot express which detached + // signature purpose the owner approved. Signing therefore needs its literal action entry. + if (action.startsWith("sign:")) { + if (!grant.actions.includes(action)) throw new ApiError("grant_action_denied", 403); + return; + } + if (!grant.actions.includes("*") && !grant.actions.includes(action)) + throw new ApiError("grant_action_denied", 403); +} + +function assertSigningAudienceExplicit(grant: AgentGrantView, action: string): void { + if (action.startsWith("sign:") && grant.signing_audiences.length === 0) + throw new ApiError("grant_signing_audience_required", 403); +} + +/** Empty grants allow only self-directed effects; every named destination must be listed. */ +function assertRecipientsAllowed(grant: AgentGrantView, check: GrantCheck): void { + if (check.recipients.length === 0 && !check.selfDirected && check.audience === undefined) + throw new ApiError("grant_recipient_required", 403); + if (check.audience !== undefined) { + if (!grant.signing_audiences.includes(check.audience)) + throw new ApiError("grant_recipient_denied", 403); + return; + } + for (const recipient of check.recipients) + if (!grant.recipients.some((allowed) => canonical(allowed) === canonical(recipient))) + throw new ApiError("grant_recipient_denied", 403); +} + +/** + * Fails closed on an action the grant does not list, an unverifiable destination, a recipient the + * grant does not list, or a grant that binds a different custody wallet. Spending limits belong + * to the owner-configured provider policy. + */ +function assertGrantAllowsAction(grant: AgentGrantView, input: GrantAuthorizationInput): void { + assertActionAllowed(grant, input.action); + assertSigningAudienceExplicit(grant, input.action); + if (grant.wallet_id !== input.walletId) throw new ApiError("grant_wallet_mismatch", 409); + assertRecipientsAllowed(grant, input.check); +} + +/** Enforces the owner grant the request names for one delegate action. */ +export async function authorizeDelegatedAction( + input: GrantAuthorizationInput, +): Promise { + const snapshot = await liveGrantSnapshot(input.actor.tenantId, input.agentId, input.grant); + if (!snapshot) throw new ApiError("agent_grant_required", 403); + const { grant, authorizationEpochs } = snapshot; + assertGrantAllowsAction(grantRecordView(grant), input); + return { grantId: grant.id, label: grant.label, authorizationEpochs }; +} diff --git a/packages/agents-core/src/modules/agents/lifecycle-repository.ts b/packages/agents-core/src/modules/agents/lifecycle-repository.ts new file mode 100644 index 000000000..936e90e58 --- /dev/null +++ b/packages/agents-core/src/modules/agents/lifecycle-repository.ts @@ -0,0 +1,150 @@ +import { agents, auditEvents, operationArtifacts, type Tx } from "@near-intents-agent-api/database"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import type { DispatchEpochs } from "../operations/dispatch-fence.js"; +import { type AuditProvenance, auditProvenance } from "./repository.js"; + +/** + * Lifecycle state is authoritative in the agents row, not derived from the audit log. Audit + * events are append-only forensic output; using them as active authorization state meant a + * general-purpose audit retention job could change who may act. + */ +export async function lifecycleOf(tenantId: string, agentId: string) { + const [agent] = await getDatabase() + .select({ lifecycle: agents.lifecycle }) + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .limit(1); + return agent?.lifecycle ?? null; +} + +export async function isArchived(tenantId: string, agentId: string) { + return (await lifecycleOf(tenantId, agentId)) === "archived"; +} + +export async function isDeleted(tenantId: string, agentId: string) { + return (await lifecycleOf(tenantId, agentId)) === "deleted"; +} + +async function eraseSigningArtifacts(tx: Tx, tenantId: string, agentId: string) { + await tx + .delete(operationArtifacts) + .where( + and( + eq(operationArtifacts.tenantId, tenantId), + eq(operationArtifacts.agentId, agentId), + inArray(operationArtifacts.action, [ + "near_message", + "evm_message", + "evm_typed_data", + "evm_transaction", + ]), + ), + ); +} + +export async function applyControl( + tenantId: string, + agentId: string, + accountId: string, + previousKey: string, + action: "archive" | "restore", + previousCounter: number, + provenance?: AuditProvenance, + onCommit?: (tx: Tx) => Promise, +): Promise { + return getDatabase().transaction(async (tx) => { + const updated = await tx + .update(agents) + .set({ + // Archive/restore is authoritative state and advances the lifecycle epoch, so + // operations authorized before the change fail their dispatch fence. + ...(action === "archive" + ? { + lifecycle: "archived" as const, + archivedAt: new Date(), + lifecycleEpoch: sql`${agents.lifecycleEpoch} + 1`, + } + : {}), + ...(action === "restore" + ? { + lifecycle: "active" as const, + archivedAt: null, + lifecycleEpoch: sql`${agents.lifecycleEpoch} + 1`, + } + : {}), + }) + .where( + and( + eq(agents.tenantId, tenantId), + eq(agents.id, agentId), + eq(agents.ownerAccountId, accountId), + eq(agents.ownerPublicKey, previousKey), + eq(agents.ownerCounter, previousCounter), + eq(agents.lifecycle, action === "archive" ? "active" : "archived"), + ), + ) + .returning({ id: agents.id, ownerEpoch: agents.ownerEpoch }); + const updatedAgent = updated[0]; + if (!updatedAgent) return false; + if (action === "archive") await eraseSigningArtifacts(tx, tenantId, agentId); + await tx.insert(auditEvents).values({ + tenantId, + agentId, + action: `agent.${action}`, + resourceId: previousKey, + ...auditProvenance(provenance), + }); + await onCommit?.(tx); + return true; + }); +} + +/** + * Archives an agent inside the deletion reservation, so no agent request is admitted or + * dispatched once the owner's deletion is accepted. The owner's authorization snapshot must still + * be current. Returns the epochs the deletion itself commits under (an active agent moves to the + * next lifecycle epoch; an archived agent keeps its epochs), or why it cannot be deleted. + */ +export async function archiveForDeletion( + tx: Tx, + tenantId: string, + agentId: string, + authorized: DispatchEpochs, + provenance: AuditProvenance, +): Promise< + | { epochs: DispatchEpochs } + | { refusal: "agent_not_found" | "authorization_stale" | "agent_deleted" } +> { + const [agent] = await tx + .select() + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .for("update"); + if (!agent) return { refusal: "agent_not_found" }; + if ( + agent.ownerEpoch !== authorized.ownerEpoch || + agent.policyEpoch !== authorized.policyEpoch || + agent.lifecycleEpoch !== authorized.lifecycleEpoch + ) + return { refusal: "authorization_stale" }; + if (agent.lifecycle === "deleted") return { refusal: "agent_deleted" }; + if (agent.lifecycle === "archived") return { epochs: authorized }; + await tx + .update(agents) + .set({ + lifecycle: "archived", + archivedAt: new Date(), + lifecycleEpoch: sql`${agents.lifecycleEpoch} + 1`, + }) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))); + await eraseSigningArtifacts(tx, tenantId, agentId); + await tx.insert(auditEvents).values({ + tenantId, + agentId, + action: "agent.archive", + resourceId: "delete", + ...auditProvenance(provenance), + }); + return { epochs: { ...authorized, lifecycleEpoch: authorized.lifecycleEpoch + 1 } }; +} diff --git a/packages/agents-core/src/modules/agents/lifecycle-service.ts b/packages/agents-core/src/modules/agents/lifecycle-service.ts new file mode 100644 index 000000000..8b72132e9 --- /dev/null +++ b/packages/agents-core/src/modules/agents/lifecycle-service.ts @@ -0,0 +1,120 @@ +import { type AgentControl, canonical } from "@near-intents-agent-api/contracts"; +import { getOutlayer } from "../../lib/outlayer.js"; +import type { Actor } from "../../shared/actor.js"; +import type { CommitEffect } from "../../shared/commit-effect.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { nearPolicyOwnerMatches } from "../../shared/near.js"; +import { consumeOwnerNonceInTransaction } from "../../shared/nonces.js"; +import { ownerEnvelopeMismatch, ownerMessageWindowInvalid } from "../../shared/owner-message.js"; +import { assertOwnerIdentity } from "../../shared/owner-proof.js"; +import { custodyCredential } from "../wallet/service.js"; +import { applyControl, isArchived, isDeleted } from "./lifecycle-repository.js"; +import { verifyBoundOwner } from "./owner-authorization.js"; +import { recordOwnerReceipt } from "./owner-receipts.js"; +import { ownerMessageRecipient, requireBoundAgent, resolveOwnerWallet } from "./service.js"; + +export async function requireActiveAgent(tenantId: string, agentId: string) { + if (await isDeleted(tenantId, agentId)) throw new ApiError("agent_deleted", 409); + if (await isArchived(tenantId, agentId)) throw new ApiError("agent_archived", 409); +} + +function assertControlMessage( + actor: Actor, + agentId: string, + agent: Awaited>["agent"], + message: AgentControl["message"], +) { + if ( + ownerEnvelopeMismatch(message, actor.tenantId, agentId) || + message.account_id !== agent.ownerAccountId || + message.previous_public_key !== agent.ownerPublicKey || + message.recipient !== ownerMessageRecipient() + ) + throw new ApiError("owner_mismatch", 409); + if (message.public_key !== agent.ownerPublicKey) throw new ApiError("owner_mismatch", 409); + if (ownerMessageWindowInvalid(message, Date.now())) + throw new ApiError("owner_control_expired", 409); +} + +export async function assertControlTransition( + tenantId: string, + agentId: string, + action: AgentControl["message"]["action"], + wallet: Awaited>["wallet"], + ownerAccountId: string | null, +) { + const archived = await isArchived(tenantId, agentId); + if (action === "archive" && archived) throw new ApiError("agent_archived", 409); + if (action === "restore" && !archived) throw new ApiError("agent_not_archived", 409); + const provider = await getOutlayer().policy(custodyCredential(wallet)); + const expectedFrozen = action === "archive"; + if ( + !(await nearPolicyOwnerMatches({ + contractId: getOutlayer().contractId, + nearAccountId: wallet.nearAccountId, + expectedOwner: ownerAccountId, + })) || + provider.frozen !== expectedFrozen + ) + throw new ApiError( + action === "archive" ? "pause_before_archive" : "unpause_before_restore", + 409, + ); +} + +export async function controlAgent( + actor: Actor, + agentId: string, + input: AgentControl, + onCommit?: CommitEffect<{ archived: boolean; ownerPublicKey: string }>, + original?: unknown, +) { + const { agent, wallet } = await requireBoundAgent(actor, agentId); + const message = input.message; + assertControlMessage(actor, agentId, agent, message); + assertOwnerIdentity(agent.ownerIdentity, message.owner); + const near = resolveOwnerWallet(message.owner); + if (message.account_id !== near.accountId || message.public_key !== near.publicKey) + throw new ApiError("owner_mismatch", 409); + const verification = await verifyBoundOwner(agent, message, input.proof); + await assertControlTransition( + actor.tenantId, + agentId, + message.action, + wallet, + agent.ownerAccountId, + ); + const result = { archived: message.action === "archive", ownerPublicKey: message.public_key }; + const applied = await applyControl( + actor.tenantId, + agentId, + agent.ownerAccountId ?? "", + message.previous_public_key, + message.action, + agent.ownerCounter, + { + actorKeyId: actor.keyId, + ownerEpoch: agent.ownerEpoch, + lifecycleEpoch: agent.lifecycleEpoch, + requestHash: hashSecret(canonical(message)), + }, + async (tx) => { + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, message.nonce); + await recordOwnerReceipt(tx, { + tenantId: actor.tenantId, + agentId, + action: `agent_${message.action}`, + targetId: agentId, + ownerEpoch: agent.ownerEpoch, + message, + proof: input.proof, + verification, + original, + }); + await onCommit?.(tx, result); + }, + ); + if (!applied) throw new ApiError("owner_mismatch", 409); + return result; +} diff --git a/packages/agents-core/src/modules/agents/mapper.ts b/packages/agents-core/src/modules/agents/mapper.ts new file mode 100644 index 000000000..4b8c38e91 --- /dev/null +++ b/packages/agents-core/src/modules/agents/mapper.ts @@ -0,0 +1,31 @@ +import { type AgentView, ownerWalletSchema, walletSchema } from "@near-intents-agent-api/contracts"; +import { projectEvmAddress } from "../wallet/service.js"; +import type { AgentRecord } from "./repository.js"; + +export function toAgentDto( + agent: AgentRecord, + wallet: + | { providerWalletId: string; nearAccountId: string; evmAddress: string; status: string } + | undefined, +): AgentView { + const ownerWallet = agent.ownerIdentity ? ownerWalletSchema.parse(agent.ownerIdentity) : null; + return { + id: agent.id, + name: agent.name, + externalUserId: agent.externalUserId, + status: agent.lifecycle, + archived: agent.lifecycle === "archived", + deleted: agent.lifecycle === "deleted", + ownerWallet, + ownerNear: agent.ownerNear, + wallet: + wallet?.status === "active" + ? walletSchema.parse({ + wallet_id: wallet.providerWalletId, + near_account_id: wallet.nearAccountId, + evm_address: projectEvmAddress(wallet.evmAddress), + }) + : null, + createdAt: agent.createdAt.toISOString(), + }; +} diff --git a/packages/agents-core/src/modules/agents/onboarding-service.ts b/packages/agents-core/src/modules/agents/onboarding-service.ts new file mode 100644 index 000000000..bed6f8277 --- /dev/null +++ b/packages/agents-core/src/modules/agents/onboarding-service.ts @@ -0,0 +1,354 @@ +import { + type AgentOnboarding, + type CreateAgent, + canonical, + type OnboardingSignature, + type OnboardingView, + type OwnerWallet, + ownerWalletSchema, + type Policy, + policySchema, +} from "@near-intents-agent-api/contracts"; +import { z } from "zod"; +import { getRuntime } from "../../config/runtime.js"; +import type { Actor } from "../../shared/actor.js"; +import { hashSecret, newId } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { verifyNearFullAccessKey } from "../../shared/near.js"; +import { findOperation, type OperationRecord } from "../operations/repository.js"; +import { operationIdFor, runOperation } from "../operations/service.js"; +import { stagePolicyRevision } from "../wallet/owner-policy-preparation.js"; +import { + policyFailureCodes, + providerConfirmsPolicy, + settlePolicyWrite, +} from "../wallet/owner-policy-reconciliation.js"; +import { dispatchPreparedPolicy } from "../wallet/owner-policy-submission.js"; +import { finalizePolicyOperation } from "../wallet/policy-finalization.js"; +import { assertPolicyWritable } from "../wallet/policy-write-rules.js"; +import { preparedSchema } from "../wallet/prepared-policy.js"; +import { findPolicyRecord, latestPolicy } from "../wallet/repository.js"; +import { provisionWallet, requireActiveWallet } from "../wallet/service.js"; +import type { StorageFunding } from "../wallet/storage-funding.js"; +import { + AgentQuotaExceededError, + abandonAgent, + abandonAgentInTransaction, + bindOnboardingOwner, + insertAgent, +} from "./repository.js"; +import { type Agent, agentView, loadAgent, resolveOwnerWallet } from "./service.js"; +import { assertOwnerWalletCode, ensureOwnerWalletInitialized } from "./wallet-initialization.js"; + +/** + * Agent onboarding: one owner signature creates a live agent. + * + * Creation provisions the agent's custody wallet and stages its first policy as the exact + * on-chain request the proposed owner must sign: a NEP-366 delegate for a named NEAR account, a + * `w_execute_signed` request for a passkey/EVM `0s` wallet. That request installs the policy for + * this agent's custody wallet under the owner's account, so its signature is at once the binding + * proof, the policy consent and the transaction authorization. + * + * The owner is written to the agent only after that transaction finalizes and the provider reads + * the policy back under the owner as controller. Until then the agent is `pending` and every + * owner-gated route refuses it; an expired or failed request leaves it `abandoned`. + */ + +const onboardingIdempotencyKey = "agent-onboarding"; +/** NEAR wallets choose the delegate's block horizon; the API bounds how long it will relay. */ +const nearOnboardingTtlMs = 10 * 60_000; + +const storedOnboardingSchema = preparedSchema.extend({ + status: z.enum(["pending_wallet_signature", "dispatching", "submitted", "applied", "failed"]), + onboarding: z.literal(true), + owner: ownerWalletSchema, + expires_at: z.iso.datetime(), + failure_code: z.string().optional(), +}); +type StoredOnboarding = z.infer; + +function onboardingOperationId(tenantId: string, agentId: string) { + return operationIdFor(tenantId, agentId, "policy", onboardingIdempotencyKey); +} + +function signerFor(owner: OwnerWallet) { + const near = resolveOwnerWallet(owner); + return { + near, + signer: { + ownerIdentity: owner, + ownerAccountId: near.accountId, + ownerPublicKey: near.publicKey, + }, + }; +} + +function expiresAt(staged: { wallet_request?: { created_at: string; timeout_secs: number } }) { + const deadline = staged.wallet_request + ? Date.parse(staged.wallet_request.created_at) + staged.wallet_request.timeout_secs * 1000 + : Date.now() + nearOnboardingTtlMs; + return new Date(deadline).toISOString(); +} + +/** Creates a pending agent with custody and the one request its owner signs to make it live. */ +const defaultMaxAgentsPerTenant = 50; + +export async function createAgent( + actor: Actor, + input: CreateAgent, + reservedId?: string, +): Promise { + const policy = policySchema.parse(input.policy); + assertPolicyWritable(policy); + const owner = ownerWalletSchema.parse(input.owner); + const { near } = signerFor(owner); + const id = reservedId ?? newId(); + try { + await insertAgent( + { + id, + tenantId: actor.tenantId, + name: input.name, + externalUserId: input.externalUserId ?? null, + }, + getRuntime().maxAgentsPerTenant ?? defaultMaxAgentsPerTenant, + getRuntime().maxCreatedAgentsPerTenant, + ); + } catch (error) { + if (error instanceof AgentQuotaExceededError) throw new ApiError(error.message, 409); + throw error; + } + try { + const wallet = await provisionWallet(actor.tenantId, id); + await runOperation({ + actor, + agentId: id, + kind: "policy", + action: "onboarding", + idempotencyKey: onboardingIdempotencyKey, + request: { owner, policy }, + run: async (operationId) => { + const staged = await stagePolicyRevision({ + tenantId: actor.tenantId, + agentId: id, + operationId, + ownerType: owner.type, + ownerAccountId: near.accountId, + wallet, + policy, + policyHash: hashSecret(canonical(policy)), + revision: 0, + frozenBefore: false, + }); + return { ...staged, onboarding: true, owner, expires_at: expiresAt(staged) }; + }, + }); + } catch (error) { + await abandonAgent(actor.tenantId, id, "onboarding_preparation_failed"); + throw error; + } + return onboardingResponse(actor, id); +} + +/** Current onboarding state. Read only: never touches chain or provider. */ +export async function getOnboarding(actor: Actor, id: string): Promise { + return onboardingResponse(actor, id); +} + +/** + * Accepts the owner's signature over the prepared request. The signature is verified locally + * before any sponsor key is used; a passkey/EVM `0s` wallet is initialized only after that proof. + */ +export async function submitOnboarding( + actor: Actor, + id: string, + signed: OnboardingSignature, +): Promise { + const { agent, operation, stored } = await onboardingState(actor, id); + if ( + agent.lifecycle !== "pending" || + operation.status !== "pending" || + stored.status !== "pending_wallet_signature" + ) + throw new ApiError("onboarding_not_pending", 409); + if (Date.parse(stored.expires_at) <= Date.now()) { + await failOnboarding(actor.tenantId, id, operation, stored, "onboarding_expired"); + throw new ApiError("onboarding_expired", 409); + } + const owner = stored.owner; + const { near, signer } = signerFor(owner); + await dispatchPreparedPolicy({ + actor, + agentId: id, + operationId: operation.id, + signer, + prepared: { ...stored, status: "pending_wallet_signature" }, + stored: operation.result, + signed, + afterVerify: async () => { + if (owner.type === "near") await verifyNearFullAccessKey(near.accountId, near.publicKey); + else await assertOwnerWalletCode(owner); + }, + afterClaim: async () => { + if (owner.type !== "near") await ensureOwnerWalletInitialized(owner, near.accountId); + }, + }); + return reconcileOnboarding(actor, id); +} + +/** Observation only: confirms a submitted request, or retires one that can no longer land. */ +export async function refreshOnboarding(actor: Actor, id: string): Promise { + return reconcileOnboarding(actor, id); +} + +async function reconcileOnboarding(actor: Actor, id: string): Promise { + const { operation, stored } = await onboardingState(actor, id); + if (operation.status === "completed" || operation.status === "failed") + return onboardingResponse(actor, id); + if (operation.status === "pending") { + if (Date.parse(stored.expires_at) <= Date.now()) + await failOnboarding(actor.tenantId, id, operation, stored, "onboarding_expired"); + return onboardingResponse(actor, id); + } + const settlement = await settlePolicyWrite(stored.owner.type, operation, () => + onboardingConfirmed(actor, id, stored), + ); + if (settlement.settle === "failed") + // The hash is persisted before broadcast, so a long-claimed request without one never left. + await failOnboarding( + actor.tenantId, + id, + operation, + stored, + settlement.failureCode === policyFailureCodes.not_broadcast + ? "onboarding_not_broadcast" + : settlement.failureCode, + settlement.funding, + ); + if (settlement.settle === "applied") + await activateOnboarding(actor, id, operation, stored, settlement.transactionHash); + return onboardingResponse(actor, id); +} + +/** Whether provider readback and the finalized on-chain owner confirm the onboarding policy. */ +async function onboardingConfirmed(actor: Actor, id: string, stored: StoredOnboarding) { + const record = await latestPolicy(actor.tenantId, id); + if (!record || record.id !== stored.policy_id) + throw new ApiError("policy_revision_conflict", 409); + return providerConfirmsPolicy({ + wallet: await requireActiveWallet(actor.tenantId, id), + expectedOwner: signerFor(stored.owner).near.accountId, + policy: policySchema.parse(record.rules), + }); +} + +/** Binds the owner and activates the agent once provider readback confirmed the final policy. */ +async function activateOnboarding( + actor: Actor, + id: string, + operation: OperationRecord, + stored: StoredOnboarding, + transactionHash: string, +) { + const { near } = signerFor(stored.owner); + // Owner binding, first policy, its epoch and the completed operation commit together. + await finalizePolicyOperation({ + tenantId: actor.tenantId, + agentId: id, + operationId: operation.id, + policyId: stored.policy_id, + observed: operation, + outcome: { status: "applied", transactionHash }, + result: { + ...(operation.result as object), + status: "applied", + transaction_hash: transactionHash, + provider_policy_readback: true, + }, + bindOwner: (tx) => + bindOnboardingOwner(tx, { + tenantId: actor.tenantId, + id, + ownerAccountId: near.accountId, + ownerPublicKey: near.publicKey, + ownerIdentity: stored.owner, + ownerNear: near, + }), + }); +} + +async function failOnboarding( + tenantId: string, + agentId: string, + operation: OperationRecord, + stored: StoredOnboarding, + code: string, + funding?: StorageFunding, +) { + await finalizePolicyOperation({ + tenantId, + agentId, + operationId: operation.id, + policyId: stored.policy_id, + observed: operation, + outcome: { status: "failed", failureCode: code }, + result: { + ...(operation.result as object), + ...(funding ? { storage_funding: funding } : {}), + status: "failed", + failure_code: code, + }, + onFailed: async (tx) => { + await abandonAgentInTransaction(tx, tenantId, agentId, code); + }, + }); +} + +async function onboardingState(actor: Actor, id: string) { + const agent = await loadAgent(actor, id); + const operation = await findOperation( + actor.tenantId, + id, + onboardingOperationId(actor.tenantId, id), + ); + const stored = storedOnboardingSchema.safeParse(operation?.result); + if (!operation || !stored.success) throw new ApiError("onboarding_not_found", 404); + return { agent, operation, stored: stored.data }; +} + +async function onboardingResponse(actor: Actor, id: string): Promise { + const { agent, operation, stored } = await onboardingState(actor, id); + const record = await findPolicyRecord(actor.tenantId, id, stored.policy_id); + const policy: Policy | null = + record?.id === stored.policy_id ? policySchema.parse(record.rules) : null; + if (!policy) throw new ApiError("onboarding_not_found", 404); + return { + agent: await agentView(actor, agent), + onboarding: toOnboardingView(agent, operation, stored, policy), + }; +} + +function toOnboardingView( + agent: Agent, + operation: OperationRecord, + stored: StoredOnboarding, + policy: Policy, +): OnboardingView { + const signable = stored.status === "pending_wallet_signature" && agent.lifecycle === "pending"; + return { + operation_id: operation.id, + status: stored.status, + owner: stored.owner, + controller_id: resolveOwnerWallet(stored.owner).accountId, + wallet_id: stored.wallet_id, + policy_hash: stored.policy_hash, + policy, + expires_at: stored.expires_at, + transaction_hash: stored.transaction_hash ?? null, + failure_code: stored.failure_code ?? null, + ...(signable && stored.near_policy_request + ? { near_policy_request: stored.near_policy_request } + : {}), + ...(signable && stored.wallet_request ? { wallet_request: stored.wallet_request } : {}), + }; +} diff --git a/packages/agents-core/src/modules/agents/owner-admin-command.ts b/packages/agents-core/src/modules/agents/owner-admin-command.ts new file mode 100644 index 000000000..9f29ec3f7 --- /dev/null +++ b/packages/agents-core/src/modules/agents/owner-admin-command.ts @@ -0,0 +1,91 @@ +import type { + AgentAdminCommandMessage, + OwnerProof, + OwnerWallet, +} from "@near-intents-agent-api/contracts"; +import { getRuntime } from "../../config/runtime.js"; +import type { Actor } from "../../shared/actor.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { issueOwnerNonce } from "../../shared/nonces.js"; +import { + ownerEnvelopeMismatch, + ownerMessageMaxLifetimeMs, + ownerMessageWindowInvalid, +} from "../../shared/owner-message.js"; +import { verifyBoundOwner } from "./owner-authorization.js"; +import type { AgentRecord } from "./repository.js"; +import { ownerMessageRecipient } from "./service.js"; + +export type OwnerAdminAction = AgentAdminCommandMessage["action"]; +export type OwnerAdminMessageFor = Extract< + AgentAdminCommandMessage, + { action: A } +>; + +/** Issues one exact owner consent envelope for an owner-only command. */ +export async function ownerAdminCommandChallenge( + actor: Actor, + agent: AgentRecord, + action: A, + targetId: string, +): Promise> { + if (!agent.ownerIdentity) throw new ApiError("agent_not_bound", 409); + const issuedAtMs = Date.now(); + return { + domain: "near-intents-agent-api.owner-admin.v2", + action, + owner: agent.ownerIdentity, + tenant_id: actor.tenantId, + agent_id: agent.id, + network: getRuntime().network, + owner_epoch: agent.ownerEpoch, + target_id: targetId, + recipient: ownerMessageRecipient(), + nonce: await issueOwnerNonce(actor.tenantId, agent.id), + issued_at_ms: issuedAtMs, + expires_at_ms: issuedAtMs + ownerMessageMaxLifetimeMs, + } as OwnerAdminMessageFor; +} + +/** Rejects stale or redirected commands before proof verification or state changes. */ +export function assertOwnerAdminCommand( + actor: Actor, + agent: AgentRecord, + action: OwnerAdminAction, + targetId: string, + message: AgentAdminCommandMessage, +) { + if (message.domain !== "near-intents-agent-api.owner-admin.v2") + throw new ApiError("owner_command_domain_invalid", 409); + if ( + ownerEnvelopeMismatch(message, actor.tenantId, agent.id) || + message.recipient !== ownerMessageRecipient() + ) + throw new ApiError("owner_mismatch", 409); + if (message.action !== action || message.target_id !== targetId) + throw new ApiError("owner_command_target_mismatch", 409); + if (message.owner_epoch !== agent.ownerEpoch) throw new ApiError("owner_command_stale", 409); + if (ownerMessageWindowInvalid(message, Date.now())) + throw new ApiError("owner_command_expired", 409); +} + +export async function verifyOwnerAdminCommand( + agent: AgentRecord, + message: AgentAdminCommandMessage, + proof: OwnerProof, +) { + return verifyBoundOwner(agent, message, proof); +} + +/** Stable audit principal, separate from the tenant API key that submitted the command. */ +export function ownerPrincipalId(owner: OwnerWallet): string { + switch (owner.type) { + case "near": + return `near:${owner.accountId}:${owner.publicKey}`; + case "evm": + return `evm:${owner.chainId}:${owner.address}`; + case "passkey": + return `passkey:${hashSecret(owner.credentialId)}`; + } +} diff --git a/packages/agents-core/src/modules/agents/owner-authorization.ts b/packages/agents-core/src/modules/agents/owner-authorization.ts new file mode 100644 index 000000000..ad65036a3 --- /dev/null +++ b/packages/agents-core/src/modules/agents/owner-authorization.ts @@ -0,0 +1,50 @@ +import { canonical, type OwnerProof, type OwnerWallet } from "@near-intents-agent-api/contracts"; +import { ApiError } from "../../shared/errors.js"; +import { assertOwnerIdentity, verifyOwnerProof } from "../../shared/owner-proof.js"; +import { verifyWalletAuthorization } from "../../shared/wallet-authorization.js"; +import { type AgentRecord, advanceOwnerCounter } from "./repository.js"; +import { assertOwnerWalletCode } from "./wallet-initialization.js"; + +export async function verifyBoundOwner( + agent: AgentRecord, + message: { owner: OwnerWallet; nonce: string; recipient: string }, + proof: OwnerProof, +) { + assertOwnerIdentity(agent.ownerIdentity, message.owner); + if ("walletAuthorization" in proof) { + if (!agent.ownerAccountId) throw new ApiError("agent_not_bound", 409); + if (message.owner.type !== "passkey" && message.owner.type !== "evm") + throw new ApiError("wallet_authorization_invalid", 401); + const blockId = await assertOwnerWalletCode(message.owner); + await verifyWalletAuthorization({ + accountId: agent.ownerAccountId, + authorization: proof.walletAuthorization, + expectedPayload: canonical(message), + blockId, + }); + return { + verifier: "wallet-authorization.v1", + blockId, + ownerAccountId: agent.ownerAccountId, + owner: message.owner, + }; + } + if (message.owner.type === "passkey" || message.owner.type === "evm") + throw new ApiError("wallet_authorization_required", 401); + const counter = await verifyOwnerProof({ + owner: message.owner, + message, + proof, + counter: agent.ownerCounter, + }); + if (counter === 0) return { verifier: "owner-proof.v1", owner: message.owner }; + const updated = await advanceOwnerCounter({ + tenantId: agent.tenantId, + id: agent.id, + ownerIdentity: message.owner, + previousCounter: agent.ownerCounter, + counter, + }); + if (!updated) throw new ApiError("owner_counter_conflict", 409); + return { verifier: "owner-proof.v1", owner: message.owner, counter }; +} diff --git a/packages/agents-core/src/modules/agents/owner-receipts.ts b/packages/agents-core/src/modules/agents/owner-receipts.ts new file mode 100644 index 000000000..436c3a2d7 --- /dev/null +++ b/packages/agents-core/src/modules/agents/owner-receipts.ts @@ -0,0 +1,40 @@ +import { canonical, type OwnerProof } from "@near-intents-agent-api/contracts"; +import { ownerReceipts, type Tx } from "@near-intents-agent-api/database"; +import { sql } from "drizzle-orm"; +import { hashSecret } from "../../shared/crypto.js"; + +/** Called only after proof verification, inside the transaction applying its effect. */ +export async function recordOwnerReceipt( + tx: Tx, + input: { + tenantId: string; + agentId: string; + action: string; + targetId: string; + /** Epoch verified for the effect; kept outside the unmodified signed message. */ + ownerEpoch: number; + message: { nonce: string }; + proof: OwnerProof; + verification: unknown; + original?: unknown; + }, +) { + const payload = canonical(input.message); + await tx.insert(ownerReceipts).values({ + id: hashSecret( + canonical({ tenantId: input.tenantId, agentId: input.agentId, nonce: input.message.nonce }), + ), + tenantId: input.tenantId, + agentId: input.agentId, + action: input.action, + targetId: input.targetId, + ownerEpoch: input.ownerEpoch, + nonce: input.message.nonce, + payloadHash: hashSecret(payload), + message: input.message, + proof: input.proof, + original: input.original ?? null, + verification: input.verification, + decidedAt: sql`clock_timestamp()`, + }); +} diff --git a/packages/agents-core/src/modules/agents/repository.ts b/packages/agents-core/src/modules/agents/repository.ts new file mode 100644 index 000000000..dfe84bf37 --- /dev/null +++ b/packages/agents-core/src/modules/agents/repository.ts @@ -0,0 +1,233 @@ +import type { OwnerNear, OwnerWallet } from "@near-intents-agent-api/contracts"; +import { + type AgentLifecycle, + agents, + auditEvents, + type Tx, + tenants, +} from "@near-intents-agent-api/database"; +import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; + +export type AgentRecord = typeof agents.$inferSelect; + +/** Repositories cannot map HTTP errors (see architecture.test.ts); the caller translates this. */ +export class AgentQuotaExceededError extends Error { + constructor( + code: "agent_quota_exceeded" | "agent_creation_quota_exceeded" = "agent_quota_exceeded", + ) { + super(code); + } +} + +export async function findAgent(tenantId: string, id: string) { + return ( + await getDatabase() + .select() + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, id))) + )[0]; +} + +/** + * Cursor page over one tenant's agents, newest first. The cursor is the last id of the previous + * page. `(created_at, id)` is compared as a row value so agents created in the same millisecond + * are neither skipped nor repeated. A caller that pages through every agent is no longer + * silently truncated at the old fixed limit of 100. + */ +export async function listAgentViews( + tenantId: string, + filter: { externalUserId?: string; after?: string; limit?: number } = {}, +) { + const limit = Math.min(Math.max(filter.limit ?? 50, 1), 200); + const cursor = filter.after + ? sql`(${agents.createdAt}, ${agents.id}) < ( + select cursor.created_at, cursor.id from ${agents} cursor + where cursor.tenant_id = ${tenantId} and cursor.id = ${filter.after} + )` + : undefined; + return getDatabase() + .select() + .from(agents) + .where( + and( + eq(agents.tenantId, tenantId), + filter.externalUserId ? eq(agents.externalUserId, filter.externalUserId) : undefined, + cursor, + ), + ) + .orderBy(desc(agents.createdAt), desc(agents.id)) + .limit(limit); +} + +/** + * Lifecycles that still hold a provider custody wallet: a `pending` agent's wallet is provisioned + * before the owner signs, and archiving does not deprovision. Deletion releases the wallet. + * `abandoned` agents are not counted so a failed onboarding does not consume usable capacity; their + * wallets are never deprovisioned, so this bounds live agents, not wallets ever created. + */ +const countableAgentLifecycles: AgentLifecycle[] = ["pending", "active", "archived"]; + +export async function insertAgent( + input: { + id: string; + tenantId: string; + name: string; + externalUserId: string | null; + }, + limit: number, + creationLimit?: number, +) { + await getDatabase().transaction(async (tx) => { + // Locking the tenant row serializes concurrent agent creation for this tenant, so two + // requests that both read the count as under the cap cannot both insert and overshoot it. + // The lock (and the whole check) happens before any provider call: `createAgent` in + // onboarding-service.ts only provisions the custody wallet after this transaction commits, so + // an over-quota request never reaches the provider and this lock never spans that call. + const [tenant] = await tx + .select({ id: tenants.id, profile: tenants.quotaProfile }) + .from(tenants) + .where(eq(tenants.id, input.tenantId)) + .for("update"); + if (!tenant) throw new Error("insertAgent: tenant not found"); + limit = tenant.profile.liveAgents ?? limit; + creationLimit = tenant.profile.lifetimeAgents ?? creationLimit; + // A failed onboarding or deletion frees live capacity, but must not reset a lifetime + // creation budget. Count every retained admission, even if provider registration failed. + if (creationLimit !== undefined) { + const [created] = await tx + .select({ total: count() }) + .from(agents) + .where(eq(agents.tenantId, input.tenantId)); + if ((created?.total ?? 0) >= creationLimit) + throw new AgentQuotaExceededError("agent_creation_quota_exceeded"); + } + const [row] = await tx + .select({ total: count() }) + .from(agents) + .where( + and( + eq(agents.tenantId, input.tenantId), + inArray(agents.lifecycle, countableAgentLifecycles), + ), + ); + if ((row?.total ?? 0) >= limit) throw new AgentQuotaExceededError(); + await tx.insert(agents).values(input); + }); +} + +/** Provenance attached to a lifecycle or authorization event, for independent reconstruction. */ +export type AuditProvenance = Partial<{ + actorKeyId: string; + grantId: string; + ownerEpoch: number; + policyEpoch: number; + lifecycleEpoch: number; + requestHash: string; + providerReference: string; +}>; + +/** + * The only way an agent gains an owner: its onboarding transaction finalized and the provider + * confirmed the policy. Runs inside the policy finalization transaction, so owner binding, first + * policy and activation commit together, and only for a still-pending agent, so a replay cannot + * rebind or reactivate anything. + */ +export async function bindOnboardingOwner( + tx: Tx, + input: { + tenantId: string; + id: string; + ownerAccountId: string; + ownerPublicKey: string; + ownerIdentity: OwnerWallet; + ownerNear: OwnerNear; + }, +): Promise { + const updated = await tx + .update(agents) + .set({ + ownerAccountId: input.ownerAccountId, + ownerPublicKey: input.ownerPublicKey, + ownerIdentity: input.ownerIdentity, + ownerNear: input.ownerNear, + lifecycle: "active", + }) + .where( + and( + eq(agents.tenantId, input.tenantId), + eq(agents.id, input.id), + eq(agents.lifecycle, "pending"), + isNull(agents.ownerAccountId), + ), + ) + .returning({ id: agents.id }); + if (!updated.length) return false; + await tx.insert(auditEvents).values({ + tenantId: input.tenantId, + agentId: input.id, + action: "owner.bound", + resourceId: input.ownerAccountId, + }); + return true; +} + +/** A pending agent whose onboarding expired or failed never becomes usable. */ +export async function abandonAgent(tenantId: string, id: string, reason: string) { + await getDatabase().transaction((tx) => abandonAgentInTransaction(tx, tenantId, id, reason)); +} + +/** Returns whether this call abandoned the agent; one already past `pending` is left alone. */ +export async function abandonAgentInTransaction( + tx: Tx, + tenantId: string, + id: string, + reason: string, +): Promise { + const updated = await tx + .update(agents) + .set({ lifecycle: "abandoned" }) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, id), eq(agents.lifecycle, "pending"))) + .returning({ id: agents.id }); + if (!updated.length) return false; + await tx + .insert(auditEvents) + .values({ tenantId, agentId: id, action: "agent.abandoned", resourceId: reason }); + return true; +} + +/** Columns written from provenance. Kept in one place so no caller records a partial record. */ +export function auditProvenance(provenance: AuditProvenance | undefined) { + if (!provenance) return {}; + return { + actorKeyId: provenance.actorKeyId ?? null, + grantId: provenance.grantId ?? null, + ownerEpoch: provenance.ownerEpoch ?? null, + policyEpoch: provenance.policyEpoch ?? null, + lifecycleEpoch: provenance.lifecycleEpoch ?? null, + requestHash: provenance.requestHash ?? null, + providerReference: provenance.providerReference ?? null, + }; +} + +export async function advanceOwnerCounter(input: { + tenantId: string; + id: string; + ownerIdentity: OwnerWallet; + previousCounter: number; + counter: number; +}): Promise { + const updated = await getDatabase() + .update(agents) + .set({ ownerCounter: input.counter }) + .where( + and( + eq(agents.tenantId, input.tenantId), + eq(agents.id, input.id), + eq(agents.ownerCounter, input.previousCounter), + eq(agents.ownerIdentity, input.ownerIdentity), + ), + ) + .returning({ id: agents.id }); + return updated.length > 0; +} diff --git a/packages/agents-core/src/modules/agents/service.ts b/packages/agents-core/src/modules/agents/service.ts new file mode 100644 index 000000000..a039c5a48 --- /dev/null +++ b/packages/agents-core/src/modules/agents/service.ts @@ -0,0 +1,102 @@ +import type { OwnerWallet } from "@near-intents-agent-api/contracts"; +import { + deriveEvmWallet, + derivePasskeyWallet, + evmAddressFromPublicKey, +} from "@near-intents-agent-api/owner-auth"; +import { getRuntime } from "../../config/runtime.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { findCustodyWallet } from "../wallet/repository.js"; +import { requireActiveWallet } from "../wallet/service.js"; +import { isDeleted } from "./lifecycle-repository.js"; +import { toAgentDto } from "./mapper.js"; +import { type AgentRecord, findAgent, listAgentViews } from "./repository.js"; + +export type Agent = AgentRecord; + +export async function loadAgent(actor: Actor, id: string): Promise { + const row = await findAgent(actor.tenantId, id); + if (!row) throw new ApiError("agent_not_found", 404); + return row; +} + +export async function agentView(actor: Actor, agent: AgentRecord) { + return toAgentDto(agent, await findCustodyWallet(actor.tenantId, agent.id)); +} + +export async function getAgentView(actor: Actor, id: string) { + return agentView(actor, await loadAgent(actor, id)); +} + +/** Cursor page of this tenant's agents. `next_cursor` is echoed for the next `after`. */ +export async function listAgents( + actor: Actor, + filter: { externalUserId?: string; after?: string } = {}, +) { + const records = await listAgentViews(actor.tenantId, { + externalUserId: filter.externalUserId, + after: filter.after, + limit: 51, + }); + const page = records.slice(0, 50); + return { + agents: await Promise.all(page.map((record) => agentView(actor, record))), + next_cursor: records.length > 50 ? (page.at(-1)?.id ?? null) : null, + }; +} + +/** NEP-413 recipient is the service hostname; owners sign it into every owner message. */ +export function ownerMessageRecipient() { + return new URL(getRuntime().serviceUrl).hostname; +} + +export function resolveOwnerWallet(owner: OwnerWallet) { + if (owner.type === "near") + return { accountId: owner.accountId, publicKey: owner.publicKey, authority: "wallet" as const }; + if (getRuntime().network !== "mainnet") throw new ApiError("wallet_network_unsupported", 409); + if (owner.type === "passkey") { + const wallet = derivePasskeyWallet(owner.publicKey); + return { + accountId: wallet.accountId, + publicKey: wallet.publicKey, + authority: "wallet" as const, + }; + } + if (owner.type === "evm") { + if (evmAddressFromPublicKey(owner.publicKey) !== owner.address) + throw new ApiError("evm_public_key_mismatch", 409); + const wallet = deriveEvmWallet(owner.publicKey); + return { + accountId: wallet.accountId, + publicKey: wallet.publicKey, + authority: "wallet" as const, + }; + } + throw new ApiError("owner_type_unsupported", 400); +} + +export async function requireBoundAgent(actor: Actor, id: string) { + return requireBoundTenantAgent(actor.tenantId, id); +} + +async function requireBound(tenantId: string, id: string) { + const agent = await findAgent(tenantId, id); + if (!agent) throw new ApiError("agent_not_found", 404); + if (await isDeleted(tenantId, id)) throw new ApiError("agent_deleted", 409); + if (!agent.ownerIdentity || !agent.ownerNear || !agent.ownerAccountId || !agent.ownerPublicKey) + throw new ApiError("agent_not_bound", 409); + return agent; +} + +/** The bound owner identity, without custody state. Readiness checks use this. */ +export async function requireBoundOwnerAgent(tenantId: string, id: string) { + return requireBound(tenantId, id); +} + +/** The bound owner identity plus its active custody wallet, for anything that will write. */ +export async function requireBoundTenantAgent(tenantId: string, id: string) { + const agent = await requireBound(tenantId, id); + const wallet = await requireActiveWallet(tenantId, id); + return { agent, wallet }; +} diff --git a/packages/agents-core/src/modules/agents/wallet-initialization.ts b/packages/agents-core/src/modules/agents/wallet-initialization.ts new file mode 100644 index 000000000..9a4e47634 --- /dev/null +++ b/packages/agents-core/src/modules/agents/wallet-initialization.ts @@ -0,0 +1,171 @@ +import type { OwnerWallet } from "@near-intents-agent-api/contracts"; +import { + deriveEvmWallet, + derivePasskeyWallet, + EVM_WALLET_CODE_ID, + PASSKEY_WALLET_CODE_ID, +} from "@near-intents-agent-api/owner-auth"; +import { JsonRpcProvider } from "near-api-js"; +import { getRuntime } from "../../config/runtime.js"; +import { withAdvisoryLock } from "../../lib/db.js"; +import { withSponsorKey } from "../../lib/sponsor-pool.js"; +import { requireWalletSponsor } from "../../lib/wallet-sponsor.js"; +import { ApiError } from "../../shared/errors.js"; + +/** Pinned mainnet owner-wallet code; see docs/architecture.md#owners-and-wallets. */ +const EXPECTED_OWNER_WALLET_CODE_HASH: Record = { + [PASSKEY_WALLET_CODE_ID]: "qD9cxbe38rJn7BwUBtqaC2vVAiYD7TS4vnrafccHsRp", + [EVM_WALLET_CODE_ID]: "FkAmDpjc2HaoFmU9xwgG6x5oJUXnpxAREtTMZi5UcgRy", +}; + +/** Global contract code is at most a few MiB; base64 plus the JSON envelope stays well below. */ +const codeCheckMaxResponseBytes = 8 * 1_048_576; +const codeCheckTimeoutMs = 10_000; + +/** Reads at most `codeCheckMaxResponseBytes` and cancels the stream beyond it. */ +async function readBoundedJson(response: Response): Promise { + if (!response.body) return undefined; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > codeCheckMaxResponseBytes) return undefined; + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** A code-identity answer from a cleartext remote or redirected endpoint is not evidence. */ +function trustedRpcUrl(url: string): boolean { + const parsed = new URL(url); + return ( + parsed.protocol === "https:" || + (parsed.protocol === "http:" && loopbackHosts.has(parsed.hostname)) + ); +} + +/** One bounded RPC read; a block hash is reused for every authorization observation. */ +async function rpcRead(url: string, method: string, params: unknown): Promise { + if (!trustedRpcUrl(url)) return; + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: "owner-wallet-code-check", method, params }), + redirect: "error", + credentials: "omit", + signal: AbortSignal.timeout(codeCheckTimeoutMs), + }); + if (!response.ok) { + await response.body?.cancel(); + return; + } + return readBoundedJson(response); +} + +/** Hash and proof must use the same finalized block, even across RPC failover. */ +async function publishedCodeHash( + url: string, + accountId: string, +): Promise<{ hash: string; blockId: string } | undefined> { + const block = (await rpcRead(url, "block", { finality: "final" })) as + | { result?: { header?: { hash?: unknown } } } + | undefined; + const blockId = block?.result?.header?.hash; + if (typeof blockId !== "string" || !blockId) return; + const code = (await rpcRead(url, "query", { + request_type: "view_global_contract_code_by_account_id", + block_id: blockId, + account_id: accountId, + })) as { result?: { hash?: unknown; block_hash?: unknown } } | undefined; + const result = code?.result; + if (typeof result?.hash !== "string") return; + if (result.block_hash !== blockId) return; + return { hash: result.hash, blockId }; +} + +export async function assertOwnerWalletCode( + owner: Extract, +): Promise { + const accountId = owner.type === "passkey" ? PASSKEY_WALLET_CODE_ID : EVM_WALLET_CODE_ID; + const expectedHash = EXPECTED_OWNER_WALLET_CODE_HASH[accountId]; + if (!expectedHash) throw new ApiError("wallet_code_untrusted", 409); + + for (const url of getRuntime().nearRpcUrls) { + let observation: Awaited>; + try { + observation = await publishedCodeHash(url, accountId); + } catch { + // Timeout, redirect, oversized or malformed response: try the next endpoint. + } + if (!observation) continue; + if (observation.hash !== expectedHash) throw new ApiError("wallet_code_untrusted", 409); + return observation.blockId; + } + throw new ApiError("wallet_rpc_unavailable", 503); +} + +function isUnknownAccount(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { name?: string; cause?: { name?: string } }; + return ( + candidate.name === "AccountDoesNotExistError" || candidate.cause?.name === "UNKNOWN_ACCOUNT" + ); +} + +/** Check code identity, rather than trusting a syntactically valid 0s address. */ +async function accountState(accountId: string) { + for (const url of getRuntime().nearRpcUrls) { + try { + return await new JsonRpcProvider({ url }).viewAccount({ + accountId, + blockQuery: { finality: "final" }, + }); + } catch (error) { + if (isUnknownAccount(error)) return null; + } + } + throw new ApiError("wallet_rpc_unavailable", 503); +} + +/** + * Makes the owner's deterministic `0s` wallet exist with the pinned wallet code. Callers invoke + * this only after verifying the owner's signature over the exact request the wallet will execute, + * so the sponsor never funds an account for an unproven key. + */ +export async function ensureOwnerWalletInitialized( + owner: Extract, + accountId: string, +) { + const wallet = + owner.type === "passkey" + ? derivePasskeyWallet(owner.publicKey) + : deriveEvmWallet(owner.publicKey); + const codeId = owner.type === "passkey" ? PASSKEY_WALLET_CODE_ID : EVM_WALLET_CODE_ID; + if (wallet.accountId !== accountId) throw new ApiError("wallet_account_mismatch", 409); + await assertOwnerWalletCode(owner); + const existing = await accountState(wallet.accountId); + if (existing) { + if (existing.global_contract_account_id !== codeId) + throw new ApiError("wallet_code_mismatch", 409); + return; + } + const sponsor = requireWalletSponsor(); + await withSponsorKey(sponsor.accountId, () => + withAdvisoryLock(`wallet-init:${wallet.accountId}`, async () => { + const current = await accountState(wallet.accountId); + if (!current) await sponsor.initialize(wallet.accountId, wallet.stateInitBorsh); + }), + ); + const created = await accountState(wallet.accountId); + if (created?.global_contract_account_id !== codeId) + throw new ApiError("wallet_initialization_unconfirmed", 409); +} diff --git a/packages/agents-core/src/modules/developer/management-repository.ts b/packages/agents-core/src/modules/developer/management-repository.ts new file mode 100644 index 000000000..f45df09aa --- /dev/null +++ b/packages/agents-core/src/modules/developer/management-repository.ts @@ -0,0 +1,67 @@ +import { + agents, + custodyWallets, + operations, + walletPolicies, +} from "@near-intents-agent-api/database"; +import { and, asc, count, eq, gt, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; + +export async function findManagedAgents(tenantId: string, after?: string) { + const db = getDatabase(); + const rows = await db + .select({ + id: agents.id, + name: agents.name, + externalUserId: agents.externalUserId, + ownerAccountId: agents.ownerAccountId, + walletStatus: custodyWallets.status, + providerWalletId: custodyWallets.providerWalletId, + policyStatus: sql`( + select ${walletPolicies.status} from ${walletPolicies} + where ${walletPolicies.tenantId} = ${agents.tenantId} + and ${walletPolicies.agentId} = ${agents.id} + and ${walletPolicies.status} NOT IN ('draft', 'discarded') + order by ${walletPolicies.version} desc limit 1 + )`, + }) + .from(agents) + .leftJoin( + custodyWallets, + and(eq(custodyWallets.tenantId, agents.tenantId), eq(custodyWallets.agentId, agents.id)), + ) + .where(and(eq(agents.tenantId, tenantId), after ? gt(agents.id, after) : undefined)) + .orderBy(asc(agents.id)) + .limit(51); + return rows; +} + +export async function countManagedResources(tenantId: string) { + const db = getDatabase(); + const [[agentCount], [operationCount]] = await Promise.all([ + db.select({ total: count() }).from(agents).where(eq(agents.tenantId, tenantId)), + db + .select({ + total: count(), + pending: sql`count(*) filter (where ${operations.status} = 'pending')`.mapWith( + Number, + ), + uncertain: sql`count(*) filter (where ${operations.status} = 'uncertain')`.mapWith( + Number, + ), + settled: + sql`count(*) filter (where ${operations.kind} = 'execute' and ${operations.status} = 'completed')`.mapWith( + Number, + ), + }) + .from(operations) + .where(eq(operations.tenantId, tenantId)), + ]); + return { + agents: agentCount?.total ?? 0, + operations: operationCount?.total ?? 0, + pending: operationCount?.pending ?? 0, + uncertain: operationCount?.uncertain ?? 0, + settled: operationCount?.settled ?? 0, + }; +} diff --git a/packages/agents-core/src/modules/developer/management-service.ts b/packages/agents-core/src/modules/developer/management-service.ts new file mode 100644 index 000000000..bf4a7bfc6 --- /dev/null +++ b/packages/agents-core/src/modules/developer/management-service.ts @@ -0,0 +1,29 @@ +import type { ManagedAgent, ManagementSummary } from "@near-intents-agent-api/contracts"; +import { countManagedResources, findManagedAgents } from "./management-repository.js"; + +export async function listManagedAgents( + tenantId: string, + after?: string, +): Promise<{ agents: ManagedAgent[]; nextCursor: string | null }> { + const rows = await findManagedAgents(tenantId, after); + const page = rows.slice(0, 50).map( + (row): ManagedAgent => ({ + id: row.id, + name: row.name, + externalUserId: row.externalUserId, + ownerAccountId: row.ownerAccountId, + ownerWalletBound: row.ownerAccountId !== null, + walletProvisioned: row.walletStatus === "active", + policyStatus: (row.policyStatus ?? "none") as ManagedAgent["policyStatus"], + }), + ); + return { agents: page, nextCursor: rows.length > 50 ? (page.at(-1)?.id ?? null) : null }; +} + +export async function managementSummary(tenantId: string): Promise { + return { + ...(await countManagedResources(tenantId)), + observedAt: new Date().toISOString(), + period: "all_time", + }; +} diff --git a/packages/agents-core/src/modules/developer/mapper.ts b/packages/agents-core/src/modules/developer/mapper.ts new file mode 100644 index 000000000..543bd0fdc --- /dev/null +++ b/packages/agents-core/src/modules/developer/mapper.ts @@ -0,0 +1,11 @@ +import type { ApiKeyListRow } from "./repository.js"; + +export function toApiKeyDto(row: ApiKeyListRow) { + return { + id: row.id, + name: row.name, + prefix: row.prefix, + expiresAt: row.expiresAt.toISOString(), + revokedAt: row.revokedAt?.toISOString() ?? null, + }; +} diff --git a/packages/agents-core/src/modules/developer/quota-service.ts b/packages/agents-core/src/modules/developer/quota-service.ts new file mode 100644 index 000000000..6dfe226e2 --- /dev/null +++ b/packages/agents-core/src/modules/developer/quota-service.ts @@ -0,0 +1,120 @@ +import { + type PartnerQuotaProfile, + type PartnerQuotaView, + partnerQuotaProfileSchema, +} from "@near-intents-agent-api/contracts"; +import { + agents, + quotaChanges, + requestBuckets, + sponsorReservations, + type Tx, + tenants, +} from "@near-intents-agent-api/database"; +import { and, count, eq, gte, inArray, isNull } from "drizzle-orm"; +import { getRuntime } from "../../config/runtime.js"; +import { databaseClock, getDatabase } from "../../lib/db.js"; +import { newId } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; + +export function effectiveQuota(profile: PartnerQuotaProfile) { + const runtime = getRuntime(); + return { + liveAgents: profile.liveAgents ?? runtime.maxAgentsPerTenant ?? 50, + lifetimeAgents: profile.lifetimeAgents ?? runtime.maxCreatedAgentsPerTenant ?? null, + requestsPerMinute: profile.requestsPerMinute ?? runtime.tenantRequestsPerMinute ?? 600, + keyRequestsPerMinute: profile.keyRequestsPerMinute ?? runtime.keyRequestsPerMinute ?? 120, + sponsorDaily: profile.sponsorDaily ?? runtime.sponsorDailyTenantLimit, + sponsorDailyPerAgent: profile.sponsorDailyPerAgent ?? runtime.sponsorDailyAgentLimit, + sponsorDailyGlobal: runtime.sponsorDailyGlobalLimit, + }; +} + +export async function readPartnerQuota(tenantId: string, tx?: Tx): Promise { + if (!tx) + return getDatabase().transaction((snapshot) => readPartnerQuota(tenantId, snapshot), { + isolationLevel: "repeatable read", + accessMode: "read only", + }); + const [tenant] = await tx.select().from(tenants).where(eq(tenants.id, tenantId)); + if (!tenant) throw new ApiError("tenant_not_found", 404); + const now = await databaseClock(tx); + const [all] = await tx.select({ n: count() }).from(agents).where(eq(agents.tenantId, tenantId)); + const [live] = await tx + .select({ n: count() }) + .from(agents) + .where( + and( + eq(agents.tenantId, tenantId), + inArray(agents.lifecycle, ["pending", "active", "archived"]), + ), + ); + const [requests] = await tx + .select() + .from(requestBuckets) + .where( + and( + eq(requestBuckets.key, `tenant:${tenantId}`), + gte(requestBuckets.windowStart, new Date(Math.floor(now.getTime() / 60_000) * 60_000)), + ), + ); + const [sponsor] = await tx + .select({ n: count() }) + .from(sponsorReservations) + .where( + and( + eq(sponsorReservations.tenantId, tenantId), + isNull(sponsorReservations.releasedAt), + gte(sponsorReservations.createdAt, new Date(now.getTime() - 86_400_000)), + ), + ); + return { + revision: tenant.quotaRevision, + overrides: tenant.quotaProfile, + limits: effectiveQuota(tenant.quotaProfile), + usage: { + liveAgents: live?.n ?? 0, + lifetimeAgents: all?.n ?? 0, + requestsThisMinute: requests?.count ?? 0, + sponsorLast24Hours: sponsor?.n ?? 0, + }, + observedAt: now.toISOString(), + }; +} + +/** Operator CLI only. The DB credential is the administrative authority; no partner mutation route. */ +export async function setPartnerQuota(input: { + tenantId: string; + expectedRevision: number; + profile: PartnerQuotaProfile; + operator: string; + reason: string; +}) { + const profile = partnerQuotaProfileSchema.parse(input.profile); + if (!input.operator.trim() || !input.reason.trim()) + throw new ApiError("quota_audit_required", 400); + return getDatabase().transaction(async (tx) => { + const [tenant] = await tx + .select() + .from(tenants) + .where(eq(tenants.id, input.tenantId)) + .for("update"); + if (!tenant) throw new ApiError("tenant_not_found", 404); + if (tenant.quotaRevision !== input.expectedRevision) + throw new ApiError("quota_revision_conflict", 409); + await tx + .update(tenants) + .set({ quotaProfile: profile, quotaRevision: tenant.quotaRevision + 1 }) + .where(eq(tenants.id, input.tenantId)); + await tx.insert(quotaChanges).values({ + id: newId(), + tenantId: input.tenantId, + revision: tenant.quotaRevision + 1, + operator: input.operator, + reason: input.reason, + previous: tenant.quotaProfile, + profile, + }); + return { revision: tenant.quotaRevision + 1, profile }; + }); +} diff --git a/packages/agents-core/src/modules/developer/repository.ts b/packages/agents-core/src/modules/developer/repository.ts new file mode 100644 index 000000000..8856e4874 --- /dev/null +++ b/packages/agents-core/src/modules/developer/repository.ts @@ -0,0 +1,191 @@ +import type { Tx } from "@near-intents-agent-api/database"; +import { apiKeys, auditEvents, requestBuckets, tenants } from "@near-intents-agent-api/database"; +import { and, count, desc, eq, gt, isNull, lt, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import { + type CommittedOperations, + committedDelegatedOperations, +} from "../operations/dispatch-fence.js"; + +/** Repositories cannot map HTTP errors (see architecture.test.ts); the caller translates this. */ +export class ApiKeyQuotaExceededError extends Error { + constructor() { + super("api_key_quota_exceeded"); + } +} + +export type ApiKeyListRow = { + id: string; + name: string; + prefix: string; + expiresAt: Date; + revokedAt: Date | null; +}; + +export async function upsertTenant(userId: string, tenantId: string) { + const tenant = ( + await getDatabase() + .insert(tenants) + .values({ id: tenantId, ownerUserId: userId }) + .onConflictDoUpdate({ + target: tenants.ownerUserId, + set: { ownerUserId: userId }, + }) + .returning({ id: tenants.id }) + )[0]; + if (!tenant) throw new Error("Tenant persistence failed"); + return tenant.id; +} + +export function insertApiKey( + client: Tx, + input: { + id: string; + tenantId: string; + name: string; + tokenHash: string; + prefix: string; + expiresAt: Date; + }, +) { + return client.insert(apiKeys).values(input); +} + +export function insertAuditEvent( + client: Tx, + input: { tenantId: string; agentId?: string; action: string; resourceId: string }, +) { + return client.insert(auditEvents).values(input); +} + +/** Persists a developer-created API key within the tenant's cap on live keys. */ +export async function persistApiKey( + input: { + id: string; + tenantId: string; + name: string; + tokenHash: string; + prefix: string; + expiresAt: Date; + }, + limit: number, +) { + await getDatabase().transaction(async (tx) => { + // Locks the tenant row before counting, for the same race-free reason as `insertAgent`. + const [tenant] = await tx + .select({ id: tenants.id }) + .from(tenants) + .where(eq(tenants.id, input.tenantId)) + .for("update"); + if (!tenant) throw new Error("persistApiKey: tenant not found"); + const [row] = await tx + .select({ total: count() }) + .from(apiKeys) + .where( + and( + eq(apiKeys.tenantId, input.tenantId), + isNull(apiKeys.revokedAt), + gt(apiKeys.expiresAt, sql`now()`), + ), + ); + if ((row?.total ?? 0) >= limit) throw new ApiKeyQuotaExceededError(); + await insertApiKey(tx, input); + await insertAuditEvent(tx, { + tenantId: input.tenantId, + action: "api_key.created", + resourceId: input.id, + }); + }); +} + +export async function selectApiKeys(tenantId: string): Promise { + return getDatabase() + .select({ + id: apiKeys.id, + name: apiKeys.name, + prefix: apiKeys.prefix, + expiresAt: apiKeys.expiresAt, + revokedAt: apiKeys.revokedAt, + }) + .from(apiKeys) + .where(eq(apiKeys.tenantId, tenantId)) + .orderBy(desc(apiKeys.createdAt)) + .limit(100); +} + +export async function findApiKey(tokenHash: string) { + return ( + await getDatabase() + .select({ + id: apiKeys.id, + tenantId: apiKeys.tenantId, + }) + .from(apiKeys) + .where( + and( + eq(apiKeys.tokenHash, tokenHash), + isNull(apiKeys.revokedAt), + gt(apiKeys.expiresAt, sql`now()`), + ), + ) + )[0]; +} + +/** + * Fixed-window request counter for one bucket: `key:` per credential and + * `tenant:` for the tenant-wide ceiling. The per-key bucket keeps one noisy credential + * from consuming its siblings' share; the tenant bucket bounds the sum across all keys. + */ +export async function consumeQuota(bucketKey: string, tx?: Tx) { + const windowStart = sql`date_trunc('minute', now())`; + const usage = ( + await (tx ?? getDatabase()) + .insert(requestBuckets) + .values({ key: bucketKey, windowStart, count: 1 }) + .onConflictDoUpdate({ + target: requestBuckets.key, + set: { + count: sql`CASE WHEN ${requestBuckets.windowStart} = date_trunc('minute', now()) THEN ${requestBuckets.count} + 1 ELSE 1 END`, + windowStart, + }, + }) + .returning({ count: requestBuckets.count }) + )[0]; + if (!usage) throw new Error("Rate limit persistence failed"); + return usage.count; +} + +/** + * Removes rate-limit buckets from closed windows. A bucket is only meaningful for the minute it + * counts, so anything older is dead weight that would otherwise grow one row per key per minute + * forever. + */ +export async function sweepExpiredRequestBuckets() { + const deleted = await getDatabase() + .delete(requestBuckets) + .where(lt(requestBuckets.windowStart, sql`now() - interval '1 hour'`)) + .returning({ key: requestBuckets.key }); + return deleted.length; +} + +/** + * Revokes a key and returns the delegated operations it had already committed to dispatch. The + * update waits for in-flight dispatch commitments that share-lock this row, so every later + * commitment is refused and every earlier one is reported, up to the report bound. Null when the + * key does not exist. + */ +export async function revokeApiKey( + tenantId: string, + id: string, +): Promise { + return getDatabase().transaction(async (tx) => { + const result = await tx + .update(apiKeys) + .set({ revokedAt: sql`coalesce(${apiKeys.revokedAt}, now())` }) + .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, id))) + .returning({ id: apiKeys.id }); + if (!result.length) return null; + await insertAuditEvent(tx, { tenantId, action: "api_key.revoked", resourceId: id }); + return committedDelegatedOperations(tx, tenantId, { keyId: id }); + }); +} diff --git a/packages/agents-core/src/modules/developer/service.ts b/packages/agents-core/src/modules/developer/service.ts new file mode 100644 index 000000000..131ff2457 --- /dev/null +++ b/packages/agents-core/src/modules/developer/service.ts @@ -0,0 +1,100 @@ +import { tenants } from "@near-intents-agent-api/database"; +import { eq } from "drizzle-orm"; +import { getRuntime } from "../../config/runtime.js"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import { hashSecret, newApiToken, newId } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { toApiKeyDto } from "./mapper.js"; +import { effectiveQuota } from "./quota-service.js"; +import { + ApiKeyQuotaExceededError, + consumeQuota, + findApiKey, + persistApiKey, + revokeApiKey, + selectApiKeys, + upsertTenant, +} from "./repository.js"; + +export async function ensureDeveloperTenant(userId: string) { + return upsertTenant(userId, newId()); +} + +const defaultMaxApiKeysPerTenant = 50; + +export async function createApiKey(tenantId: string, input: { name: string; expiresAt: string }) { + const expiry = Date.parse(input.expiresAt); + if (expiry <= Date.now() || expiry > Date.now() + 366 * 86400000) + throw new ApiError("invalid_key_expiry"); + const token = newApiToken(); + const id = newId(); + try { + await persistApiKey( + { + id, + tenantId, + name: input.name, + tokenHash: hashSecret(token), + prefix: token.slice(0, 12), + expiresAt: new Date(input.expiresAt), + }, + getRuntime().maxApiKeysPerTenant ?? defaultMaxApiKeysPerTenant, + ); + } catch (error) { + if (error instanceof ApiKeyQuotaExceededError) throw new ApiError(error.message, 409); + throw error; + } + return { + id, + name: input.name, + prefix: token.slice(0, 12), + expiresAt: new Date(input.expiresAt).toISOString(), + revokedAt: null, + token, + }; +} + +export async function listApiKeys(tenantId: string) { + return (await selectApiKeys(tenantId)).map(toApiKeyDto); +} + +/** + * Revocation refuses every dispatch that has not committed. `committed_operation_ids` lists the + * delegated operations that committed first: the revocation cannot retract their provider writes. + * `committed_truncated` is true when more than the listed operations committed. + */ +export async function deleteApiKey(tenantId: string, id: string) { + const committed = await revokeApiKey(tenantId, id); + if (!committed) throw new ApiError("key_not_found", 404); + return { + revoked: true as const, + committed_operation_ids: committed.ids, + committed_truncated: committed.truncated, + }; +} + +export async function authenticateApiKey(token: string | undefined): Promise { + if (!token || !/^naa_[A-Za-z0-9_-]{43}$/.test(token)) throw new ApiError("invalid_api_key", 401); + const key = await findApiKey(hashSecret(token)); + if (!key) throw new ApiError("invalid_api_key", 401); + // Per key, so one noisy caller cannot starve its siblings; per tenant too, so minting more keys + // cannot multiply the tenant's allowance. + const exceeded = await getDatabase().transaction(async (tx) => { + const [tenant] = await tx + .select() + .from(tenants) + .where(eq(tenants.id, key.tenantId)) + .for("share"); + if (!tenant) throw new ApiError("invalid_api_key", 401); + const limits = effectiveQuota(tenant.quotaProfile); + const keyUsage = await consumeQuota(`key:${key.id}`, tx); + const tenantUsage = await consumeQuota(`tenant:${key.tenantId}`, tx); + return keyUsage > limits.keyRequestsPerMinute || tenantUsage > limits.requestsPerMinute; + }); + if (exceeded) throw new ApiError("rate_limited", 429); + return { + tenantId: key.tenantId, + keyId: key.id, + }; +} diff --git a/packages/agents-core/src/modules/intents/approval-recovery.ts b/packages/agents-core/src/modules/intents/approval-recovery.ts new file mode 100644 index 000000000..d377af395 --- /dev/null +++ b/packages/agents-core/src/modules/intents/approval-recovery.ts @@ -0,0 +1,67 @@ +import { approvalDetailSchema } from "@near-intents-agent-api/contracts"; +import { ownerIntents, type Tx } from "@near-intents-agent-api/database"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { getOutlayer } from "../../lib/outlayer.js"; +import { hashSecret } from "../../shared/crypto.js"; +import type { ApprovalVoteEvidence } from "../wallet/approval-service.js"; +import { commitOwnerIntent } from "./commit-service.js"; +import type { OwnerIntentRecord } from "./repository.js"; + +const acceptedVoteSchema = z.object({ + family: z.literal("approval"), + approvalId: z.string(), + verdict: z.enum(["approve", "reject"]), + requestHash: z.string(), + evidence: z.object({ + walletId: z.string(), + walletPublicKey: z.string(), + signatureHash: z.string(), + }), +}); + +/** Store only a fingerprint, atomically with acceptance and nonce consumption. */ +export async function acceptApprovalVote( + tx: Tx, + row: OwnerIntentRecord, + evidence: ApprovalVoteEvidence, +) { + await commitOwnerIntent(tx, row.tenantId, row.id, null, "submitted"); + await tx + .update(ownerIntents) + .set({ context: { ...z.record(z.string(), z.unknown()).parse(row.context), evidence } }) + .where(and(eq(ownerIntents.tenantId, row.tenantId), eq(ownerIntents.id, row.id))); +} + +/** Read-only reconciliation: a recorded vote is not proof its underlying action executed. */ +export async function recoverApprovalVote(row: OwnerIntentRecord) { + const parsed = acceptedVoteSchema.safeParse(row.context); + if (row.state !== "submitted" || !parsed.success) return null; + const context = parsed.data; + // The public contract documents collected approvals, not rejection receipts. + if (context.verdict !== "approve") return null; + const signer = row.signer; + if (signer.type !== "near") return null; + try { + // This endpoint is public; recovery must survive wallet deletion or key revocation. + const approval = approvalDetailSchema.parse( + await getOutlayer().approval("", context.approvalId), + ); + if ( + approval.id !== context.approvalId || + approval.wallet_id !== context.evidence.walletId || + approval.wallet_pubkey !== context.evidence.walletPublicKey || + approval.request_hash !== context.requestHash || + !approval.approvers?.some( + (receipt) => + receipt.approver_id === signer.accountId && + hashSecret(receipt.signature) === context.evidence.signatureHash, + ) + ) + return null; + return { approvalId: context.approvalId, verdict: context.verdict }; + } catch { + // Unavailable, malformed or missing evidence is never a terminal outcome. + return null; + } +} diff --git a/packages/agents-core/src/modules/intents/commit-service.ts b/packages/agents-core/src/modules/intents/commit-service.ts new file mode 100644 index 000000000..d94abc9a2 --- /dev/null +++ b/packages/agents-core/src/modules/intents/commit-service.ts @@ -0,0 +1,31 @@ +import { ownerIntents, type Tx } from "@near-intents-agent-api/database"; +import { and, eq, sql } from "drizzle-orm"; +import { ApiError } from "../../shared/errors.js"; + +/** Acceptance is decided after domain locks; failure rolls the entire effect and nonce back. */ +export async function commitOwnerIntent( + tx: Tx, + tenantId: string, + id: string, + result: unknown, + state: "completed" | "submitted" = "completed", + operationId?: string, +) { + const [row] = await tx + .select() + .from(ownerIntents) + .where(and(eq(ownerIntents.tenantId, tenantId), eq(ownerIntents.id, id))) + .for("update"); + if (row?.state !== "pending_signature") throw new ApiError("intent_not_pending", 409); + const changed = await tx + .update(ownerIntents) + .set({ + state, + result, + ...(operationId ? { operationId } : {}), + updatedAt: sql`clock_timestamp()`, + }) + .where(and(eq(ownerIntents.id, id), sql`${ownerIntents.expiresAt} > clock_timestamp()`)) + .returning({ id: ownerIntents.id }); + if (!changed.length) throw new ApiError("intent_expired", 409); +} diff --git a/packages/agents-core/src/modules/intents/generate.ts b/packages/agents-core/src/modules/intents/generate.ts new file mode 100644 index 000000000..1e3be4e6f --- /dev/null +++ b/packages/agents-core/src/modules/intents/generate.ts @@ -0,0 +1,494 @@ +import { + type AgentControlMessage, + type AgentGrantMessage, + agentGrantDomain, + canonical, + destinationLabel, + type OwnerWallet, + policySchema, +} from "@near-intents-agent-api/contracts"; +import type { + GenerateIntentRequest, + GenerateIntentResponse, + Intent, + IntentPreview, + IntentType, +} from "@near-intents-agent-api/contracts/api"; +import { generateIntentRequestSchema } from "@near-intents-agent-api/contracts/api"; +import { deletionPreview } from "../../api/views.js"; +import { getRuntime } from "../../config/runtime.js"; +import { withAdvisoryLock } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { issueOwnerNonce } from "../../shared/nonces.js"; +import { ownerMessageMaxLifetimeMs } from "../../shared/owner-message.js"; +import { deletionChallenge } from "../agents/deletion-service.js"; +import { + assertGrantMessage, + grantChallenge, + grantRevocationChallenge, +} from "../agents/grant-service.js"; +import { assertControlTransition } from "../agents/lifecycle-service.js"; +import { createAgent } from "../agents/onboarding-service.js"; +import { ownerMessageRecipient, requireBoundAgent } from "../agents/service.js"; +import { budgetChallenge } from "../operations/budget-policy-service.js"; +import { findOperation } from "../operations/repository.js"; +import { signingArtifactOwnerChallenge } from "../operations/signing-artifact-owner-service.js"; +import { cancellationChallenge, timelockChallenge } from "../operations/timelock-policy-service.js"; +import { prepareApproval } from "../wallet/approval-service.js"; +import { preparePolicyRevision } from "../wallet/owner-policy-preparation.js"; +import { latestAppliedPolicy, latestPolicyVersion } from "../wallet/repository.js"; +import { recoverPreparedGeneration } from "./generation-recovery.js"; +import { buildReservedDraft, reserveGeneration } from "./generation-service.js"; +import { + approvalVoteIntent, + type ConsentContext, + consentIntent, + type WalletRequestContext, +} from "./payloads.js"; +import { + freezeSummary, + onboardingDraft, + policySummary, + preparedPolicyDraft, +} from "./prepared-drafts.js"; +import { + findOwnerIntent, + findOwnerIntentByIdempotencyKey, + insertOwnerIntent, + type OwnerIntentRecord, +} from "./repository.js"; + +/** Approval votes carry the approver identity and the provider request they bind. */ +export type ApprovalContext = { + family: "approval"; + approvalId: string; + verdict: "approve" | "reject"; + requestHash: string; + nonceHex: string; +}; + +export type IntentContext = ConsentContext | WalletRequestContext | ApprovalContext; + +export type Draft = { + agentId: string; + signer: OwnerWallet; + intent: Intent; + context: IntentContext; + preview: IntentPreview; + expiresAtMs: number; + operationId?: string; +}; + +type ParsedRequest = ReturnType; +type Request = Extract; +type Builder = ( + actor: Actor, + request: Request, + correlationId: string, + reservedAgentId?: string, +) => Promise; + +function parseRequest(input: GenerateIntentRequest) { + return generateIntentRequestSchema.parse(input); +} + +/** A consent nonce lives five minutes; leave the client a margin to submit inside it. */ +const consentLifetimeMs = ownerMessageMaxLifetimeMs - 10_000; + +async function boundOwner(actor: Actor, agentId: string) { + const { agent, wallet } = await requireBoundAgent(actor, agentId); + if (!agent.ownerIdentity || !agent.ownerAccountId) throw new ApiError("agent_not_bound", 409); + return { agent, wallet, owner: agent.ownerIdentity, ownerAccountId: agent.ownerAccountId }; +} + +async function consentDraft( + actor: Actor, + agentId: string, + message: Parameters[2], + preview: IntentPreview, +): Promise { + const { owner, ownerAccountId } = await boundOwner(actor, agentId); + const now = Date.now(); + const built = consentIntent(owner, ownerAccountId, message, now); + return { + agentId, + signer: owner, + intent: built.intent, + context: built.context, + preview, + expiresAtMs: Math.min(built.expiresAtMs, now + consentLifetimeMs), + }; +} + +const agentCreate: Builder<"agent_create"> = async ( + actor, + request, + _correlationId, + reservedAgentId, +) => { + const created = await createAgent( + actor, + { + name: request.name, + ...(request.externalUserId === undefined ? {} : { externalUserId: request.externalUserId }), + owner: request.owner, + policy: request.policy, + }, + reservedAgentId, + ); + return onboardingDraft(request, created); +}; + +async function policyDraft( + actor: Actor, + agentId: string, + input: { policy: unknown; expectedRevision: number; summary: string }, + correlationId: string, +): Promise { + const { owner } = await boundOwner(actor, agentId); + const policy = policySchema.parse(input.policy); + const operation = await preparePolicyRevision(actor, agentId, { + policy, + expectedRevision: input.expectedRevision, + idempotencyKey: `intent:${correlationId}`, + }); + const stored = await findOperation(actor.tenantId, agentId, operation.id); + if (!stored) throw new ApiError("policy_not_pending", 409); + return preparedPolicyDraft(owner, stored, input.summary); +} + +const policyUpdate: Builder<"policy_update"> = (actor, request, correlationId) => + policyDraft( + actor, + request.agentId, + { + policy: request.policy, + expectedRevision: request.expectedRevision, + summary: policySummary(request.expectedRevision, request.policy.frozen), + }, + correlationId, + ); + +/** + * Freeze and unfreeze restate the applied rules with the flag changed, as the next revision. The + * freeze transition itself is derived from the provider's live state during preparation, so a + * failed or expired earlier revision never decides it. + */ +function freezeBuilder(frozen: boolean): Builder<"agent_freeze" | "agent_unfreeze"> { + return async (actor, request, correlationId) => { + const { wallet } = await boundOwner(actor, request.agentId); + const applied = await latestAppliedPolicy(actor.tenantId, request.agentId); + if (!applied) throw new ApiError("policy_not_ready", 409); + return policyDraft( + actor, + request.agentId, + { + policy: { ...policySchema.parse(applied.rules), frozen }, + expectedRevision: (await latestPolicyVersion(wallet.providerWalletId)) ?? 0, + summary: freezeSummary(frozen), + }, + correlationId, + ); + }; +} + +const grantIssue: Builder<"grant_issue"> = async (actor, request) => { + const { agent, wallet, owner } = await boundOwner(actor, request.agentId); + const challenge = await grantChallenge(actor, request.agentId); + const issuedAtMs = Date.now(); + const message: AgentGrantMessage = { + domain: agentGrantDomain, + owner, + tenant_id: actor.tenantId, + agent_id: request.agentId, + wallet_id: wallet.providerWalletId, + label: request.label, + credential: request.credential, + actions: request.actions, + recipients: request.recipients ?? [], + signing_audiences: request.signingAudiences ?? [], + network: getRuntime().network, + issued_at_ms: issuedAtMs, + expires_at_ms: Date.parse(request.expiresAt), + owner_epoch: challenge.owner_epoch, + nonce: challenge.nonce, + recipient: challenge.recipient, + }; + assertGrantMessage(actor, message, agent, wallet.providerWalletId); + const recipients = message.recipients.length + ? message.recipients.map(destinationLabel).join(", ") + : "internal balances of this agent only"; + return consentDraft(actor, request.agentId, message, { + summary: `Let "${message.label}" run ${message.actions.join(", ")} (to ${recipients}; signing audiences: ${message.signing_audiences.join(", ") || "none"}) until ${request.expiresAt}.`, + }); +}; + +const grantRevoke: Builder<"grant_revoke"> = async (actor, request) => + consentDraft( + actor, + request.agentId, + await grantRevocationChallenge(actor, request.agentId, request.grantId), + { summary: `Revoke grant ${request.grantId}.` }, + ); + +const timelockSet: Builder<"timelock_set"> = async (actor, request) => + consentDraft( + actor, + request.agentId, + await timelockChallenge(actor, request.agentId, { delay_seconds: request.delaySeconds }), + { + summary: request.delaySeconds + ? `Delay every execution on this account, by every connection, by ${request.delaySeconds} seconds. Queued executions under the old delay are refused.` + : "Remove this account's execution delay for every connection. Queued executions under the old delay are refused.", + }, + ); + +const budgetSet: Builder<"budget_set"> = async (actor, request) => + consentDraft( + actor, + request.agentId, + await budgetChallenge(actor, request.agentId, { + daily_usd: request.dailyUsd, + weekly_usd: request.weeklyUsd, + monthly_usd: request.monthlyUsd, + }), + { summary: budgetSummary(request) }, + ); + +function budgetSummary(request: { + dailyUsd: string | null; + weeklyUsd: string | null; + monthlyUsd: string | null; +}): string { + const caps = [ + request.dailyUsd && `$${request.dailyUsd} per 24 hours`, + request.weeklyUsd && `$${request.weeklyUsd} per 7 days`, + request.monthlyUsd && `$${request.monthlyUsd} per 30 days`, + ].filter(Boolean); + return caps.length + ? `Cap this account's USD spend at ${caps.join(", ")}, across all assets, shared by every connection. Omitted windows are uncapped. Usage already counted stays counted, and usage stays tracked even if caps are later cleared.` + : "Remove this account's USD caps for every connection. Usage already counted stays counted, and usage stays tracked, so counted executions still need a current price."; +} + +const executionCancel: Builder<"execution_cancel"> = async (actor, request) => + consentDraft( + actor, + request.agentId, + await cancellationChallenge(actor, request.agentId, request.correlationId), + { summary: `Cancel queued execution ${request.correlationId}.` }, + ); + +function controlBuilder(action: "archive" | "restore"): Builder<"agent_archive" | "agent_restore"> { + return async (actor, request) => { + const { agent, wallet, owner } = await boundOwner(actor, request.agentId); + await assertControlTransition( + actor.tenantId, + request.agentId, + action, + wallet, + agent.ownerAccountId, + ); + const issuedAtMs = Date.now(); + const message: AgentControlMessage = { + domain: "near-intents-agent-api.agent-control.v3", + tenant_id: actor.tenantId, + agent_id: request.agentId, + network: getRuntime().network, + owner, + account_id: agent.ownerAccountId as string, + public_key: agent.ownerPublicKey as string, + recipient: ownerMessageRecipient(), + nonce: await issueOwnerNonce(actor.tenantId, request.agentId), + issued_at_ms: issuedAtMs, + expires_at_ms: issuedAtMs + ownerMessageMaxLifetimeMs, + action, + previous_public_key: agent.ownerPublicKey as string, + }; + return consentDraft(actor, request.agentId, message, { + summary: + action === "archive" + ? "Archive the agent. It stays frozen and refuses every request until restored." + : "Restore the archived agent. Unfreeze it with agent_unfreeze to resume executions.", + }); + }; +} + +const agentDelete: Builder<"agent_delete"> = async (actor, request) => { + const challenge = await deletionChallenge(actor, request.agentId); + if (!challenge.preview.policy_allows_delete) throw new ApiError("policy_blocks_delete", 409); + const lost = challenge.preview.assets_lost; + return consentDraft(actor, request.agentId, challenge.message, { + summary: lost + ? "Delete the agent. Its remaining public and confidential balances are destroyed; withdraw them first." + : "Delete the agent and retire its custody wallet. Native NEAR returns to the service sponsor.", + deletion: deletionPreview(challenge.preview), + }); +}; + +const approvalVote: Builder<"approval_vote"> = async (actor, request) => { + const { agent } = await boundOwner(actor, request.agentId); + const owner = agent.ownerIdentity as OwnerWallet; + let signer: Extract; + if (request.signer) { + signer = { + type: "near", + accountId: request.signer.accountId, + publicKey: request.signer.publicKey as `ed25519:${string}`, + }; + } else { + // OutLayer accepts NEP-413 votes from NEAR access keys only (open work OW-14). + if (owner.type !== "near") throw new ApiError("approval_vote_owner_unsupported", 409); + signer = owner; + } + const isOwner = + signer.accountId === agent.ownerAccountId && signer.publicKey === agent.ownerPublicKey; + const applied = await latestAppliedPolicy(actor.tenantId, request.agentId); + const approvers = applied ? policySchema.parse(applied.rules).approval?.approvers : undefined; + const isApprover = approvers?.some( + (approver) => approver.id === signer.accountId && approver.pubkey === signer.publicKey, + ); + if (!isOwner && !isApprover) throw new ApiError("approver_not_allowed", 403); + const prepared = await prepareApproval( + actor, + request.agentId, + request.approvalId, + request.verdict, + ); + return { + agentId: request.agentId, + signer, + intent: approvalVoteIntent({ + message: prepared.message, + recipient: prepared.recipient, + nonceHex: prepared.nonce, + }), + context: { + family: "approval", + approvalId: request.approvalId, + verdict: request.verdict, + requestHash: prepared.approval.request_hash, + nonceHex: prepared.nonce, + }, + preview: { + summary: `${request.verdict === "approve" ? "Approve" : "Reject"} the pending ${prepared.approval.request_type} request.`, + approval: { + approvalId: request.approvalId, + requestType: prepared.approval.request_type, + requestHash: prepared.approval.request_hash, + verdict: request.verdict, + }, + }, + expiresAtMs: Math.min(Date.parse(prepared.approval.expires_at), Date.now() + consentLifetimeMs), + }; +}; + +function artifactBuilder( + action: "read" | "ack", +): Builder<"signing_artifact_read" | "signing_artifact_ack"> { + return async (actor, request) => { + const challenge = await signingArtifactOwnerChallenge( + actor, + request.agentId, + request.correlationId, + action, + ); + return consentDraft(actor, request.agentId, challenge.message, { + summary: + action === "read" + ? `Deliver the signature of ${request.correlationId} to this API key.` + : `Erase the stored signature of ${request.correlationId}.`, + }); + }; +} + +const builders: { + [T in IntentType]: ( + actor: Actor, + request: Extract, + correlationId: string, + ) => Promise; +} = { + agent_create: agentCreate, + policy_update: policyUpdate, + agent_freeze: freezeBuilder(true), + agent_unfreeze: freezeBuilder(false), + grant_issue: grantIssue, + grant_revoke: grantRevoke, + timelock_set: timelockSet, + budget_set: budgetSet, + execution_cancel: executionCancel, + agent_archive: controlBuilder("archive"), + agent_restore: controlBuilder("restore"), + agent_delete: agentDelete, + approval_vote: approvalVote, + signing_artifact_read: artifactBuilder("read"), + signing_artifact_ack: artifactBuilder("ack"), +}; + +export function generateResponse(row: OwnerIntentRecord): GenerateIntentResponse { + return { + correlationId: row.id, + type: row.type as IntentType, + agentId: row.agentId, + status: "PENDING_SIGNATURE", + expiresAt: row.expiresAt.toISOString(), + signer: row.signer, + intent: row.intent as Intent, + preview: row.preview as IntentPreview, + }; +} + +/** + * Builds and stores one owner intent. With an `Idempotency-Key`, a replay of the same request + * returns the stored intent instead of issuing new nonces or creating another agent. + */ +export async function generateIntent( + actor: Actor, + input: GenerateIntentRequest, + idempotencyKey?: string, +): Promise<{ row: OwnerIntentRecord; replayed: boolean }> { + const request = parseRequest(input); + const requestHash = hashSecret(canonical(request)); + const run = async () => { + if (idempotencyKey) { + const existing = await findOwnerIntentByIdempotencyKey(actor.tenantId, idempotencyKey); + if (existing) { + if (existing.requestHash !== requestHash) throw new ApiError("idempotency_conflict", 409); + return { row: existing, replayed: true }; + } + } + const generation = await reserveGeneration(actor, request, requestHash, idempotencyKey); + const correlationId = generation.id; + const existing = await findOwnerIntent(actor.tenantId, correlationId); + if (existing) return { row: existing, replayed: true }; + const build = builders[request.type] as Builder; + const draft = await buildReservedDraft( + generation, + () => build(actor, request as never, correlationId, generation.agentId), + () => recoverPreparedGeneration(actor, request, generation), + ); + const replayed = generation.state !== "reserved"; + const row = await insertOwnerIntent({ + id: correlationId, + tenantId: actor.tenantId, + agentId: draft.agentId, + actorKeyId: actor.keyId, + type: request.type, + standard: draft.intent.standard, + signer: draft.signer, + intent: draft.intent, + context: draft.context, + preview: draft.preview, + operationId: draft.operationId ?? null, + idempotencyKey: idempotencyKey ?? null, + requestHash, + expiresAt: new Date(draft.expiresAtMs), + }); + return { row, replayed }; + }; + return idempotencyKey + ? withAdvisoryLock(`intent:${actor.tenantId}:${idempotencyKey}`, run) + : run(); +} diff --git a/packages/agents-core/src/modules/intents/generation-recovery.ts b/packages/agents-core/src/modules/intents/generation-recovery.ts new file mode 100644 index 000000000..0b0c22f8c --- /dev/null +++ b/packages/agents-core/src/modules/intents/generation-recovery.ts @@ -0,0 +1,72 @@ +import { canonical, policySchema } from "@near-intents-agent-api/contracts"; +import type { GenerateIntentRequest } from "@near-intents-agent-api/contracts/api"; +import type { Actor } from "../../shared/actor.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { getOnboarding } from "../agents/onboarding-service.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { findOperation } from "../operations/repository.js"; +import { operationIdFor } from "../operations/service.js"; +import { preparedSchema } from "../wallet/prepared-policy.js"; +import type { Draft } from "./generate.js"; +import { + freezeSummary, + onboardingDraft, + policySummary, + preparedPolicyDraft, +} from "./prepared-drafts.js"; + +/** Observe completed local preparation only. No builder, provider call or new resource identity. */ +export async function recoverPreparedGeneration( + actor: Actor, + request: GenerateIntentRequest, + generation: { id: string; agentId: string }, +): Promise { + try { + if (request.type === "agent_create") { + const created = await getOnboarding(actor, generation.agentId); + const operation = await findOperation( + actor.tenantId, + generation.agentId, + created.onboarding.operation_id, + ); + if ( + operation?.status !== "pending" || + created.agent.status !== "pending" || + created.onboarding.status !== "pending_wallet_signature" || + canonical(created.onboarding.owner) !== canonical(request.owner) || + created.onboarding.policy_hash !== hashSecret(canonical(policySchema.parse(request.policy))) + ) + return undefined; + return onboardingDraft(request, created); + } + if (!["policy_update", "agent_freeze", "agent_unfreeze"].includes(request.type)) + return undefined; + const id = operationIdFor( + actor.tenantId, + generation.agentId, + "policy", + `intent:${generation.id}`, + ); + const operation = await findOperation(actor.tenantId, generation.agentId, id); + const prepared = preparedSchema.safeParse(operation?.result); + if (!operation || !prepared.success) return undefined; + const { agent, wallet } = await requireBoundAgent(actor, generation.agentId); + if ( + !agent.ownerIdentity || + operation.authorizedOwnerEpoch !== agent.ownerEpoch || + prepared.data.controller_id !== agent.ownerAccountId || + prepared.data.wallet_id !== wallet.providerWalletId + ) + return undefined; + return preparedPolicyDraft( + agent.ownerIdentity, + operation, + request.type === "policy_update" + ? policySummary(request.expectedRevision, request.policy.frozen) + : freezeSummary(request.type === "agent_freeze"), + ); + } catch { + // Missing/incomplete/retired evidence cannot authorize another preparation. + return undefined; + } +} diff --git a/packages/agents-core/src/modules/intents/generation-service.ts b/packages/agents-core/src/modules/intents/generation-service.ts new file mode 100644 index 000000000..83e356d0b --- /dev/null +++ b/packages/agents-core/src/modules/intents/generation-service.ts @@ -0,0 +1,74 @@ +import { randomUUID } from "node:crypto"; +import type { GenerateIntentRequest } from "@near-intents-agent-api/contracts/api"; +import { intentGenerations } from "@near-intents-agent-api/database"; +import { and, eq } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import { newId } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; + +type Generation = typeof intentGenerations.$inferSelect; + +/** Caller holds the tenant/key advisory lock. The row outlives its connection and process. */ +export async function reserveGeneration( + actor: Actor, + request: GenerateIntentRequest, + requestHash: string, + idempotencyKey?: string, +): Promise { + const existing = idempotencyKey + ? ( + await getDatabase() + .select() + .from(intentGenerations) + .where( + and( + eq(intentGenerations.tenantId, actor.tenantId), + eq(intentGenerations.idempotencyKey, idempotencyKey), + ), + ) + )[0] + : undefined; + if (existing) { + if (existing.requestHash !== requestHash) throw new ApiError("idempotency_conflict", 409); + return existing; + } + const [reserved] = await getDatabase() + .insert(intentGenerations) + .values({ + id: randomUUID(), + tenantId: actor.tenantId, + idempotencyKey: idempotencyKey ?? null, + requestHash, + agentId: request.type === "agent_create" ? newId() : request.agentId, + }) + .returning(); + if (!reserved) throw new Error("intent_generation_reservation_failed"); + return reserved; +} + +export async function buildReservedDraft( + generation: Generation, + build: () => Promise, + recover?: () => Promise, +): Promise { + if (generation.draft) return generation.draft as T; + let draft: T; + if (generation.state === "reserved") { + await getDatabase() + .update(intentGenerations) + .set({ state: "building" }) + .where(eq(intentGenerations.id, generation.id)); + draft = await build(); + } else { + // A started builder may have contacted custody. Only durable evidence may resume it. + const recovered = await recover?.(); + if (!recovered) throw new ApiError("intent_generation_recovery_required", 409); + draft = recovered; + } + await getDatabase() + .update(intentGenerations) + .set({ draft, state: "ready" }) + .where(eq(intentGenerations.id, generation.id)); + return draft; +} diff --git a/packages/agents-core/src/modules/intents/operation-retention.ts b/packages/agents-core/src/modules/intents/operation-retention.ts new file mode 100644 index 000000000..2ea2d31fd --- /dev/null +++ b/packages/agents-core/src/modules/intents/operation-retention.ts @@ -0,0 +1,149 @@ +import { + auditRetentionHolds, + delayedExecutions, + operationArtifacts, + operations, + operationTombstones, + ownerIntents, + type Tx, +} from "@near-intents-agent-api/database"; +import { and, eq, exists, inArray, isNull, lt, notExists, or, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import type { OperationRecord } from "../operations/repository.js"; +import { terminalIntentOutcome } from "./outcome.js"; +import type { OwnerIntentRecord } from "./repository.js"; + +const retentionDays = 90; +const batchSize = 500; + +/** + * Retires terminal operation detail once a permanent idempotency tombstone exists and no live + * reference needs it. + * + * An owner intent takes its outcome from its operation on a status read, and nothing guarantees a + * client ever reads it. So in the transaction that deletes an operation, every intent still + * waiting on it first takes the operation's terminal outcome; an operation with a waiting intent + * whose outcome cannot be derived from it is kept. A retired correlation id answers as it would + * have before, whether or not anyone polled it. + */ +export async function sweepTerminalOperations(): Promise { + return getDatabase().transaction(async (tx) => { + const candidates = await lockRetirableOperations(tx); + if (!candidates.length) return 0; + // Operation → intent, the order in which a submission links an intent to its operation. + const waiting = await tx + .select() + .from(ownerIntents) + .where( + and( + inArray( + ownerIntents.operationId, + candidates.map((operation) => operation.id), + ), + inArray(ownerIntents.state, ["pending_signature", "submitted"]), + ), + ) + .for("update"); + const retired: OperationRecord[] = []; + for (const operation of candidates) + if (await settleWaitingIntents(tx, operation, waiting)) retired.push(operation); + if (!retired.length) return 0; + const removed = await tx + .delete(operations) + .where(or(...retired.map(operationIdentity))) + .returning({ id: operations.id }); + return removed.length; + }); +} + +/** Writes each waiting intent's terminal outcome; false, writing nothing, if one has none. */ +async function settleWaitingIntents( + tx: Tx, + operation: OperationRecord, + waiting: OwnerIntentRecord[], +): Promise { + const settled = waiting + .filter( + (intent) => + intent.tenantId === operation.tenantId && + intent.agentId === operation.agentId && + intent.operationId === operation.id, + ) + .map((intent) => ({ intent, outcome: terminalIntentOutcome(intent, operation) })); + if (settled.some(({ outcome }) => !outcome)) return false; + for (const { intent, outcome } of settled) + await tx + .update(ownerIntents) + .set({ ...outcome, updatedAt: sql`now()` }) + .where(and(eq(ownerIntents.tenantId, intent.tenantId), eq(ownerIntents.id, intent.id))); + return true; +} + +function lockRetirableOperations(tx: Tx) { + const sameOperation = ( + table: T, + ) => + and( + eq(table.tenantId, operations.tenantId), + eq(table.agentId, operations.agentId), + eq(table.id, operations.id), + ); + return tx + .select() + .from(operations) + .where( + and( + inArray(operations.status, ["completed", "failed"]), + lt(operations.updatedAt, sql`now() - ${retentionDays} * interval '1 day'`), + exists( + tx + .select({ id: operationTombstones.id }) + .from(operationTombstones) + .where(sameOperation(operationTombstones)), + ), + notExists( + tx + .select({ id: delayedExecutions.id }) + .from(delayedExecutions) + .where(sameOperation(delayedExecutions)), + ), + notExists( + tx + .select({ id: operationArtifacts.operationId }) + .from(operationArtifacts) + .where( + and( + eq(operationArtifacts.tenantId, operations.tenantId), + eq(operationArtifacts.agentId, operations.agentId), + eq(operationArtifacts.operationId, operations.id), + ), + ), + ), + notExists( + tx + .select({ id: auditRetentionHolds.id }) + .from(auditRetentionHolds) + .where( + and( + eq(auditRetentionHolds.tenantId, operations.tenantId), + isNull(auditRetentionHolds.releasedAt), + or( + isNull(auditRetentionHolds.agentId), + eq(auditRetentionHolds.agentId, operations.agentId), + ), + ), + ), + ), + ), + ) + .orderBy(operations.updatedAt) + .limit(batchSize) + .for("update", { of: operations, skipLocked: true }); +} + +const operationIdentity = (operation: OperationRecord) => + and( + eq(operations.tenantId, operation.tenantId), + eq(operations.agentId, operation.agentId), + eq(operations.id, operation.id), + ); diff --git a/packages/agents-core/src/modules/intents/outcome.ts b/packages/agents-core/src/modules/intents/outcome.ts new file mode 100644 index 000000000..db8ff3a1c --- /dev/null +++ b/packages/agents-core/src/modules/intents/outcome.ts @@ -0,0 +1,114 @@ +import type { Status } from "@near-intents-agent-api/contracts/api"; +import { publicCode } from "@near-intents-agent-api/contracts/api"; +import { camelRecord } from "../../api/camel.js"; +import type { OperationRecord } from "../operations/repository.js"; +import { projectOperationResult } from "../operations/result-projection.js"; + +/** + * Pure outcome projections of an operation. Status reads and history retention both use them, so + * an intent settled from its operation reads the same whichever of them settled it. + */ + +export type Record_ = Record; +export type Outcome = { status: Status; failureCode: string | null }; + +export const isRecord = (value: unknown): value is Record_ => + Boolean(value) && typeof value === "object" && !Array.isArray(value); +export const str = (value: unknown) => (typeof value === "string" ? value : null); + +export const policyIntentTypes = new Set(["policy_update", "agent_freeze", "agent_unfreeze"]); + +export function failureCodeOf(result: unknown): string | null { + if (!isRecord(result)) return null; + const code = str(result.failure_code); + return code ? publicCode(code) : null; +} + +/** Execution lifecycle, from the operation status and the provider status it recorded. */ +export function executionStatus(operation: OperationRecord): Outcome { + const result = isRecord(operation.result) ? operation.result : {}; + const provider = str(result.status); + switch (operation.status) { + case "completed": + return { status: "SUCCESS", failureCode: null }; + case "failed": + return { + status: provider === "refunded" ? "REFUNDED" : "FAILED", + failureCode: failureCodeOf(result) ?? (provider ? publicCode(provider) : null), + }; + case "uncertain": + return { status: "UNCERTAIN", failureCode: failureCodeOf(result) }; + case "pending": + if (provider === "timelocked") return { status: "QUEUED", failureCode: null }; + if (provider === "pending_approval") return { status: "PENDING_APPROVAL", failureCode: null }; + if (provider === "pending_deposit") return { status: "PENDING_DEPOSIT", failureCode: null }; + return { status: "PROCESSING", failureCode: null }; + } +} + +export function executionDetails(operation: OperationRecord): Record_ { + const projected = camelRecord(projectOperationResult(operation.kind, operation.result)); + const { status: providerStatus, failureCode: _failure, ...rest } = projected; + return { + ...rest, + action: str(rest.action) ?? operation.action ?? "unknown", + ...(typeof providerStatus === "string" ? { providerStatus } : {}), + }; +} + +export function policyDetails(operation: OperationRecord | undefined): Record_ { + const result = isRecord(operation?.result) ? operation.result : {}; + const expected = typeof result.expected_revision === "number" ? result.expected_revision : null; + return { + revision: operation?.status === "completed" && expected !== null ? expected + 1 : null, + policyHash: str(result.policy_hash), + transactionHash: str(result.transaction_hash), + }; +} + +export function policyStatus(operation: OperationRecord): Outcome { + const result = isRecord(operation.result) ? operation.result : {}; + if (operation.status === "completed") return { status: "SUCCESS", failureCode: null }; + if (operation.status === "failed") + return { status: "FAILED", failureCode: failureCodeOf(result) }; + if (operation.status === "pending" && result.status === "pending_wallet_signature") + return { status: "PENDING_SIGNATURE", failureCode: null }; + return { status: "PROCESSING", failureCode: null }; +} + +/** + * The terminal state an owner intent takes from its terminal operation, or null when the operation + * is not terminal or the intent type does not take its outcome from an operation. + */ +export function terminalIntentOutcome( + intent: { type: string; agentId: string }, + operation: OperationRecord, +): { state: "completed" | "failed"; result: Record_; failureCode: string | null } | null { + if (operation.status !== "completed" && operation.status !== "failed") return null; + const result = isRecord(operation.result) ? operation.result : {}; + let outcome: Outcome; + let details: Record_; + if (intent.type === "agent_create") { + const applied = operation.status === "completed"; + outcome = applied + ? { status: "SUCCESS", failureCode: null } + : { status: "FAILED", failureCode: failureCodeOf(result) }; + details = { + agentId: intent.agentId, + revision: applied ? 1 : null, + policyHash: str(result.policy_hash), + transactionHash: str(result.transaction_hash), + }; + } else if (policyIntentTypes.has(intent.type)) { + outcome = policyStatus(operation); + details = policyDetails(operation); + } else if (intent.type === "agent_delete") { + outcome = executionStatus(operation); + details = executionDetails(operation); + } else return null; + return { + state: outcome.status === "SUCCESS" ? "completed" : "failed", + result: details, + failureCode: outcome.failureCode, + }; +} diff --git a/packages/agents-core/src/modules/intents/payloads.ts b/packages/agents-core/src/modules/intents/payloads.ts new file mode 100644 index 000000000..7a140cd0d --- /dev/null +++ b/packages/agents-core/src/modules/intents/payloads.ts @@ -0,0 +1,316 @@ +import { + canonical, + type EvmWalletRequestMessage, + evmAuthorizationTypedData, + evmWalletRequestMessageSchema, + evmWalletTypedData, + type OffchainMessage, + type OnboardingSignature, + type OwnerProof, + type OwnerWallet, + offchainMessageHash, + type PolicyOperationResult, + type SignedNearPolicy, + type SignedWalletRequest, + type WalletRequestMessage, + walletRequestHash, + walletRequestMessageSchema, +} from "@near-intents-agent-api/contracts"; +import type { Intent, SignedData } from "@near-intents-agent-api/contracts/api"; +import { + evmWalletSignature, + nearSignatureHex, + passkeyWalletProof, + WalletOutputError, +} from "@near-intents-agent-api/owner-auth"; +import { ApiError } from "../../shared/errors.js"; + +/** + * Wallet payloads, one per owner type, and the reverse mapping from a wallet's raw output to the + * proof formats the owner verifiers already check. + * + * Two families exist: + * - consent: the owner signs a canonical JSON message (grant, revoke, timelock, …) with NEP-413 + * (NEAR), an EIP-712 `PROVE_OWNERSHIP` wallet authorization (EVM) or a NEP-641 offchain + * message (passkey); + * - wallet request: the owner signs the exact on-chain policy request, as a NEP-366 delegate + * (NEAR), an EIP-712 wallet message (EVM) or a WebAuthn assertion over the NEP-616 request + * hash (passkey). + */ + +/** How long an authenticator may show its prompt. */ +const webauthnTimeoutMs = 120_000; +/** NEP-641 wallets accept a timestamp at most five minutes old; sign well inside that. */ +const offchainTimestampSkewMs = 30_000; +const offchainMaxAgeMs = 300_000; + +type ConsentMessage = { owner: OwnerWallet; nonce: string; recipient: string }; + +/** Server-side data stored with a consent intent. */ +export type ConsentContext = { + family: "consent"; + message: ConsentMessage & Record; + offchain?: OffchainMessage; +}; + +/** Server-side data stored with a wallet-request intent. */ +export type WalletRequestContext = { + family: "wallet_request"; + walletRequest?: WalletRequestMessage | EvmWalletRequestMessage; +}; + +const base64url = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64url"); + +function webauthnPayload( + owner: Extract, + challenge: Uint8Array, +): Extract { + return { + standard: "webauthn", + payload: { + challenge: base64url(challenge), + rpId: owner.rpId, + allowCredentials: [{ id: owner.credentialId, type: "public-key" }], + userVerification: "required", + timeout: webauthnTimeoutMs, + }, + }; +} + +/** + * The payload an owner signs to consent to one canonical message. `ownerAccountId` is the bound + * owner's NEAR authority account; a passkey signs for its `0s` wallet. + */ +export function consentIntent( + owner: OwnerWallet, + ownerAccountId: string, + message: ConsentMessage & Record, + now = Date.now(), +): { intent: Intent; context: ConsentContext; expiresAtMs: number } { + const text = canonical(message); + switch (owner.type) { + case "near": + return { + intent: { + standard: "nep413", + payload: { + message: text, + recipient: message.recipient, + nonce: Buffer.from(message.nonce, "hex").toString("base64"), + }, + }, + context: { family: "consent", message }, + expiresAtMs: Number.POSITIVE_INFINITY, + }; + case "evm": + return { + intent: { + standard: "eip712", + payload: evmAuthorizationTypedData({ + purpose: "PROVE_OWNERSHIP", + recipient: message.recipient, + payload: text, + }), + }, + context: { family: "consent", message }, + expiresAtMs: Number.POSITIVE_INFINITY, + }; + case "passkey": { + const signedAt = now - offchainTimestampSkewMs; + const offchain: OffchainMessage = { + chain_id: "mainnet", + signer_id: ownerAccountId, + timestamp: new Date(signedAt).toISOString(), + payload: text, + }; + return { + intent: webauthnPayload(owner, offchainMessageHash(offchain)), + context: { family: "consent", message, offchain }, + expiresAtMs: signedAt + offchainMaxAgeMs, + }; + } + } +} + +/** The payload an owner signs to authorize one prepared on-chain policy request. */ +export function walletRequestIntent( + owner: OwnerWallet, + prepared: Pick, +): { intent: Intent; context: WalletRequestContext; expiresAtMs: number } { + if (owner.type === "near") { + const request = prepared.near_policy_request; + if (!request) throw new ApiError("wallet_request_type_mismatch", 409); + return { + intent: { + standard: "nep366", + payload: { + receiverId: request.receiver_id, + actions: request.actions.map((action) => ({ + type: "FunctionCall" as const, + params: { + methodName: action.methodName, + args: jsonArgs(action.argsBase64), + gas: action.gas, + deposit: action.depositYocto, + }, + })), + }, + }, + context: { family: "wallet_request" }, + expiresAtMs: Number.POSITIVE_INFINITY, + }; + } + const request = prepared.wallet_request; + if (!request) throw new ApiError("wallet_request_type_mismatch", 409); + const expiresAtMs = Date.parse(request.created_at) + request.timeout_secs * 1000; + if (owner.type === "evm") { + const message = evmWalletRequestMessageSchema.parse(request); + return { + intent: { standard: "eip712", payload: evmWalletTypedData(message) }, + context: { family: "wallet_request", walletRequest: message }, + expiresAtMs, + }; + } + const message = walletRequestMessageSchema.parse(request); + return { + intent: webauthnPayload(owner, walletRequestHash(message)), + context: { family: "wallet_request", walletRequest: message }, + expiresAtMs, + }; +} + +/** + * Policy call arguments are JSON. A wallet re-serializes `args` with `JSON.stringify`, so the + * bytes only survive the round trip when the prepared bytes are exactly that serialization. + */ +function jsonArgs(argsBase64: string): Record { + const text = Buffer.from(argsBase64, "base64").toString("utf8"); + const parsed = JSON.parse(text) as unknown; + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + JSON.stringify(parsed) !== text + ) + throw new Error("policy_args_not_json"); + return parsed as Record; +} + +/** Refuses a submission whose payload is not byte-identical to the generated one. */ +export function assertSamePayload(intent: Intent, signed: SignedData) { + if (intent.standard !== signed.standard) throw new ApiError("intent_standard_mismatch", 409); + if (canonical(intent.payload) !== canonical(signed.payload)) + throw new ApiError("intent_payload_mismatch", 409); +} + +function walletOutput(convert: () => T): T { + try { + return convert(); + } catch (error) { + if (error instanceof WalletOutputError) throw new ApiError(error.code, 400); + throw error; + } +} + +/** Converts a consent signature into the owner proof its verifier checks. */ +export function consentProof( + owner: OwnerWallet, + context: ConsentContext, + signed: SignedData, +): OwnerProof { + switch (signed.standard) { + case "nep413": + if (owner.type !== "near") throw new ApiError("intent_standard_mismatch", 409); + if (signed.public_key !== owner.publicKey) throw new ApiError("signer_mismatch", 409); + return { signature: walletOutput(() => nearSignatureHex(signed.signature)) }; + case "eip712": { + if (owner.type !== "evm") throw new ApiError("intent_standard_mismatch", 409); + const message = signed.payload.message as Record; + return { + walletAuthorization: JSON.stringify({ + purpose: message.purpose, + recipient: message.recipient, + payload: message.payload, + signature: walletOutput(() => evmWalletSignature(signed.signature)), + }), + }; + } + case "webauthn": { + if (owner.type !== "passkey" || !context.offchain) + throw new ApiError("intent_standard_mismatch", 409); + if (signed.credential.id !== owner.credentialId) throw new ApiError("signer_mismatch", 409); + return { + walletAuthorization: JSON.stringify({ + signature: { + msg: context.offchain, + proof: walletOutput(() => passkeyWalletProof(signed.credential)), + }, + }), + }; + } + case "nep366": + throw new ApiError("intent_standard_mismatch", 409); + } +} + +/** Converts a wallet-request signature into the submission the policy relay verifies. */ +export function walletRequestSubmission( + owner: OwnerWallet, + context: WalletRequestContext, + signed: SignedData, + idempotencyKey: string, +): (SignedWalletRequest | SignedNearPolicy) & OnboardingSignature { + switch (signed.standard) { + case "nep366": + if (owner.type !== "near") throw new ApiError("intent_standard_mismatch", 409); + return { signedDelegate: signed.signedDelegate } as SignedNearPolicy & OnboardingSignature; + case "eip712": { + if (owner.type !== "evm" || !context.walletRequest) + throw new ApiError("intent_standard_mismatch", 409); + const message = evmWalletRequestMessageSchema.parse(context.walletRequest); + const typed = evmWalletTypedData(message); + return { + msg: message, + proof: JSON.stringify({ + ...typed.message, + signature: walletOutput(() => evmWalletSignature(signed.signature)), + }), + idempotencyKey, + }; + } + case "webauthn": { + if (owner.type !== "passkey" || !context.walletRequest) + throw new ApiError("intent_standard_mismatch", 409); + if (signed.credential.id !== owner.credentialId) throw new ApiError("signer_mismatch", 409); + return { + msg: walletRequestMessageSchema.parse(context.walletRequest), + proof: walletOutput(() => passkeyWalletProof(signed.credential)), + idempotencyKey, + }; + } + case "nep413": + throw new ApiError("intent_standard_mismatch", 409); + } +} + +/** NEP-413 payload for a provider approval vote, signed by a NEAR approver. */ +export function approvalVoteIntent(input: { + message: string; + recipient: string; + nonceHex: string; +}): Intent { + return { + standard: "nep413", + payload: { + message: input.message, + recipient: input.recipient, + nonce: Buffer.from(input.nonceHex, "hex").toString("base64"), + }, + }; +} + +export function approvalVoteSignature(signed: SignedData, publicKey: string) { + if (signed.standard !== "nep413") throw new ApiError("intent_standard_mismatch", 409); + if (signed.public_key !== publicKey) throw new ApiError("signer_mismatch", 409); + return walletOutput(() => nearSignatureHex(signed.signature)); +} diff --git a/packages/agents-core/src/modules/intents/prepared-drafts.ts b/packages/agents-core/src/modules/intents/prepared-drafts.ts new file mode 100644 index 000000000..0c6fb63dc --- /dev/null +++ b/packages/agents-core/src/modules/intents/prepared-drafts.ts @@ -0,0 +1,77 @@ +import type { AgentOnboarding, OwnerWallet } from "@near-intents-agent-api/contracts"; +import type { GenerateIntentRequest } from "@near-intents-agent-api/contracts/api"; +import { ApiError } from "../../shared/errors.js"; +import type { OperationRecord } from "../operations/repository.js"; +import { preparedSchema } from "../wallet/prepared-policy.js"; +import type { Draft } from "./generate.js"; +import { walletRequestIntent } from "./payloads.js"; + +function describeOwner(owner: OwnerWallet) { + if (owner.type === "near") return owner.accountId; + if (owner.type === "evm") return owner.address; + return `passkey ${owner.credentialId.slice(0, 12)}…`; +} + +export function onboardingDraft( + request: Extract, + created: AgentOnboarding, +): Draft { + const built = walletRequestIntent(request.owner, created.onboarding); + return { + agentId: created.agent.id, + signer: request.owner, + intent: built.intent, + context: built.context, + operationId: created.onboarding.operation_id, + preview: { + summary: `Create agent "${request.name}" owned by ${describeOwner(request.owner)} and install its first policy.`, + revision: 1, + previousRevision: 0, + policyHash: created.onboarding.policy_hash, + }, + expiresAtMs: Math.min(built.expiresAtMs, Date.parse(created.onboarding.expires_at)), + }; +} + +export function policySummary(revision: number, frozen?: boolean) { + return `Install account policy revision ${revision + 1}${frozen ? " (frozen: every execution refused)" : ""}. It applies to every connection on this account once the provider confirms it; each grant keeps only the actions and destinations it was signed with.`; +} + +export function freezeSummary(frozen: boolean) { + return frozen + ? "Freeze this account's wallet: every execution by every connection is refused until it is unfrozen." + : "Unfreeze this account's wallet: executions resume for every connection under the current policy."; +} + +/** Pure projection shared by first generation and recovery. Never refreshes its deadline. */ +export function preparedPolicyDraft( + owner: OwnerWallet, + operation: OperationRecord, + summary: string, +): Draft { + const prepared = preparedSchema.safeParse(operation.result); + if ( + operation.status !== "pending" || + !prepared.success || + prepared.data.status !== "pending_wallet_signature" + ) + throw new ApiError("policy_not_pending", 409); + const built = walletRequestIntent(owner, prepared.data); + const revision = prepared.data.expected_revision; + if (revision === undefined) throw new ApiError("policy_not_pending", 409); + return { + agentId: operation.agentId, + signer: owner, + intent: built.intent, + context: built.context, + operationId: operation.id, + preview: { + summary, + revision: revision + 1, + previousRevision: revision, + policyHash: prepared.data.policy_hash, + }, + // Existing policy preparation lifetime: ten minutes, anchored to durable admission. + expiresAtMs: Math.min(built.expiresAtMs, operation.createdAt.getTime() + 600_000), + }; +} diff --git a/packages/agents-core/src/modules/intents/repository.ts b/packages/agents-core/src/modules/intents/repository.ts new file mode 100644 index 000000000..52e2a78c0 --- /dev/null +++ b/packages/agents-core/src/modules/intents/repository.ts @@ -0,0 +1,110 @@ +import { type OwnerIntentState, ownerIntents } from "@near-intents-agent-api/database"; +import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; + +export type OwnerIntentRecord = typeof ownerIntents.$inferSelect; +export type NewOwnerIntent = typeof ownerIntents.$inferInsert; + +export async function insertOwnerIntent(row: NewOwnerIntent): Promise { + const [inserted] = await getDatabase().insert(ownerIntents).values(row).returning(); + if (!inserted) throw new Error("owner_intent_insert_failed"); + return inserted; +} + +export async function findOwnerIntent( + tenantId: string, + id: string, +): Promise { + const [row] = await getDatabase() + .select() + .from(ownerIntents) + .where(and(eq(ownerIntents.tenantId, tenantId), eq(ownerIntents.id, id))) + .limit(1); + return row; +} + +export async function findOwnerIntentByIdempotencyKey( + tenantId: string, + idempotencyKey: string, +): Promise { + const [row] = await getDatabase() + .select() + .from(ownerIntents) + .where( + and(eq(ownerIntents.tenantId, tenantId), eq(ownerIntents.idempotencyKey, idempotencyKey)), + ) + .limit(1); + return row; +} + +/** The intent that owns an operation, when the operation was created for one. */ +export async function findOwnerIntentForOperation( + tenantId: string, + agentId: string, + operationId: string, +): Promise { + const [row] = await getDatabase() + .select() + .from(ownerIntents) + .where( + and( + eq(ownerIntents.tenantId, tenantId), + eq(ownerIntents.agentId, agentId), + eq(ownerIntents.operationId, operationId), + ), + ) + .orderBy(desc(ownerIntents.createdAt)) + .limit(1); + return row; +} + +export async function updateOwnerIntent( + tenantId: string, + id: string, + values: { + state?: OwnerIntentState; + operationId?: string | null; + result?: unknown; + failureCode?: string | null; + }, + expectedState?: OwnerIntentState, +): Promise { + await getDatabase() + .update(ownerIntents) + .set({ ...values, updatedAt: sql`now()` }) + .where( + and( + eq(ownerIntents.tenantId, tenantId), + eq(ownerIntents.id, id), + expectedState + ? eq(ownerIntents.state, expectedState) + : inArray(ownerIntents.state, ["pending_signature", "submitted"]), + ), + ); +} + +/** Newest first, keyed by `(created_at, id)`. */ +export async function listOwnerIntents( + tenantId: string, + agentId: string, + limit: number, + before?: { createdAt: Date; id: string }, +): Promise { + return getDatabase() + .select() + .from(ownerIntents) + .where( + and( + eq(ownerIntents.tenantId, tenantId), + eq(ownerIntents.agentId, agentId), + before + ? or( + lt(ownerIntents.createdAt, before.createdAt), + and(eq(ownerIntents.createdAt, before.createdAt), lt(ownerIntents.id, before.id)), + ) + : undefined, + ), + ) + .orderBy(desc(ownerIntents.createdAt), desc(ownerIntents.id)) + .limit(limit); +} diff --git a/packages/agents-core/src/modules/intents/status.ts b/packages/agents-core/src/modules/intents/status.ts new file mode 100644 index 000000000..bb695c70f --- /dev/null +++ b/packages/agents-core/src/modules/intents/status.ts @@ -0,0 +1,441 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import type { + ExecutionType, + Status, + StatusResponse, + StatusType, +} from "@near-intents-agent-api/contracts/api"; +import { publicCode, terminalStatuses } from "@near-intents-agent-api/contracts/api"; +import { operations, ownerIntents } from "@near-intents-agent-api/database"; +import { and, desc, eq, lt, ne, notExists, or } from "drizzle-orm"; +import { camelRecord } from "../../api/camel.js"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { logger } from "../../shared/logger.js"; +import { refreshDeletion } from "../agents/deletion-service.js"; +import { getOnboarding, refreshOnboarding } from "../agents/onboarding-service.js"; +import { refreshExecution } from "../operations/execution-service.js"; +import { + findOperation, + findTenantOperation, + type OperationRecord, +} from "../operations/repository.js"; +import { projectOperationResult } from "../operations/result-projection.js"; +import { reconcileOwnerPolicy } from "../wallet/owner-policy-reconciliation.js"; +import { recoverApprovalVote } from "./approval-recovery.js"; +import { + executionDetails, + executionStatus, + isRecord, + policyDetails, + policyIntentTypes, + policyStatus, + type Record_, + str, +} from "./outcome.js"; +import { + findOwnerIntent, + findOwnerIntentForOperation, + listOwnerIntents, + type OwnerIntentRecord, + updateOwnerIntent, +} from "./repository.js"; + +/** + * One status model for every correlation id: owner intents (`owner_intents`) and the + * operations they, executions, signatures and relays create. Reads reconcile against the chain + * and provider first; reconciliation never submits anything. + */ + +const executionActions: Record = { + swap: "swap", + withdraw: "withdraw", + intents_transfer: "transfer", + confidential_transfer: "transfer", + shield: "shield", + unshield: "unshield", + confidential_deposit: "confidential_deposit", + cross_chain_deposit: "deposit", +}; + +/** The public type of an operation that no owner intent owns. */ +function operationType(operation: OperationRecord): StatusType { + const action = + operation.action ?? (isRecord(operation.result) ? str(operation.result.action) : null); + if (operation.kind === "sign") return "sign_message"; + if (operation.kind === "relay") return "relay"; + if (operation.kind === "policy") + return action === "onboarding" ? "agent_create" : "policy_update"; + if (action === "delete") return "agent_delete"; + return (action && executionActions[action]) || "swap"; +} + +function signingDetails(operation: OperationRecord): Record_ { + const action = operation.action ?? ""; + return { chain: action === "sign:evm_message" ? "evm" : "near" }; +} + +/** Null means no outcome has been observed; it is never fabricated success data. */ +function emptyIntentDetails(type: StatusType, agentId: string | null): Record_ { + switch (type) { + case "agent_create": + return { agentId, revision: null, policyHash: null, transactionHash: null }; + case "policy_update": + case "agent_freeze": + case "agent_unfreeze": + return { revision: null, policyHash: null, transactionHash: null }; + case "grant_issue": + return { grant: null }; + case "grant_revoke": + return { grantId: null, committedCorrelationIds: null, committedTruncated: null }; + case "timelock_set": + return { timelock: null }; + case "budget_set": + return { budget: null }; + case "execution_cancel": + return { cancelledCorrelationId: null }; + case "agent_archive": + case "agent_restore": + return { archived: null }; + case "agent_delete": + return { action: "delete" }; + case "approval_vote": + return { approvalId: null, verdict: null }; + case "signing_artifact_ack": + return { acknowledged: null }; + default: + return {}; + } +} + +function envelope( + base: { + correlationId: string; + agentId: string | null; + type: StatusType; + createdAt: Date; + updatedAt: Date; + dispatchCommittedAt: Date | null; + grant: { id: string; label: string } | null; + }, + outcome: { status: Status; failureCode: string | null }, + details: Record_, +): StatusResponse { + return { + correlationId: base.correlationId, + agentId: base.agentId, + type: base.type, + status: outcome.status, + failureCode: outcome.failureCode, + createdAt: base.createdAt.toISOString(), + updatedAt: base.updatedAt.toISOString(), + dispatchCommittedAt: base.dispatchCommittedAt?.toISOString() ?? null, + grant: base.grant, + details: { ...emptyIntentDetails(base.type, base.agentId), ...details }, + } as StatusResponse; +} + +/** Status of an operation no intent owns: executions, signatures, relays. */ +export function operationStatus(operation: OperationRecord): StatusResponse { + const type = operationType(operation); + const base = { + correlationId: operation.id, + agentId: operation.agentId, + type, + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + dispatchCommittedAt: operation.dispatchCommittedAt, + grant: + operation.authorizedGrantId && operation.authorizedGrantLabel + ? { id: operation.authorizedGrantId, label: operation.authorizedGrantLabel } + : null, + }; + if (operation.kind === "policy") + return envelope(base, policyStatus(operation), policyDetails(operation)); + if (operation.kind === "sign") + return envelope(base, executionStatus(operation), signingDetails(operation)); + if (operation.kind === "relay") + return envelope( + base, + executionStatus(operation), + camelRecord(projectOperationResult("relay", operation.result)), + ); + return envelope(base, executionStatus(operation), executionDetails(operation)); +} + +async function reconcileOperation(actor: Actor, operation: OperationRecord) { + if (operation.status !== "pending" && operation.status !== "uncertain") return; + try { + if (operation.kind === "execute") { + if ( + operation.action === "delete" || + (isRecord(operation.result) && operation.result.action === "delete") + ) + await refreshDeletion(actor, operation.agentId, operation.id); + else await refreshExecution(actor, operation.agentId, operation.id); + } else if (operation.kind === "policy" && operation.action !== "onboarding") + await reconcileOwnerPolicy(actor, operation.agentId, operation.id); + } catch (error) { + // A status read reports what is known; reconciliation errors never fail it. + logger.warn("status_reconcile_failed", { + operation_id: operation.id, + code: error instanceof ApiError ? error.code : "unexpected", + }); + } +} + +function expired(row: OwnerIntentRecord) { + return row.expiresAt.getTime() <= Date.now(); +} + +const pendingOrExpired = (row: OwnerIntentRecord) => + expired(row) + ? { status: "FAILED" as const, failureCode: "intent-expired" } + : { status: "PENDING_SIGNATURE" as const, failureCode: null }; + +async function onboardingStatus(actor: Actor, row: OwnerIntentRecord, reconcile: boolean) { + const state = await (reconcile ? refreshOnboarding : getOnboarding)(actor, row.agentId).catch( + () => null, + ); + if (!state) return null; + const onboarding = state.onboarding; + const outcome: { status: Status; failureCode: string | null } = + onboarding.status === "applied" + ? { status: "SUCCESS", failureCode: null } + : onboarding.status === "failed" + ? { + status: "FAILED", + failureCode: onboarding.failure_code ? publicCode(onboarding.failure_code) : null, + } + : onboarding.status === "pending_wallet_signature" + ? pendingOrExpired(row) + : { status: "PROCESSING", failureCode: null }; + return { + outcome, + details: { + agentId: row.agentId, + revision: onboarding.status === "applied" ? 1 : null, + policyHash: onboarding.policy_hash, + transactionHash: onboarding.transaction_hash, + }, + }; +} + +type Observation = { + outcome: { status: Status; failureCode: string | null }; + details: Record_; +}; + +/** Outcome of a policy or deletion intent, read from the operation it created. */ +async function linkedOperationStatus( + actor: Actor, + row: OwnerIntentRecord, + operationId: string, + reconcile: boolean, +): Promise { + let operation = await findOperation(actor.tenantId, row.agentId, operationId); + if (operation && reconcile) { + await reconcileOperation(actor, operation); + operation = (await findOperation(actor.tenantId, row.agentId, operationId)) ?? operation; + } + if (!operation) return null; + if (row.type === "agent_delete") + return { outcome: executionStatus(operation), details: executionDetails(operation) }; + const outcome = policyStatus(operation); + return { + outcome: + outcome.status === "PENDING_SIGNATURE" && row.state === "pending_signature" + ? pendingOrExpired(row) + : outcome, + details: policyDetails(operation), + }; +} + +async function observeIntent( + actor: Actor, + row: OwnerIntentRecord, + reconcile: boolean, +): Promise { + if (row.type === "approval_vote" && reconcile) { + const receipt = await recoverApprovalVote(row); + if (receipt) return { outcome: { status: "SUCCESS", failureCode: null }, details: receipt }; + } + const observed = + row.type === "agent_create" + ? await onboardingStatus(actor, row, reconcile) + : row.operationId && (policyIntentTypes.has(row.type) || row.type === "agent_delete") + ? await linkedOperationStatus(actor, row, row.operationId, reconcile) + : null; + return ( + observed ?? { + outcome: + row.state === "pending_signature" + ? pendingOrExpired(row) + : row.type === "approval_vote" + ? { status: "UNCERTAIN", failureCode: "approval-outcome-unresolved" } + : { status: "PROCESSING", failureCode: null }, + details: isRecord(row.result) ? row.result : {}, + } + ); +} + +/** Status of one owner intent, reconciling its operation when it has one. */ +export async function intentStatus( + actor: Actor, + row: OwnerIntentRecord, + options: { reconcile: boolean }, +): Promise { + const type = row.type as StatusType; + const base = { + correlationId: row.id, + agentId: row.agentId, + type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + // Owner intents are signed by the owner and are not delegated work, so revocation never applies. + dispatchCommittedAt: null, + grant: null, + }; + if (row.state === "completed" || row.state === "failed") + return envelope( + base, + row.state === "completed" + ? { status: "SUCCESS", failureCode: null } + : { status: "FAILED", failureCode: row.failureCode }, + isRecord(row.result) ? row.result : {}, + ); + const observed = await observeIntent(actor, row, options.reconcile); + + // Operations are retired by history retention; keep the decided outcome on the intent. + const decided = observed.outcome.status; + if (decided === "SUCCESS" || decided === "FAILED" || decided === "REFUNDED") { + await updateOwnerIntent( + actor.tenantId, + row.id, + { + state: decided === "SUCCESS" ? "completed" : "failed", + result: observed.details, + failureCode: observed.outcome.failureCode, + }, + row.state, + ); + // Answer exactly as every later read of the settled intent will. + const settled = await findOwnerIntent(actor.tenantId, row.id); + if (settled) return intentStatus(actor, settled, options); + } + return envelope(base, observed.outcome, observed.details); +} + +const uuidPattern = /^[0-9a-f]{8}-/; + +async function statusOnce(actor: Actor, correlationId: string, reconcile: boolean) { + if (uuidPattern.test(correlationId)) { + const row = await findOwnerIntent(actor.tenantId, correlationId); + if (!row) throw new ApiError("status_not_found", 404); + return intentStatus(actor, row, { reconcile }); + } + const operation = await findTenantOperation(actor.tenantId, correlationId); + if (!operation) throw new ApiError("status_not_found", 404); + const owner = await findOwnerIntentForOperation(actor.tenantId, operation.agentId, operation.id); + if (owner) return intentStatus(actor, owner, { reconcile }); + if (reconcile) { + await reconcileOperation(actor, operation); + const current = await findOperation(actor.tenantId, operation.agentId, operation.id); + return operationStatus(current ?? operation); + } + return operationStatus(operation); +} + +const pollIntervalMs = 1_000; + +/** + * Status of any correlation id. `waitMs` long-polls until the status is terminal, needs the + * owner's signature, or the wait ends. + */ +export async function readStatus(actor: Actor, correlationId: string, waitMs = 0) { + const deadline = Date.now() + waitMs; + for (;;) { + const status = await statusOnce(actor, correlationId, true); + if ( + terminalStatuses.includes(status.status) || + status.status === "PENDING_SIGNATURE" || + Date.now() + pollIntervalMs > deadline + ) + return status; + await sleep(pollIntervalMs); + } +} + +/** Cursor: `|` of the last entry returned. */ +function parseCursor(cursor: string | undefined) { + if (!cursor) return undefined; + const [at, id] = cursor.split("|"); + const createdAt = at ? new Date(at) : new Date(Number.NaN); + if (!id || Number.isNaN(createdAt.getTime())) throw new ApiError("invalid_cursor", 400); + return { createdAt, id }; +} + +/** + * Newest-first history of an agent: its owner intents and every operation no intent owns. + * Entries are read as stored, without reconciliation. + */ +export async function readHistory( + actor: Actor, + agentId: string, + query: { cursor?: string; limit: number }, +) { + const before = parseCursor(query.cursor); + const [intents, operationRows] = await Promise.all([ + listOwnerIntents(actor.tenantId, agentId, query.limit + 1, before), + getDatabase() + .select() + .from(operations) + .where( + and( + eq(operations.tenantId, actor.tenantId), + eq(operations.agentId, agentId), + // Policy and onboarding operations surface through the intent that created them. + ne(operations.kind, "policy"), + before + ? or( + lt(operations.createdAt, before.createdAt), + and(eq(operations.createdAt, before.createdAt), lt(operations.id, before.id)), + ) + : undefined, + notExists( + getDatabase() + .select({ id: ownerIntents.id }) + .from(ownerIntents) + .where( + and( + eq(ownerIntents.tenantId, operations.tenantId), + eq(ownerIntents.agentId, operations.agentId), + eq(ownerIntents.operationId, operations.id), + ), + ), + ), + ), + ) + .orderBy(desc(operations.createdAt), desc(operations.id)) + .limit(query.limit + 1), + ]); + const merged = [ + ...intents.map((row) => ({ createdAt: row.createdAt, id: row.id, intent: row })), + ...operationRows.map((row) => ({ createdAt: row.createdAt, id: row.id, operation: row })), + ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : -1)); + const page = merged.slice(0, query.limit); + const data = await Promise.all( + page.map((entry) => + "intent" in entry && entry.intent + ? intentStatus(actor, entry.intent, { reconcile: false }) + : operationStatus((entry as { operation: OperationRecord }).operation), + ), + ); + const last = page.at(-1); + return { + data, + nextCursor: + merged.length > query.limit && last ? `${last.createdAt.toISOString()}|${last.id}` : null, + }; +} diff --git a/packages/agents-core/src/modules/intents/submit.ts b/packages/agents-core/src/modules/intents/submit.ts new file mode 100644 index 000000000..fd6854585 --- /dev/null +++ b/packages/agents-core/src/modules/intents/submit.ts @@ -0,0 +1,359 @@ +import type { + AgentAdminCommandMessage, + AgentControlMessage, + AgentDeleteMessage, + AgentGrantMessage, + BudgetMessage, + OwnerWallet, + SigningArtifactOwnerMessage, + TimelockMessage, +} from "@near-intents-agent-api/contracts"; +import type { + Intent, + IntentType, + StatusResponse, + SubmitIntentRequest, +} from "@near-intents-agent-api/contracts/api"; +import { budgetView, grantView, signatureDelivery, timelockView } from "../../api/views.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { deleteAgent } from "../agents/deletion-service.js"; +import { issueAgentGrant, revokeAgentGrant } from "../agents/grant-service.js"; +import { controlAgent } from "../agents/lifecycle-service.js"; +import { submitOnboarding } from "../agents/onboarding-service.js"; +import { setBudget } from "../operations/budget-policy-service.js"; +import { findOperation } from "../operations/repository.js"; +import { operationIdFor } from "../operations/service.js"; +import { + acknowledgeSigningArtifactAsOwner, + readSigningArtifactAsOwner, +} from "../operations/signing-artifact-owner-service.js"; +import { cancelExecution, setTimelock } from "../operations/timelock-policy-service.js"; +import { voteApproval } from "../wallet/approval-service.js"; +import { submitOwnerPolicy } from "../wallet/owner-policy-submission.js"; +import { acceptApprovalVote } from "./approval-recovery.js"; +import { commitOwnerIntent } from "./commit-service.js"; +import type { ApprovalContext } from "./generate.js"; +import { + approvalVoteSignature, + assertSamePayload, + type ConsentContext, + consentProof, + type WalletRequestContext, + walletRequestSubmission, +} from "./payloads.js"; +import { findOwnerIntent, type OwnerIntentRecord, updateOwnerIntent } from "./repository.js"; +import { intentStatus } from "./status.js"; + +type Applied = { details: Record; operationId?: string }; +type Handler = ( + actor: Actor, + row: OwnerIntentRecord, + signed: SubmitIntentRequest["signedData"], +) => Promise; + +const consent = (row: OwnerIntentRecord) => row.context as ConsentContext; +const proofFor = (row: OwnerIntentRecord, signed: SubmitIntentRequest["signedData"]) => + consentProof(row.signer as OwnerWallet, consent(row), signed); + +/** On-chain requests: relay the signed request; the operation carries the outcome. */ +const onboardingHandler: Handler = async (actor, row, signed) => { + await submitOnboarding( + actor, + row.agentId, + walletRequestSubmission(row.signer, row.context as WalletRequestContext, signed, row.id), + ); + return undefined; +}; + +const policyHandler: Handler = async (actor, row, signed) => { + if (!row.operationId) throw new ApiError("intent_not_found", 404); + await submitOwnerPolicy( + actor, + row.agentId, + row.operationId, + walletRequestSubmission(row.signer, row.context as WalletRequestContext, signed, row.id), + ); + return undefined; +}; + +const handlers: Record = { + agent_create: onboardingHandler, + policy_update: policyHandler, + agent_freeze: policyHandler, + agent_unfreeze: policyHandler, + grant_issue: async (actor, row, signed) => { + const grant = await issueAgentGrant( + actor, + row.agentId, + { + message: consent(row).message as unknown as AgentGrantMessage, + proof: proofFor(row, signed), + idempotencyKey: row.id, + }, + (tx, result) => commitOwnerIntent(tx, actor.tenantId, row.id, { grant: grantView(result) }), + { signedData: signed, intent: row.intent, context: row.context }, + ); + return { details: { grant: grantView(grant) } }; + }, + grant_revoke: async (actor, row, signed) => { + const message = consent(row).message as unknown as AgentAdminCommandMessage; + const revoked = await revokeAgentGrant( + actor, + row.agentId, + message.target_id, + { + message: message as Extract, + proof: proofFor(row, signed), + }, + (tx, result) => + commitOwnerIntent(tx, actor.tenantId, row.id, { + grantId: message.target_id, + committedCorrelationIds: result.committed_operation_ids, + committedTruncated: result.committed_truncated, + }), + { signedData: signed, intent: row.intent, context: row.context }, + ); + return { + details: { + grantId: message.target_id, + committedCorrelationIds: revoked.committed_operation_ids, + committedTruncated: revoked.committed_truncated, + }, + }; + }, + timelock_set: async (actor, row, signed) => { + const view = await setTimelock( + actor, + row.agentId, + { + message: consent(row).message as unknown as TimelockMessage, + proof: proofFor(row, signed), + }, + (tx, result) => + commitOwnerIntent(tx, actor.tenantId, row.id, { timelock: timelockView(result) }), + { signedData: signed, intent: row.intent, context: row.context }, + ); + return { details: { timelock: timelockView(view) } }; + }, + budget_set: async (actor, row, signed) => { + const view = await setBudget( + actor, + row.agentId, + { + message: consent(row).message as unknown as BudgetMessage, + proof: proofFor(row, signed), + }, + (tx, result) => commitOwnerIntent(tx, actor.tenantId, row.id, { budget: budgetView(result) }), + { signedData: signed, intent: row.intent, context: row.context }, + ); + return { details: { budget: budgetView(view) } }; + }, + execution_cancel: async (actor, row, signed) => { + const message = consent(row).message as unknown as Extract< + AgentAdminCommandMessage, + { action: "cancel_execution" } + >; + await cancelExecution( + actor, + row.agentId, + message.target_id, + { + message, + proof: proofFor(row, signed), + }, + (tx) => + commitOwnerIntent(tx, actor.tenantId, row.id, { + cancelledCorrelationId: message.target_id, + }), + { signedData: signed, intent: row.intent, context: row.context }, + ); + return { details: { cancelledCorrelationId: message.target_id } }; + }, + agent_archive: async (actor, row, signed) => { + const result = await controlAgent( + actor, + row.agentId, + { + message: consent(row).message as unknown as AgentControlMessage, + proof: proofFor(row, signed), + }, + (tx, result) => commitOwnerIntent(tx, actor.tenantId, row.id, { archived: result.archived }), + { signedData: signed, intent: row.intent, context: row.context }, + ); + return { details: { archived: result.archived } }; + }, + agent_restore: async (actor, row, signed) => { + const result = await controlAgent( + actor, + row.agentId, + { + message: consent(row).message as unknown as AgentControlMessage, + proof: proofFor(row, signed), + }, + (tx, result) => commitOwnerIntent(tx, actor.tenantId, row.id, { archived: result.archived }), + { signedData: signed, intent: row.intent, context: row.context }, + ); + return { details: { archived: result.archived } }; + }, + agent_delete: async (actor, row, signed) => { + try { + const operation = await deleteAgent( + actor, + row.agentId, + { + message: consent(row).message as unknown as AgentDeleteMessage, + proof: proofFor(row, signed), + idempotencyKey: row.id, + }, + (tx, operationId) => + commitOwnerIntent(tx, actor.tenantId, row.id, null, "submitted", operationId), + ); + return { details: {}, operationId: operation.id }; + } catch (error) { + // A deletion reserved before the failure may have reached the provider: the intent must + // report that operation, not a signable request. + const operationId = operationIdFor(actor.tenantId, row.agentId, "execute", row.id); + if (await findOperation(actor.tenantId, row.agentId, operationId)) + await updateOwnerIntent(actor.tenantId, row.id, { state: "submitted", operationId }); + throw error; + } + }, + approval_vote: async (actor, row, signed) => { + const context = row.context as ApprovalContext; + const signer = row.signer as Extract; + await voteApproval( + actor, + row.agentId, + context.approvalId, + { + verdict: context.verdict, + account_id: signer.accountId, + public_key: signer.publicKey, + signature: approvalVoteSignature(signed, signer.publicKey), + nonce: context.nonceHex, + request_hash: context.requestHash, + }, + (tx, evidence) => acceptApprovalVote(tx, row, evidence), + ); + return { details: { approvalId: context.approvalId, verdict: context.verdict } }; + }, + signing_artifact_read: async (actor, row, signed) => { + const message = consent(row).message as unknown as SigningArtifactOwnerMessage; + const delivery = await readSigningArtifactAsOwner( + actor, + row.agentId, + message.operation_id, + { + message, + proof: proofFor(row, signed), + }, + (tx) => commitOwnerIntent(tx, actor.tenantId, row.id, {}), + ); + // The signature itself is returned once, in this response; the stored result omits it. + return { details: { delivery: signatureDelivery(delivery) } }; + }, + signing_artifact_ack: async (actor, row, signed) => { + const message = consent(row).message as unknown as SigningArtifactOwnerMessage; + await acknowledgeSigningArtifactAsOwner( + actor, + row.agentId, + message.operation_id, + { + message, + proof: proofFor(row, signed), + }, + (tx) => commitOwnerIntent(tx, actor.tenantId, row.id, { acknowledged: true }), + ); + return { details: { acknowledged: true } }; + }, +}; + +/** Types whose outcome lives in an operation created at generate time. */ +const operationBacked = new Set([ + "agent_create", + "policy_update", + "agent_freeze", + "agent_unfreeze", +]); + +/** Domain refusals that mean an operation-backed intent was already submitted. */ +const alreadySubmittedCodes = new Set(["onboarding_not_pending", "policy_not_pending"]); + +async function applyHandler( + actor: Actor, + row: OwnerIntentRecord, + input: SubmitIntentRequest, + submittedOnce: boolean, +): Promise<{ applied: Applied | undefined } | { status: StatusResponse }> { + try { + return { applied: await handlers[row.type as IntentType](actor, row, input.signedData) }; + } catch (error) { + if (!(error instanceof ApiError)) throw error; + if (alreadySubmittedCodes.has(error.code) && submittedOnce) + return { status: await intentStatus(actor, row, { reconcile: true }) }; + // A concurrent duplicate lost the single-use nonce to the winner; answer with its outcome. + { + const current = await findOwnerIntent(actor.tenantId, row.id); + if ( + current && + (current.state === "completed" || + current.state === "failed" || + (current.state === "submitted" && + ["owner_nonce_invalid", "intent_not_pending"].includes(error.code))) + ) + return { status: await intentStatus(actor, current, { reconcile: false }) }; + } + throw error; + } +} + +/** Records a synchronously applied intent. A delivered signature is returned, never stored. */ +async function completeIntent(actor: Actor, row: OwnerIntentRecord, applied: Applied | undefined) { + const { delivery, ...stored } = applied?.details ?? {}; + if (row.type === "approval_vote") + await updateOwnerIntent( + actor.tenantId, + row.id, + { state: "completed", result: stored }, + "submitted", + ); + const current = (await findOwnerIntent(actor.tenantId, row.id)) ?? row; + const status = await intentStatus(actor, current, { reconcile: false }); + return delivery ? ({ ...status, details: { delivery } } as StatusResponse) : status; +} + +/** + * Applies the owner's signature to one generated intent. A resubmission after the intent was + * applied returns its status; a request refused before anything was sent stays signable. + */ +export async function submitIntent( + actor: Actor, + input: SubmitIntentRequest, +): Promise { + const row = await findOwnerIntent(actor.tenantId, input.correlationId); + if (!row) throw new ApiError("intent_not_found", 404); + if (row.type !== input.type) throw new ApiError("intent_type_mismatch", 409); + assertSamePayload(row.intent as Intent, input.signedData); + + const type = row.type as IntentType; + const settled = row.state === "completed" || row.state === "failed"; + const submittedOnce = + row.state === "submitted" || (type === "agent_delete" && row.operationId !== null); + if (settled || (submittedOnce && !operationBacked.has(type))) + return intentStatus(actor, row, { reconcile: true }); + if (row.expiresAt.getTime() <= Date.now() && !submittedOnce) + throw new ApiError("intent_expired", 409); + + const outcome = await applyHandler(actor, row, input, submittedOnce); + if ("status" in outcome) return outcome.status; + const applied = outcome.applied; + if (!operationBacked.has(type) && !applied?.operationId) + return completeIntent(actor, row, applied); + await updateOwnerIntent(actor.tenantId, row.id, { + state: "submitted", + ...(applied?.operationId ? { operationId: applied.operationId } : {}), + }); + const current = (await findOwnerIntent(actor.tenantId, row.id)) ?? row; + return intentStatus(actor, current, { reconcile: true }); +} diff --git a/packages/agents-core/src/modules/operations/budget-policy-service.ts b/packages/agents-core/src/modules/operations/budget-policy-service.ts new file mode 100644 index 000000000..833171828 --- /dev/null +++ b/packages/agents-core/src/modules/operations/budget-policy-service.ts @@ -0,0 +1,125 @@ +import { + type BudgetSettings, + type BudgetWrite, + canonical, +} from "@near-intents-agent-api/contracts"; +import { agents, auditEvents } from "@near-intents-agent-api/database"; +import { and, eq, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import type { CommitEffect } from "../../shared/commit-effect.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { consumeOwnerNonceInTransaction } from "../../shared/nonces.js"; +import { + assertOwnerAdminCommand, + ownerAdminCommandChallenge, + ownerPrincipalId, + verifyOwnerAdminCommand, +} from "../agents/owner-admin-command.js"; +import { recordOwnerReceipt } from "../agents/owner-receipts.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { centsFromUsd, readBudgetFor } from "./spend-budget.js"; + +export { readBudgetFor } from "./spend-budget.js"; + +export async function readBudget(actor: Actor, agentId: string) { + return readBudgetFor(actor.tenantId, agentId); +} + +function target(settings: BudgetSettings, expected_revision: number) { + return hashSecret( + canonical({ + daily_usd: settings.daily_usd, + weekly_usd: settings.weekly_usd, + monthly_usd: settings.monthly_usd, + expected_revision, + }), + ); +} + +export async function budgetChallenge(actor: Actor, agentId: string, settings: BudgetSettings) { + const { agent } = await requireBoundAgent(actor, agentId); + return { + ...(await ownerAdminCommandChallenge( + actor, + agent, + "set_budget", + target(settings, agent.budgetRevision), + )), + daily_usd: settings.daily_usd, + weekly_usd: settings.weekly_usd, + monthly_usd: settings.monthly_usd, + expected_revision: agent.budgetRevision, + }; +} + +/** + * Replaces the agent's USD caps under the owner's signature. The update takes the agent row that + * every dispatch commitment holds while it decides its charge, so it is ordered against each one: + * a dispatch that commits first is charged under the old caps and cannot be retracted, and every + * later one sees these. Charges already made stay counted, so lowering a cap below current spend + * blocks further spend until the window frees up. + */ +export async function setBudget( + actor: Actor, + agentId: string, + input: BudgetWrite, + onCommit?: CommitEffect>>, + original?: unknown, +) { + const { agent } = await requireBoundAgent(actor, agentId); + const { message, proof } = input; + assertOwnerAdminCommand( + actor, + agent, + "set_budget", + target(message, message.expected_revision), + message, + ); + const verification = await verifyOwnerAdminCommand(agent, message, proof); + return getDatabase().transaction(async (tx) => { + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, message.nonce); + const changed = await tx + .update(agents) + .set({ + budgetDailyCents: centsFromUsd(message.daily_usd), + budgetWeeklyCents: centsFromUsd(message.weekly_usd), + budgetMonthlyCents: centsFromUsd(message.monthly_usd), + budgetRevision: sql`${agents.budgetRevision} + 1`, + }) + .where( + and( + eq(agents.tenantId, actor.tenantId), + eq(agents.id, agentId), + eq(agents.budgetRevision, message.expected_revision), + eq(agents.ownerEpoch, message.owner_epoch), + ), + ) + .returning({ id: agents.id }); + if (!changed.length) throw new ApiError("budget_revision_conflict", 409); + await tx.insert(auditEvents).values({ + tenantId: actor.tenantId, + agentId, + resourceId: agentId, + action: "budget.updated", + actorKeyId: actor.keyId, + actorOwnerId: ownerPrincipalId(message.owner), + requestHash: hashSecret(canonical(message)), + }); + await recordOwnerReceipt(tx, { + tenantId: actor.tenantId, + agentId, + action: "budget_set", + targetId: message.target_id, + ownerEpoch: message.owner_epoch, + message, + proof, + verification, + original, + }); + const result = await readBudgetFor(actor.tenantId, agentId, tx); + await onCommit?.(tx, result); + return result; + }); +} diff --git a/packages/agents-core/src/modules/operations/dispatch-fence.ts b/packages/agents-core/src/modules/operations/dispatch-fence.ts new file mode 100644 index 000000000..6d8eb91e2 --- /dev/null +++ b/packages/agents-core/src/modules/operations/dispatch-fence.ts @@ -0,0 +1,344 @@ +import { randomUUID } from "node:crypto"; +import { + agentGrants, + agents, + apiKeys, + auditEvents, + delayedExecutions, + operations, + type Tx, +} from "@near-intents-agent-api/database"; +import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm"; +import { databaseClock, getDatabase } from "../../lib/db.js"; +import { ApiError } from "../../shared/errors.js"; + +/** + * Authorization and dispatch have to agree on a linearization point. Checking readiness, then + * awaiting provider reads, then dispatching lets a policy tightening, owner change or lifecycle + * change land in the gap, so an operation admitted under the old state executes under the new one. + * + * An operation records the epochs it was authorized under, and a delegated one the grant that + * admitted it and the key that sent it. `lockDispatchAuthority` share-locks the agent, grant, key + * and delayed-job rows and validates them against database time. Revocation, epoch changes and + * timelock cancellation all update those rows, so they serialize against it: whichever + * transaction commits first wins, and the other observes it. Grants are independent: revoking one + * stops only the work it admitted, and issuing one stops nothing. + * + * - `assertDispatchFence` is that check as an early refusal between preparation steps. + * - `commitDispatch` is the commitment point, taken immediately before a provider write. In the + * same transaction it stamps `dispatch_committed_at` and records a `.committed` audit + * event. A revocation that commits first makes it fail closed. A revocation that commits after + * it cannot stop the provider write, and reports the operation as committed instead. + * + * Anything else that must be decided against the same state joins that transaction as a + * `beforeCommit` step, so its effects exist exactly when the commitment does. The owner's USD + * budget is one: the caps are read under the agent lock a budget change also needs, and the charge + * is inserted with the commitment, so no cap change can fall between the two. + * + * Expiry is evaluated at the commitment; an authorization that expires after it does not retract + * the write. + */ + +export type DispatchEpochs = { + ownerEpoch: number; + policyEpoch: number; + lifecycleEpoch: number; +}; + +export async function currentEpochs(tenantId: string, agentId: string): Promise { + const [agent] = await getDatabase() + .select({ + ownerEpoch: agents.ownerEpoch, + policyEpoch: agents.policyEpoch, + lifecycleEpoch: agents.lifecycleEpoch, + }) + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .limit(1); + if (!agent) throw new ApiError("agent_not_found", 404); + return agent; +} + +/** Rejects a direct provider write authorized under a stale epoch snapshot. */ +export async function assertAuthorizationEpochsCurrent( + tenantId: string, + agentId: string, + authorized: DispatchEpochs, +): Promise { + const current = await currentEpochs(tenantId, agentId); + if ( + authorized.ownerEpoch !== current.ownerEpoch || + authorized.policyEpoch !== current.policyEpoch || + authorized.lifecycleEpoch !== current.lifecycleEpoch + ) + throw new ApiError("authorization_stale", 409); +} + +type DispatchRefusal = + | "agent_not_found" + | "operation_not_found" + | "execution_timelocked" + | "authorization_stale"; + +const refusalStatus: Record = { + agent_not_found: 404, + operation_not_found: 404, + execution_timelocked: 409, + authorization_stale: 409, +}; + +type DispatchAuthority = + | { + operation: typeof operations.$inferSelect; + agent: typeof agents.$inferSelect; + /** Earliest delegated authority expiry, captured from the rows held locked by this transaction. */ + validUntil: Date | null; + } + | { refusal: DispatchRefusal }; + +/** + * Locks and validates an operation's dispatch authority inside the caller's transaction. Rows are + * locked in the order their writers take them (agent before grant, delayed job before operation). + * Revocation predicates sit in the locking statements, so a row revoked while we wait for its lock + * is re-evaluated and excluded. Expiry and timelock release are compared with database wall-clock + * time read after every lock is held. + */ +export async function lockDispatchAuthority( + tx: Tx, + tenantId: string, + agentId: string, + operationId: string, +): Promise { + const [agent] = await tx + .select() + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .for("share"); + if (!agent) return { refusal: "agent_not_found" }; + // Abandonment is final. Its cleanup can commit between an onboarding admission's epoch read and + // its insert, so the lifecycle itself, not only the epochs, keeps that admission from dispatching. + if (agent.lifecycle === "abandoned") return { refusal: "authorization_stale" }; + const [delayed] = await tx + .select({ + state: delayedExecutions.state, + executeAfter: delayedExecutions.executeAfter, + }) + .from(delayedExecutions) + .where( + and( + eq(delayedExecutions.tenantId, tenantId), + eq(delayedExecutions.agentId, agentId), + eq(delayedExecutions.id, operationId), + ), + ) + .for("share"); + if (delayed && delayed.state !== "dispatching") return { refusal: "execution_timelocked" }; + const [operation] = await tx + .select() + .from(operations) + .where( + and( + eq(operations.tenantId, tenantId), + eq(operations.agentId, agentId), + eq(operations.id, operationId), + ), + ) + .for("update"); + if (!operation) return { refusal: "operation_not_found" }; + if (!admittedUnder(operation, agent)) return { refusal: "authorization_stale" }; + const delegation = operation.authorizedGrantId + ? await lockLiveDelegation( + tx, + tenantId, + agentId, + operation.actorKeyId ?? "", + operation.authorizedGrantId, + ) + : null; + if (operation.authorizedGrantId && !delegation) return { refusal: "authorization_stale" }; + // Expiry and timelock release are decided once every row above is locked. + const decidedAt = await databaseClock(tx); + if (delayed?.executeAfter && delayed.executeAfter > decidedAt) + return { refusal: "execution_timelocked" }; + if (delegation && delegation.expiresAt <= decidedAt) return { refusal: "authorization_stale" }; + return { operation, agent, validUntil: delegation?.expiresAt ?? null }; +} + +/** + * Legacy rows have no admission authority, and a null epoch never equals a live one, so a missing + * component is stale instead of silently inheriting live epochs. + */ +function admittedUnder( + operation: typeof operations.$inferSelect, + agent: typeof agents.$inferSelect, +): boolean { + return ( + operation.authorizedOwnerEpoch === agent.ownerEpoch && + operation.authorizedPolicyEpoch === agent.policyEpoch && + operation.authorizedLifecycleEpoch === agent.lifecycleEpoch + ); +} + +/** + * Share-locks a delegation's unrevoked grant and the unrevoked key that sent it: revoking the key + * stops work it sent, even though the grant stays usable through other keys. Returns the earlier + * of their expiries, which the caller checks against database time. + */ +async function lockLiveDelegation( + tx: Tx, + tenantId: string, + agentId: string, + keyId: string, + grantId: string, +): Promise<{ expiresAt: Date } | null> { + const [key] = await tx + .select({ expiresAt: apiKeys.expiresAt }) + .from(apiKeys) + .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId), isNull(apiKeys.revokedAt))) + .for("share"); + const [grant] = await tx + .select({ expiresAt: agentGrants.expiresAt }) + .from(agentGrants) + .where( + and( + eq(agentGrants.tenantId, tenantId), + eq(agentGrants.agentId, agentId), + eq(agentGrants.id, grantId), + isNull(agentGrants.revokedAt), + ), + ) + .for("share"); + if (!key || !grant) return null; + return { expiresAt: key.expiresAt < grant.expiresAt ? key.expiresAt : grant.expiresAt }; +} + +/** Throws a dispatch-authority refusal as its API error. */ +export function refuseDispatch(refusal: DispatchRefusal): never { + throw new ApiError(refusal, refusalStatus[refusal]); +} + +/** Early refusal between preparation steps. It commits nothing; see `commitDispatch`. */ +export async function assertDispatchFence( + tenantId: string, + agentId: string, + operationId: string, +): Promise { + await getDatabase().transaction(async (tx) => { + const authority = await lockDispatchAuthority(tx, tenantId, agentId, operationId); + if ("refusal" in authority) refuseDispatch(authority.refusal); + }); +} + +/** + * Identity of one dispatch commitment. Only the attempt that won it holds this, so anything that + * must be undone for that write, such as a refund, can be bound to it and to no other attempt. + */ +export type DispatchReceipt = Readonly<{ token: string }>; + +/** Work joined to the commitment's transaction; a throw rolls the commitment back with it. */ +export type BeforeCommit = ( + tx: Tx, + context: { agent: typeof agents.$inferSelect; token: string }, +) => Promise; + +/** + * The commitment point, called immediately before a provider write with no await in between + * other than the write itself. Once it returns, a later revocation cannot retract the write. + */ +export async function commitDispatch( + tenantId: string, + agentId: string, + operationId: string, + beforeCommit?: BeforeCommit, +): Promise { + return getDatabase().transaction(async (tx) => { + const authority = await lockDispatchAuthority(tx, tenantId, agentId, operationId); + if ("refusal" in authority) refuseDispatch(authority.refusal); + const { operation, agent, validUntil } = authority; + // A terminal operation, for example one the maintenance sweep failed, must not dispatch. + if (operation.status !== "pending" && operation.status !== "uncertain") + throw new ApiError("operation_not_pending", 409); + // An execution, signature or relay has one provider write, right after its commitment. A second + // commitment would replay a write whose outcome is unknown, for example a stalled worker + // resuming after recovery dispatched. Policy preparation already reached the provider before + // its owner-signed submission commits, so its first commitment time is kept instead. + if (operation.dispatchCommittedAt && operation.kind !== "policy") + throw new ApiError("operation_not_pending", 409); + const token = randomUUID(); + await beforeCommit?.(tx, { agent, token }); + // A joined step can wait for locks after lockDispatchAuthority checked expiry. Row locks + // prevent revocation writes, not the passage of time. Refuse an expired delegation here; + // the same transaction rolls back any budget charge already inserted by the joined step. + const decidedAt = await databaseClock(tx); + if (validUntil && validUntil <= decidedAt) refuseDispatch("authorization_stale"); + const stamped = await tx + .update(operations) + .set({ + dispatchCommittedAt: sql`coalesce(${operations.dispatchCommittedAt}, ${decidedAt})`, + updatedAt: sql`clock_timestamp()`, + }) + .where( + and( + eq(operations.tenantId, tenantId), + eq(operations.agentId, agentId), + eq(operations.id, operationId), + ), + ) + .returning({ id: operations.id }); + // The row is locked above, so anything but one stamped row is a broken invariant; roll back. + if (stamped.length !== 1) throw new Error("dispatch commitment did not stamp exactly one row"); + await tx.insert(auditEvents).values({ + tenantId, + agentId, + action: `${operation.kind}.committed`, + resourceId: operationId, + actorKeyId: operation.actorKeyId, + grantId: operation.authorizedGrantId, + ownerEpoch: operation.authorizedOwnerEpoch, + policyEpoch: operation.authorizedPolicyEpoch, + lifecycleEpoch: operation.authorizedLifecycleEpoch, + requestHash: operation.requestHash, + }); + return { token }; + }); +} + +/** Bounds a revocation response; the audit log keeps every `.committed` event. */ +const committedOperationReportLimit = 100; + +/** Operation ids reported by a revocation; `truncated` means more exist than were listed. */ +export type CommittedOperations = { ids: string[]; truncated: boolean }; + +/** + * Delegated operations that committed under a key or grant and have no terminal outcome yet. + * Revocation calls this in its own transaction after updating the revoked row. That update waited + * for every in-flight commitment holding the row, so every operation that won the race is among + * these rows and the revocation cannot retract it. The list holds the oldest + * `committedOperationReportLimit` of them; `truncated` says there are more. + */ +export async function committedDelegatedOperations( + tx: Tx, + tenantId: string, + by: { keyId: string } | { agentId: string; grantId: string }, +): Promise { + const rows = await tx + .select({ id: operations.id }) + .from(operations) + .where( + and( + eq(operations.tenantId, tenantId), + "keyId" in by + ? eq(operations.actorKeyId, by.keyId) + : and(eq(operations.agentId, by.agentId), eq(operations.authorizedGrantId, by.grantId)), + isNotNull(operations.authorizedGrantId), + isNotNull(operations.dispatchCommittedAt), + inArray(operations.status, ["pending", "uncertain"]), + ), + ) + .orderBy(operations.dispatchCommittedAt, operations.id) + .limit(committedOperationReportLimit + 1); + return { + ids: rows.slice(0, committedOperationReportLimit).map((row) => row.id), + truncated: rows.length > committedOperationReportLimit, + }; +} diff --git a/packages/agents-core/src/modules/operations/execution-dispatch.ts b/packages/agents-core/src/modules/operations/execution-dispatch.ts new file mode 100644 index 000000000..19d1b14e1 --- /dev/null +++ b/packages/agents-core/src/modules/operations/execution-dispatch.ts @@ -0,0 +1,118 @@ +import { type ExecutionRequest, executionResultSchema } from "@near-intents-agent-api/contracts"; +import { OutlayerError } from "@near-intents-agent-api/outlayer"; +import { getOutlayer } from "../../lib/outlayer.js"; +import type { AssetPrice } from "../../lib/prices.js"; +import { getPrices } from "../../lib/token-catalog.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { errorFields, logger } from "../../shared/logger.js"; +import { requireReadyPolicy } from "../wallet/policy-readiness.js"; +import type { CustodyWalletRecord } from "../wallet/repository.js"; +import { custodyCredential } from "../wallet/service.js"; +import { assertDispatchFence, commitDispatch, type DispatchReceipt } from "./dispatch-fence.js"; +import { isProviderPreBroadcastRefusal } from "./outcome.js"; +import { dispatchProviderExecution } from "./provider-dispatch.js"; +import { depositAddressIssued } from "./provider-observation.js"; +import { providerResult, readString } from "./provider-result.js"; +import { chargeSpend, PriceRequired, refundSpend, spendOf } from "./spend-budget.js"; + +export async function dispatchExecution( + actor: Actor, + agentId: string, + wallet: CustodyWalletRecord, + input: ExecutionRequest, + operationId: string, +) { + // Admission recorded the epochs this operation was authorized under; re-check them at the + // commitment point. A policy tightening, owner change or lifecycle change since admission + // invalidates the pending authorization before any provider write happens. + await assertDispatchFence(actor.tenantId, agentId, operationId); + await requireReadyPolicy(actor.tenantId, agentId); + // The commitment point: revocation that commits first refuses this write; one that commits + // later reports the operation as committed. The owner's USD budget is decided and charged in the + // same transaction, so a timelocked execution counts when it dispatches, not when it was queued. + const receipt = await commitExecutionDispatch(actor, agentId, operationId, input); + const response = await dispatchCountingSpend(actor, agentId, wallet, input, operationId, receipt); + const projected = depositAddressIssued(input.action, providerResult(response)); + return executionResultSchema.parse({ + action: input.action, + ...(confidentialRoute(input) ? { confidential: true } : {}), + ...(input.action === "withdraw" ? { chain: input.request.chain } : {}), + near_account_id: wallet.nearAccountId, + provider_request_id: readString(response, "request_id"), + ...projected, + evidence: projected, + }); +} + +/** Prices are fetched again at most this often when a quote expires while locks are awaited. */ +const maxQuoteAttempts = 3; + +/** + * Commits dispatch and charges the budget atomically. A refusal, or a failure of any kind, leaves + * neither a commitment nor a charge. Prices are read over the network, never inside the + * transaction: an account never configured for USD tracking needs none. When tracking applies + * and no price is at hand, the attempt rolls back, is priced outside any lock and decided again + * from the top against the caps then in force. + */ +export async function commitExecutionDispatch( + actor: Actor, + agentId: string, + operationId: string, + input: ExecutionRequest, +): Promise { + const spend = spendOf(input); + let price: AssetPrice | undefined; + for (let attempt = 1; ; attempt += 1) { + try { + return await commitDispatch(actor.tenantId, agentId, operationId, (tx, { agent, token }) => + chargeSpend(tx, agent, operationId, token, input, price), + ); + } catch (error) { + if (!(error instanceof PriceRequired) || !spend) throw error; + if (attempt === maxQuoteAttempts) throw new ApiError("spend_price_unavailable", 503); + price = await getPrices().price(spend.asset); + if (!price) throw new ApiError("spend_price_unavailable", 503); + } + } +} + +/** + * The charge is refunded only when OutLayer refused the write before admitting it, and only by the + * attempt that holds the commitment's receipt. Anything else may have moved value and stays + * counted. An attempt that lost the commitment never reaches here, so it cannot refund the winner. + */ +async function dispatchCountingSpend( + actor: Actor, + agentId: string, + wallet: CustodyWalletRecord, + input: ExecutionRequest, + operationId: string, + receipt: DispatchReceipt, +) { + try { + return await dispatchProviderExecution( + getOutlayer(), + input, + custodyCredential(wallet), + operationId, + ); + } catch (error) { + if (spendOf(input) && error instanceof OutlayerError && isProviderPreBroadcastRefusal(error)) { + // A failed refund leaves the charge counted, which only over-counts; the provider's refusal + // is still the outcome to report. + await refundSpend(actor.tenantId, agentId, operationId, receipt.token).catch((refundError) => + logger.error("spend_refund_failed", { + operation_id: operationId, + ...errorFields(refundError), + }), + ); + } + throw error; + } +} + +/** Requests that `dispatchProviderExecution` sends to a `/confidential/*` provider route. */ +function confidentialRoute(input: ExecutionRequest): boolean { + return "confidential" in input.request && input.request.confidential === true; +} diff --git a/packages/agents-core/src/modules/operations/execution-service.ts b/packages/agents-core/src/modules/operations/execution-service.ts new file mode 100644 index 000000000..bb3a259c7 --- /dev/null +++ b/packages/agents-core/src/modules/operations/execution-service.ts @@ -0,0 +1,210 @@ +import { + canonical, + canonicalChain, + type ExecutionRequest, + executionResultSchema, + operationStatusSchema, +} from "@near-intents-agent-api/contracts"; +import { getRuntime } from "../../config/runtime.js"; +import { getOutlayer } from "../../lib/outlayer.js"; +import { getTokenCatalog } from "../../lib/token-catalog.js"; +import type { Actor } from "../../shared/actor.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { grantCheckForExecution } from "../agents/grant-constraints.js"; +import { authorizeDelegatedAction, type GrantSelector } from "../agents/grant-service.js"; +import { findAgent } from "../agents/repository.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { requireReadyPolicy } from "../wallet/policy-readiness.js"; +import { custodyCredential } from "../wallet/service.js"; +import { dispatchExecution } from "./execution-dispatch.js"; +import { toOperationDto } from "./mapper.js"; +import { classifyExecutionOutcome } from "./outcome.js"; +import { providerDeduplicatesResubmission } from "./provider-dispatch.js"; +import { depositIntentId, observeDeposit } from "./provider-observation.js"; +import { providerResult, readString } from "./provider-result.js"; +import { + findOperation, + leaseOperationRecovery, + settleRecoveredDelayedJob, + updateOperationResult, +} from "./repository.js"; +import { readOperation, runOperation } from "./service.js"; + +function timingFor( + resume: { operationId: string; grantId: string } | undefined, + delaySeconds: number, + nearAccountId: string, + request: ExecutionRequest, + grantId: string, + ownerEpoch: number, +) { + if (resume) return { reserved: true, reservedOperationId: resume.operationId }; + if (!delaySeconds) return {}; + return { + delayed: { delaySeconds, nearAccountId, action: request.action, request, grantId, ownerEpoch }, + }; +} + +/** + * Fund-moving actions call OutLayer `/wallet/v1/*` with the stored custody credential. + * The provider's wallet policy is the authority boundary; this server never signs. + * + * `grant` is the caller's grant token. Only a resume of an admitted operation names its grant by + * id, and a resume must run under the exact grant that admitted it. + */ +export async function execute( + actor: Actor, + agentId: string, + input: ExecutionRequest, + grant: GrantSelector, + resume?: { operationId: string; grantId: string }, +) { + const { wallet } = await requireBoundAgent(actor, agentId); + await requireReadyPolicy(actor.tenantId, agentId); + let sourceChain: string | undefined; + if (input.action === "cross_chain_deposit" && input.request.source_asset) { + const source = (await getTokenCatalog().list()).find( + (token) => token.assetId === input.request.source_asset, + ); + sourceChain = source?.blockchain; + if ( + !sourceChain || + (input.request.chain && canonicalChain(input.request.chain) !== canonicalChain(sourceChain)) + ) + throw new ApiError("grant_destination_unresolved", 403); + } + const check = grantCheckForExecution(input, getRuntime().network, sourceChain); + // A tenant API key is not an owner delegation. The grant the request names is checked against + // the exact action and every destination before the request can reach a provider write. + const decision = await authorizeDelegatedAction({ + actor, + agentId, + grant, + action: input.action, + check, + walletId: wallet.providerWalletId, + }); + if (resume && decision.grantId !== resume.grantId) throw new ApiError("authorization_stale", 409); + const agent = await findAgent(actor.tenantId, agentId); + if (!agent || agent.policyEpoch !== decision.authorizationEpochs.policyEpoch) + throw new ApiError("authorization_stale", 409); + return runOperation({ + actor, + agentId, + kind: "execute", + action: input.action, + idempotencyKey: input.request.idempotencyKey, + request: input, + ...timingFor( + resume, + agent.timelockDelaySeconds, + wallet.nearAccountId, + input, + decision.grantId, + decision.authorizationEpochs.ownerEpoch, + ), + grant: { id: decision.grantId, label: decision.label }, + authorizationEpochs: decision.authorizationEpochs, + run: (operationId) => dispatchExecution(actor, agentId, wallet, input, operationId), + }); +} + +/** + * Observation only. A refresh may query the provider for an already-submitted request and record + * what it learns; it must never itself submit work. Recovery of an operation with no provider + * request id lives on the explicit retry path, which requires the writer's scope, the stored + * authorization proof and the current dispatch fence. + */ +export async function refreshExecution(actor: Actor, agentId: string, operationId: string) { + const prior = await readOperation(actor, agentId, operationId); + // Deletion completes only through `refreshDeletion`, which retires the custody wallet with it. + if (prior.kind !== "execute" || (prior.result as { action?: string } | null)?.action === "delete") + throw new ApiError("operation_kind_mismatch", 409); + if (prior.status === "completed" || prior.status === "failed") + return toOperationDto(actor, prior); + const result = prior.result as { provider_request_id?: string | null } | null; + const intentId = depositIntentId(result); + if (!result?.provider_request_id && !intentId) return toOperationDto(actor, prior); + const { wallet } = await requireBoundAgent(actor, agentId); + const refreshed = intentId + ? await observeDeposit(custodyCredential(wallet), intentId, result) + : await observeRequest(custodyCredential(wallet), result?.provider_request_id ?? ""); + const priorEvidence = + result && typeof result === "object" + ? ((result as { evidence?: Record }).evidence ?? {}) + : {}; + const merged = executionResultSchema.parse({ + ...result, + ...refreshed, + // A follow-up status can carry the receipt that was absent at submission. Merging the new + // provider fields over the stored evidence is what lets the classifier promote a pending + // operation to completed; keeping the stale evidence would leave it uncertain forever. + evidence: { ...priorEvidence, ...refreshed }, + }); + const classification = classifyExecutionOutcome( + typeof merged.action === "string" ? merged.action : null, + merged, + ); + // Refresh re-persists provider data, so it must pass the same redaction boundary as first write. + const persisted = await updateOperationResult( + actor.tenantId, + agentId, + operationId, + "execute", + operationStatusSchema.parse(classification.status), + merged, + ); + if (!persisted) throw new ApiError("operation_not_found", 404); + return toOperationDto(actor, persisted); +} + +async function observeRequest(credential: string, requestId: string) { + const provider = await getOutlayer().requestStatus(credential, requestId); + if (readString(provider, "request_id") !== requestId) + throw new ApiError("provider_request_mismatch", 502); + return providerResult(provider); +} + +/** + * Recovers an execution left `uncertain` only when its dispatch commitment never happened. That + * proves no provider write began, so this is its first dispatch. A committed operation with lost + * response stays uncertain for provider reconciliation; idempotency retention is undocumented. + */ +export async function recoverExecution( + actor: Actor, + agentId: string, + operationId: string, + input: ExecutionRequest, + grantToken: string | undefined, +) { + const prior = await readOperation(actor, agentId, operationId); + if (prior.kind !== "execute") throw new ApiError("operation_kind_mismatch", 409); + const stored = prior.result as { provider_request_id?: string | null } | null; + if ( + prior.status !== "uncertain" || + stored?.provider_request_id || + !prior.authorizedGrantId || + !providerDeduplicatesResubmission(input) + ) + throw new ApiError("operation_not_recoverable", 409); + if (prior.requestHash !== hashSecret(canonical(input))) + throw new ApiError("idempotency_conflict", 409); + if (!(await leaseOperationRecovery(actor.tenantId, agentId, operationId))) + throw new ApiError("operation_recovery_unavailable", 409); + try { + // The caller's token must name the grant that admitted the operation; another grant, even a + // live one on the same agent, cannot adopt it. + return await execute( + actor, + agentId, + input, + { token: grantToken }, + { operationId, grantId: prior.authorizedGrantId }, + ); + } finally { + const settled = await findOperation(actor.tenantId, agentId, operationId); + if (settled) + await settleRecoveredDelayedJob(actor.tenantId, agentId, operationId, settled.status); + } +} diff --git a/packages/agents-core/src/modules/operations/identity-signing.ts b/packages/agents-core/src/modules/operations/identity-signing.ts new file mode 100644 index 000000000..47f54ccf0 --- /dev/null +++ b/packages/agents-core/src/modules/operations/identity-signing.ts @@ -0,0 +1,59 @@ +import { + canonical, + type IdentitySigningChallenge, + identitySigningChallengeSchema, + nearAccountSchema, +} from "@near-intents-agent-api/contracts"; +import { ApiError } from "../../shared/errors.js"; + +const maxChallengeLifetimeMs = 5 * 60 * 1000; +const maxClockSkewMs = 30 * 1000; + +/** Parse only the canonical, short-lived identity envelope accepted by detached sign routes. */ +export function parseIdentitySigningMessage( + message: string, + expectedChain: IdentitySigningChallenge["chain"], + expectedAudience?: string, +): IdentitySigningChallenge { + let value: unknown; + try { + value = JSON.parse(message); + } catch { + throw new ApiError("signing_identity_challenge_invalid", 400); + } + + const parsed = identitySigningChallengeSchema.safeParse(value); + if (!parsed.success) throw new ApiError("signing_identity_challenge_invalid", 400); + const challenge = parsed.data; + const now = Date.now(); + if ( + canonical(challenge) !== message || + challenge.chain !== expectedChain || + (expectedAudience !== undefined && challenge.audience !== expectedAudience) || + challenge.expires_at_ms <= challenge.issued_at_ms || + challenge.expires_at_ms <= now || + challenge.issued_at_ms > now + maxClockSkewMs || + challenge.expires_at_ms - challenge.issued_at_ms > maxChallengeLifetimeMs || + challenge.expires_at_ms - now > maxChallengeLifetimeMs || + !isAllowedAudience(challenge) + ) + throw new ApiError("signing_identity_challenge_invalid", 400); + + return challenge; +} + +function isAllowedAudience(challenge: IdentitySigningChallenge): boolean { + if (challenge.chain === "near") return nearAccountSchema.safeParse(challenge.audience).success; + + try { + const audience = new URL(challenge.audience); + return ( + audience.protocol === "https:" && + audience.origin === challenge.audience && + audience.username === "" && + audience.password === "" + ); + } catch { + return false; + } +} diff --git a/packages/agents-core/src/modules/operations/mapper.ts b/packages/agents-core/src/modules/operations/mapper.ts new file mode 100644 index 000000000..f3eddc6cf --- /dev/null +++ b/packages/agents-core/src/modules/operations/mapper.ts @@ -0,0 +1,28 @@ +import type { Operation as OperationContract } from "@near-intents-agent-api/contracts"; +import type { Actor } from "../../shared/actor.js"; +import type { OperationRecord } from "./repository.js"; +import { projectOperationResult } from "./result-projection.js"; + +type OperationDtoInput = Pick & + Partial>; + +/** + * The DTO is the read boundary: safe metadata is always returned, while the stored body is only + * projected back through the redactor for the actor asking. Every operation read route funnels + * through here, so a caller cannot bypass projection by choosing a different endpoint. + */ +export async function toOperationDto( + _actor: Actor, + operation: OperationDtoInput & { result: T }, +): Promise> { + return mapOperationDto(operation); +} + +function mapOperationDto(operation: OperationDtoInput & { result: T }): OperationContract { + return { + id: operation.id, + kind: operation.kind, + status: operation.status, + result: projectOperationResult(operation.kind, operation.result) as T, + }; +} diff --git a/packages/agents-core/src/modules/operations/outcome.ts b/packages/agents-core/src/modules/operations/outcome.ts new file mode 100644 index 000000000..6c8273a76 --- /dev/null +++ b/packages/agents-core/src/modules/operations/outcome.ts @@ -0,0 +1,235 @@ +import { + type OperationKind, + type OperationStatus, + operationStatusSchema, +} from "@near-intents-agent-api/contracts"; +import { readRecord } from "./provider-result.js"; + +/** + * Errors that provably mean no provider write happened. A refusal decided before broadcast is + * recorded as failed rather than uncertain. Operation admission adds route-specific failures + * only where their origin proves no provider write happened. + */ +export const preBroadcastRefusalCodes = [ + "policy_denied", + "authorization_stale", + "agent_archived", + "agent_paused", + "provider_policy_mismatch", + "policy_not_ready", +] as const; + +export function isPreBroadcastRefusalCode(code: string | undefined): boolean { + return code !== undefined && (preBroadcastRefusalCodes as readonly string[]).includes(code); +} + +/** + * Provider refusals OutLayer decides before it admits, signs or submits anything: its policy + * engine, the balance check, a frozen wallet, a custody account that cannot prepay gas, and + * `wallet_busy`. OutLayer runs one money operation per custody wallet and takes that per-wallet + * lock before its idempotency check, so an overlapping request is refused without a request row. + * A transaction that did broadcast and then reverted comes back as `onchain_tx_failed`, never as + * one of these. + * + * The refusal proves nothing about an earlier attempt: when the transport had already resent + * the request, the refusal can be caused by that attempt, for example `wallet_busy` while it still + * holds the wallet. Only an answer to the first send classifies the operation as never executed. + */ +const providerPreBroadcastRefusalCodes = new Set([ + "policy_denied", + // OutLayer's 400 validation refusals: the request was never admitted. + "invalid_request", + "amount_too_low", + "route_unavailable", + "invalid_address", + "unsupported_chain", + "unsupported_token", + "insufficient_balance", + "wallet_frozen", + "custody_native_balance_required", + "wallet_busy", +]); + +export function isProviderPreBroadcastRefusal(error: { + code: string; + status?: number; + resent?: boolean; +}): boolean { + const status = error.status ?? 0; + return ( + !error.resent && + providerPreBroadcastRefusalCodes.has(error.code) && + status >= 400 && + status < 500 && + status !== 408 && + status !== 429 + ); +} + +/** + * The classifier separates the provider's claim from what the evidence proves. It is the only + * place that decides an operation's lifecycle status: + * + * A transaction hash is an identifier, not proof of settlement. A missing status with a hash, an + * empty evidence array, or a pending settlement field must never become `completed`. + */ + +const withdrawEvidence = [ + "tx_hash", + "settlement_tx_hash", + "destination_tx_hash", + "receipt_hash", +] as const; + +/** Settlement evidence that actually demonstrates funds movement for its action. */ +const settlementEvidence: Record = { + swap: ["settlement_tx_hash", "destination_tx_hash", "receipt_hash"], + withdraw: withdrawEvidence, + // Confidential routes settle on the private `intents.far` shard: they never return a public + // transaction hash, and the intent hash is the provider's settlement identifier. + shield: ["intent_hash"], + unshield: ["intent_hash"], + intents_transfer: ["intent_hash", "transfer_intent_hash", "settlement_tx_hash"], + confidential_transfer: ["intent_hash", "settlement_tx_hash"], + confidential_deposit: ["intent_hash"], + cross_chain_deposit: ["settlement_tx_hash", "destination_tx_hash", "receipt_hash"], + // Address generation only proves the provider created a deposit address, not that a deposit + // happened. `amount_out` is an identifier, not verified settlement. + // Provider deletion settles with its `DeleteAccount` transaction. A wallet with no on-chain + // account is retired by erasing the API's only credential, which is itself the evidence. + delete: ["tx_hash", "credential_erased"], +}; + +/** + * A confidential route settles on the private shard, where the intent hash is the only settlement + * identifier: shard-internal swaps and transfers never get a public transaction hash. + */ +const confidentialSettlementEvidence = ["intent_hash"] as const; +/** + * A same-chain NEAR withdrawal is a gasless intent that `intents.near` itself executes, delivering + * to the recipient in that execution; its intent hash identifies the settling transaction. A + * cross-chain exit keeps needing evidence from the destination chain. + */ +const nearWithdrawEvidence = [...withdrawEvidence, "intent_hash"] as const; + +function successEvidenceFor(action: string | null, result: unknown): readonly string[] { + if (action === "withdraw") + return value(result, "chain") === "near" ? nearWithdrawEvidence : withdrawEvidence; + if (value(result, "confidential") === true) return confidentialSettlementEvidence; + return action ? (settlementEvidence[action] ?? []) : []; +} + +function value(value: unknown, key: string) { + return readRecord(value)?.[key]; +} + +function nonEmpty(value: unknown) { + if (value === undefined || value === null) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "string") return value.length > 0; + return true; +} + +function hasReceipt(evidence: Record, keys: readonly string[]) { + return keys.some((key) => nonEmpty(evidence[key])); +} + +export type OutcomeClassification = { + status: OperationStatus; + /** Provider-reported state kept separate from any verified settlement claim. */ + providerStatus: string | null; + settlementPending: boolean; + failureCode: string | null; +}; + +const pendingStatuses = new Set([ + "pending", + "pending_approval", + "pending_deposit", + "processing", + "approved", +]); +const terminalFailureStatuses = new Set([ + "partially_failed", + "failed", + "refunded", + "rejected", + "expired", + "cancelled", +]); + +/** + * Reads the fields the classifier needs out of an untrusted provider result. + * + * Receipt fields may arrive at the top level, in a nested `evidence` object, or split across both + * (a submission returns a hash, a later status poll returns a receipt). Both are merged, with the + * nested evidence winning on conflict, so a receipt is never hidden by an earlier weaker record. + */ +function readOutcomeInputs(result: unknown) { + const topLevel = (result as Record | null) ?? {}; + const nested = value(result, "evidence"); + const evidence = { + ...(typeof topLevel === "object" && topLevel !== null ? topLevel : {}), + ...(nested && typeof nested === "object" ? (nested as Record) : {}), + }; + const rawStatus = value(result, "provider_status") ?? value(result, "status") ?? null; + const settlementStatus = value(result, "settlement_status"); + const rawFailure = value(result, "failure_code"); + return { + evidence, + providerStatus: typeof rawStatus === "string" ? rawStatus : null, + settlementPending: + typeof settlementStatus === "string" && + /pending|processing|submitted/i.test(settlementStatus), + failureCode: typeof rawFailure === "string" ? rawFailure : null, + }; +} + +/** + * Action-specific classifier. It is deliberately conservative: only a provider status that means + * terminal success plus evidence appropriate to the action produces `completed`. Anything else is + * `uncertain` or `pending`, and the settlement state is preserved rather than flattened. + */ +export function classifyExecutionOutcome( + action: string | null, + result: unknown, +): OutcomeClassification { + const { evidence, providerStatus, settlementPending, failureCode } = readOutcomeInputs(result); + const base = { providerStatus, settlementPending, failureCode }; + + // No usable provider status: a hash alone is an identifier. Report uncertainty. + if (!providerStatus || providerStatus === "unknown") return { ...base, status: "uncertain" }; + if (terminalFailureStatuses.has(providerStatus)) return { ...base, status: "failed" }; + if (pendingStatuses.has(providerStatus)) return { ...base, status: "pending" }; + if (providerStatus !== "success") return { ...base, status: "uncertain" }; + + // Provider success only completes when the action's own settlement evidence is present and + // no settlement is still pending. + const successEvidence = successEvidenceFor(action, result); + // OutLayer SwapResponse documents amount_out + intent_hash as the settled result. + // Public swaps credit public balances, including assets whose home chain is external; + // no destination-chain transaction is required until a separate withdrawal. + const settledIntentsSwap = + action === "swap" && + typeof evidence.amount_out === "string" && + /^[0-9]+$/.test(evidence.amount_out) && + /[1-9]/.test(evidence.amount_out) && + typeof evidence.intent_hash === "string" && + evidence.intent_hash.length > 0; + if (settlementPending || (!settledIntentsSwap && !hasReceipt(evidence, successEvidence))) + return { ...base, status: "uncertain" }; + return { ...base, status: "completed" }; +} + +export function operationStatusFor(kind: OperationKind, result: unknown): OperationStatus { + if (kind === "policy" && value(result, "status") === "pending_wallet_signature") return "pending"; + if (kind === "execute") { + const action = value(result, "action"); + return classifyExecutionOutcome(typeof action === "string" ? action : null, result).status; + } + if (result && typeof result === "object" && "status" in result) { + const parsed = operationStatusSchema.safeParse((result as { status: unknown }).status); + if (parsed.success) return parsed.data; + } + return "completed"; +} diff --git a/packages/agents-core/src/modules/operations/provider-dispatch.ts b/packages/agents-core/src/modules/operations/provider-dispatch.ts new file mode 100644 index 000000000..7756aa73e --- /dev/null +++ b/packages/agents-core/src/modules/operations/provider-dispatch.ts @@ -0,0 +1,93 @@ +import type { ExecutionRequest } from "@near-intents-agent-api/contracts"; +import type { OutlayerWalletClient } from "@near-intents-agent-api/outlayer"; + +type Credential = string; + +/** Translate canonical execution actions into the provider's wallet operations. */ +export function dispatchProviderExecution( + client: OutlayerWalletClient, + input: ExecutionRequest, + credential: Credential, + operationId: string, +): Promise { + switch (input.action) { + case "cross_chain_deposit": + return input.request.confidential + ? client.confidentialDepositCrossChain(credential, { + amount: input.request.amount, + chain: input.request.chain, + refundAddress: input.request.refund_address, + sourceAsset: input.request.source_asset, + }) + : client.intentsDepositCrossChain(credential, { + amount: input.request.amount, + chain: input.request.chain, + destinationAsset: input.request.destination_asset, + refundAddress: input.request.refund_address, + sourceAsset: input.request.source_asset, + token: input.request.token, + }); + case "intents_transfer": + case "confidential_transfer": + case "confidential_deposit": { + const paths = { + intents_transfer: "intents/transfer", + confidential_transfer: "confidential/transfer", + confidential_deposit: "confidential/deposit", + } as const; + const { idempotencyKey: _key, ...body } = input.request; + return client.walletExecution(paths[input.action], credential, body, operationId); + } + case "swap": { + const body = { + amountIn: input.request.amount_in, + minAmountOut: input.request.min_amount_out, + tokenIn: input.request.token_in, + tokenOut: input.request.token_out, + }; + return input.request.confidential + ? client.confidentialSwap(credential, body, operationId) + : client.intentsSwap(credential, body, operationId); + } + case "withdraw": { + const body = { + amount: input.request.amount, + async: input.request.async, + chain: input.request.chain, + memo: input.request.memo, + token: input.request.token, + to: input.request.to, + }; + return input.request.confidential + ? client.confidentialWithdraw(credential, body, operationId) + : client.intentsWithdraw(credential, body, operationId); + } + case "shield": + return client.confidentialShield( + credential, + { amount: input.request.amount, token: input.request.token }, + operationId, + ); + case "unshield": + return client.confidentialUnshield( + credential, + { amount: input.request.amount, token: input.request.token }, + operationId, + ); + } +} + +/** + * Whether the OutLayer route `dispatchProviderExecution` uses for this request accepts the + * operation id as its idempotency key, so a resubmission is reported as a duplicate of the + * original request instead of executing again. Cross-chain deposit routes accept no key, so their + * wrappers send none and neither the transport nor recovery ever resubmits them. + */ +export function providerDeduplicatesResubmission(input: ExecutionRequest): boolean { + switch (input.action) { + case "cross_chain_deposit": + return false; + default: + return true; + } +} diff --git a/packages/agents-core/src/modules/operations/provider-observation.ts b/packages/agents-core/src/modules/operations/provider-observation.ts new file mode 100644 index 000000000..23b8eb6c1 --- /dev/null +++ b/packages/agents-core/src/modules/operations/provider-observation.ts @@ -0,0 +1,91 @@ +import { getOutlayer } from "../../lib/outlayer.js"; +import { ApiError } from "../../shared/errors.js"; +import { type ProviderResult, providerResult, readRecord, readString } from "./provider-result.js"; + +const depositActions = new Set(["cross_chain_deposit"]); + +/** Only canonical inbound routes may use the provider's deposit-status identity. */ +export function depositIntentId(result: unknown) { + return depositActions.has(readString(result, "action") ?? "") + ? readString(result, "intent_id") + : null; +} + +/** + * OutLayer answers a new deposit with the one-time address and its intent id, and no status. That + * answer means exactly one thing: the address exists and waits for funds. Reading it as an unknown + * outcome would report a live deposit address as UNCERTAIN. + */ +export function depositAddressIssued(action: string, projected: ProviderResult): ProviderResult { + if (!depositActions.has(action) || projected.status !== "unknown") return projected; + const issued = Boolean(projected.deposit_address) && Boolean(projected.intent_id); + return issued ? { ...projected, status: "pending_deposit" } : projected; +} + +/** + * The public deposit status in the API's vocabulary. OutLayer reports `bridging` from the moment + * the address exists, paid or not (observed live on mainnet), so it proves nothing about funds: + * the deposit keeps waiting unless the attached 1Click status says the deposit was seen. + */ +const depositStatuses: Record = { + pending: "pending_deposit", + bridging: "pending_deposit", +}; +/** 1Click's own deposit states, when OutLayer attaches them as `result.status`. */ +const oneClickDepositStatuses: Record = { + PENDING_DEPOSIT: "pending_deposit", + INCOMPLETE_DEPOSIT: "pending_deposit", + KNOWN_DEPOSIT_TX: "processing", + PROCESSING: "processing", +}; + +function publicDepositStatus(status: string | null, nested: unknown) { + if (status !== "bridging" && status !== "pending") return undefined; + const oneClick = readString(nested, "status"); + return (oneClick && oneClickDepositStatuses[oneClick]) ?? depositStatuses[status]; +} + +/** 1Click lists hashes as plain strings or as `{ hash, explorerUrl }`; the first is the one. */ +function firstHash(value: unknown, key: string) { + const values = readRecord(value)?.[key]; + const first = Array.isArray(values) ? values[0] : undefined; + const hash = typeof first === "string" ? first : readString(first, "hash"); + return hash && hash.length <= 256 ? hash : undefined; +} + +/** Deposit status uses intent_id and nested 1Click hash arrays, not request_id. */ +export async function observeDeposit(credential: string, intentId: string, prior: unknown) { + const confidential = readRecord(prior)?.confidential === true; + const provider = getOutlayer(); + const response = confidential + ? await provider.requestStatus(credential, intentId) + : await provider.depositIntentStatus(credential, intentId); + const nested = readRecord(response)?.result; + const address = readString(response, "deposit_address") ?? readString(nested, "deposit_address"); + const identity = confidential ? "request_id" : "intent_id"; + if ( + readString(response, identity) !== intentId || + (address !== null && address !== readString(prior, "deposit_address")) + ) + throw new ApiError("provider_request_mismatch", 502); + // The settled 1Click status sits in `result`, its hashes under `swap_details` (OutLayer's + // spelling), `swapDetails` (1Click's) or directly. + const settled = + readRecord(nested)?.swap_details ?? readRecord(nested)?.swapDetails ?? nested ?? null; + const status = confidential + ? undefined + : publicDepositStatus(readString(response, "status"), nested); + const intentHash = firstHash(settled, "intentHashes"); + const destinationHash = firstHash(settled, "destinationChainTxHashes"); + // A deposit into Intents has no destination chain: 1Click leaves `destinationChainTxHashes` + // empty (observed live for Base USDC) and names the NEAR transaction that credited the balance. + const settlementHash = firstHash(settled, "nearTxHashes"); + return providerResult({ + ...readRecord(response), + ...(status ? { status } : {}), + // Only add what the settled status proves; never blank a hash the response itself carried. + ...(intentHash ? { intent_hash: intentHash } : {}), + ...(destinationHash ? { destination_tx_hash: destinationHash } : {}), + ...(settlementHash ? { settlement_tx_hash: settlementHash } : {}), + }); +} diff --git a/packages/agents-core/src/modules/operations/provider-result.ts b/packages/agents-core/src/modules/operations/provider-result.ts new file mode 100644 index 000000000..10a8c5413 --- /dev/null +++ b/packages/agents-core/src/modules/operations/provider-result.ts @@ -0,0 +1,167 @@ +const knownStatuses = [ + "pending", + "pending_approval", + "pending_deposit", + "processing", + "approved", + "success", + "partially_failed", + "failed", + "refunded", + "rejected", + "expired", + "cancelled", + "needs_review", +] as const; + +type RecordValue = Record; + +export type ProviderResult = { + status: string; + provider_request_id?: string; + [key: string]: unknown; +}; + +/** Narrows an untrusted provider payload to a record before any field is read from it. */ +export function readRecord(value: unknown): RecordValue | null { + return value && typeof value === "object" ? (value as RecordValue) : null; +} + +export function readString(value: unknown, key: string) { + const field = readRecord(value)?.[key]; + return typeof field === "string" ? field : null; +} + +function readNumber(value: unknown, key: string) { + const field = readRecord(value)?.[key]; + return typeof field === "number" && Number.isSafeInteger(field) ? field : null; +} + +function readBoolean(value: unknown, key: string) { + return readRecord(value)?.[key] === true; +} + +function firstString(primary: unknown, secondary: unknown, key: string) { + return readString(primary, key) ?? readString(secondary, key); +} + +const evidenceKeys = [ + "approval_id", + "request_hash", + "tx_hash", + "intent_hash", + "transfer_intent_hash", + "destination_tx_hash", + "receipt_id", + "receipt_hash", + "settlement_status", + "settlement_tx_hash", + "settled_at", + "refund_tx_hash", + "refund_status", + "failure_code", + "failure_reason", + "fee", + "fee_amount", + "fee_token", + "provider_fee", + "provider_fee_amount", + "provider_fee_token", + "solver_fee", + "solver_fee_amount", + "solver_fee_token", + "protocol_fee", + "protocol_fee_amount", + "protocol_fee_token", + "amount_out", + "deposit_address", + "intent_id", + "expires_at", + "poll_url", + "token", + "amount", + "created_at", + "memo", +] as const; + +function copyStringEvidence(evidence: RecordValue, primary: unknown, secondary: unknown) { + for (const key of evidenceKeys) { + const field = firstString(primary, secondary, key); + if (field !== null) evidence[key] = field; + } +} + +function copyNumericEvidence(evidence: RecordValue, primary: unknown, secondary: unknown) { + const estimatedTime = + readNumber(primary, "estimated_time_secs") ?? readNumber(secondary, "estimated_time_secs"); + if (estimatedTime !== null) evidence.estimated_time_secs = estimatedTime; + for (const key of ["required", "approved"] as const) { + const value = readNumber(primary, key) ?? readNumber(secondary, key); + if (value !== null) evidence[key] = value; + } +} + +function copyArrayEvidence( + evidence: RecordValue, + primary: unknown, + secondary: unknown, + key: "promises", +) { + const primaryValue = readRecord(primary)?.[key]; + const secondaryValue = readRecord(secondary)?.[key]; + const value = Array.isArray(primaryValue) + ? primaryValue + : Array.isArray(secondaryValue) + ? secondaryValue + : null; + if (value) evidence[key] = value; +} + +/** + * 1Click-settled routes report the delivery transaction only inside `swap_details`, as + * camelCase arrays of plain hashes. The first destination hash is the payout transaction. + */ +function copySwapDetailsEvidence(evidence: RecordValue, primary: unknown, secondary: unknown) { + if (evidence.destination_tx_hash !== undefined) return; + const details = readRecord( + readRecord(primary)?.swap_details ?? readRecord(secondary)?.swap_details, + ); + const hashes = details?.destinationChainTxHashes; + const first = Array.isArray(hashes) ? hashes[0] : undefined; + const hash = typeof first === "string" ? first : readString(first, "hash"); + if (hash && hash.length <= 256) evidence.destination_tx_hash = hash; +} + +function readEvidence(primary: unknown, secondary: unknown) { + const evidence: RecordValue = {}; + copyStringEvidence(evidence, primary, secondary); + copySwapDetailsEvidence(evidence, primary, secondary); + copyNumericEvidence(evidence, primary, secondary); + copyArrayEvidence(evidence, primary, secondary, "promises"); + if (readBoolean(primary, "already_registered")) evidence.already_registered = true; + return evidence; +} + +export function providerResult(value: unknown): ProviderResult { + const nested = readRecord(value)?.result; + const raw = firstString(value, nested, "status"); + const requestId = firstString(value, nested, "request_id"); + const evidence = readEvidence(value, nested); + // OutLayer's request status names a finished request `completed` as well as `success`; both + // are the same claim, and success still needs the action's own settlement evidence. + const canonical = raw === "completed" ? "success" : raw; + const normalizedStatus = + canonical && (knownStatuses as readonly string[]).includes(canonical) ? canonical : null; + if (normalizedStatus === null && raw !== null) evidence.provider_status = raw; + return { + /** + * A missing provider status is reported as `unknown`. It must not be upgraded to `success` + * just because the response carried a hash, an amount, or an evidence array: those are + * identifiers, not proof of settlement. The action-specific classifier in `outcome.ts` + * decides what the evidence actually establishes. + */ + status: normalizedStatus ?? "unknown", + ...(requestId ? { provider_request_id: requestId } : {}), + ...evidence, + }; +} diff --git a/packages/agents-core/src/modules/operations/relay-service.ts b/packages/agents-core/src/modules/operations/relay-service.ts new file mode 100644 index 000000000..3dc5a58ef --- /dev/null +++ b/packages/agents-core/src/modules/operations/relay-service.ts @@ -0,0 +1,76 @@ +import type { RelayInput } from "@near-intents-agent-api/relayer/schema"; +import { PublicKey } from "near-api-js"; +import { requireRelayer } from "../../lib/relayer.js"; +import { sponsorIdentity } from "../../lib/sponsor-pool.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { logger } from "../../shared/logger.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { commitDispatch } from "./dispatch-fence.js"; +import { recordDispatchEvidence } from "./repository.js"; +import { runOperation } from "./service.js"; +import { commitSponsorship, releaseSponsorship, reserveSponsorship } from "./sponsor-budget.js"; + +/** + * Owner-signed NEP-366 delegate submission. The relayer verifies receiver allowlist, + * owner key and signature; it grants no spending authority. + * + * The sponsor pays gas, so every relay holds a daily sponsor-budget reservation. The slot is + * committed inside the pre-broadcast callback, after the dispatch commitment. The relayer broadcasts only + * once that callback resolves, so a failure before commit proves nothing reached the chain and is + * the only case that refunds the slot. + */ +export async function relayTransaction(actor: Actor, agentId: string, input: RelayInput) { + const { agent } = await requireBoundAgent(actor, agentId); + const submit = requireRelayer(); + if (!agent.ownerPublicKey) throw new ApiError("relay_requires_near_owner"); + if (input.senderId !== agent.ownerAccountId) throw new ApiError("relay_owner_mismatch", 403); + const ownerPublicKeyHex = Buffer.from(PublicKey.from(agent.ownerPublicKey).data).toString("hex"); + return runOperation({ + actor, + agentId, + kind: "relay", + action: "relay", + idempotencyKey: input.idempotencyKey, + request: input, + authorizationEpochs: { + ownerEpoch: agent.ownerEpoch, + policyEpoch: agent.policyEpoch, + lifecycleEpoch: agent.lifecycleEpoch, + }, + run: async (operationId) => { + await reserveSponsorship(actor.tenantId, agentId, operationId); + let committed = false; + let signingIdentity: ReturnType; + try { + const result = await submit(input, ownerPublicKeyHex, async (transactionHash) => { + signingIdentity = sponsorIdentity(); + await commitDispatch(actor.tenantId, agentId, operationId); + // The signed hash is known before broadcast; persisting it first leaves no window in + // which a broadcast transaction has no recorded identity. + await recordDispatchEvidence( + actor.tenantId, + agentId, + operationId, + "relay", + { transactionHash, ...signingIdentity }, + transactionHash, + ); + await commitSponsorship(operationId); + committed = true; + sponsorIdentity(); // Recheck the lease after the awaited dispatch writes. + }); + // A relayer that resolved without running the callback may have broadcast unfenced. + if (!committed) await commitSponsorship(operationId); + return result && typeof result === "object" ? { ...result, ...signingIdentity } : result; + } catch (error) { + if (!committed) + await releaseSponsorship(actor.tenantId, agentId, operationId).catch(() => { + // A failed refund leaves the slot charged, which only over-counts sponsor spend. + logger.warn("sponsor_release_failed", { operation_id: operationId }); + }); + throw error; + } + }, + }); +} diff --git a/packages/agents-core/src/modules/operations/repository.ts b/packages/agents-core/src/modules/operations/repository.ts new file mode 100644 index 000000000..be9d49639 --- /dev/null +++ b/packages/agents-core/src/modules/operations/repository.ts @@ -0,0 +1,922 @@ +import { + evmSignatureSchema, + type SigningArtifactAction, + signatureSchema, +} from "@near-intents-agent-api/contracts"; +import { + auditEvents, + delayedExecutions, + operationArtifacts, + operations, + operationTombstones, + sponsorReservations, + type Tx, +} from "@near-intents-agent-api/database"; +import { and, desc, eq, inArray, isNotNull, isNull, lt, notExists, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import { sealSecret } from "../../shared/secrets.js"; +import { type AuditProvenance, auditProvenance } from "../agents/repository.js"; +import { type DispatchEpochs, lockDispatchAuthority } from "./dispatch-fence.js"; +import { redactOperationResult } from "./result-projection.js"; +import type { OperationKind } from "./schema.js"; +import { requireSigningArtifactAgentActive } from "./signing-artifact-service.js"; + +export const operationArtifactTtlMs = 15 * 60 * 1000; + +export type SigningArtifactPersistence = { + grantId: string; + ownerEpoch: number; + action: SigningArtifactAction; +}; + +export type OperationStatus = "pending" | "completed" | "uncertain" | "failed"; +export type OperationRecord = { + id: string; + tenantId: string; + agentId: string; + actorKeyId: string | null; + kind: OperationKind; + action: string | null; + requestHash: string; + status: OperationStatus; + result: unknown; + authorizedOwnerEpoch: number | null; + authorizedPolicyEpoch: number | null; + authorizedLifecycleEpoch: number | null; + authorizedGrantId: string | null; + authorizedGrantLabel: string | null; + dispatchClaimedAt: Date | null; + dispatchCommittedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +export async function createPendingOperation(input: { + id: string; + tenantId: string; + agentId: string; + actorKeyId?: string | null; + grant?: { id: string; label: string }; + kind: OperationKind; + action?: string; + requestHash: string; + authorizationEpochs?: DispatchEpochs; + delayed?: { + delaySeconds: number; + nearAccountId: string; + action: string; + request: unknown; + grantId: string; + ownerEpoch: number; + }; +}): Promise> { + const { authorizationEpochs, grant, delayed, ...operation } = input; + const authorizedGrantId = grant?.id ?? null; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Admission, tombstone and delayed payload must commit in one transaction. + return getDatabase().transaction(async (tx) => { + const [admitted] = await tx + .insert(operationTombstones) + .values({ + tenantId: input.tenantId, + agentId: input.agentId, + id: input.id, + requestHash: input.requestHash, + }) + .onConflictDoNothing() + .returning({ id: operationTombstones.id }); + if (!admitted) return []; + const inserted = await tx + .insert(operations) + .values({ + ...operation, + action: input.action ?? null, + actorKeyId: input.actorKeyId ?? null, + status: "pending", + authorizedOwnerEpoch: authorizationEpochs?.ownerEpoch ?? null, + authorizedPolicyEpoch: authorizationEpochs?.policyEpoch ?? null, + authorizedLifecycleEpoch: authorizationEpochs?.lifecycleEpoch ?? null, + authorizedGrantId, + authorizedGrantLabel: grant?.label ?? null, + }) + .onConflictDoNothing() + .returning({ id: operations.id }); + if (inserted.length) + await tx.insert(auditEvents).values({ + tenantId: input.tenantId, + agentId: input.agentId, + resourceId: input.id, + action: `${input.kind}.pending`, + actorKeyId: input.actorKeyId ?? null, + grantId: authorizedGrantId, + requestHash: input.requestHash, + ...authorizationEpochs, + }); + if (inserted.length && delayed) { + const sealed = sealSecret(JSON.stringify(delayed.request), { + schemaVersion: 1, + tenantId: input.tenantId, + agentId: input.agentId, + walletId: "", + purpose: "delayed_execution", + operationId: input.id, + grantId: delayed.grantId, + ownerEpoch: delayed.ownerEpoch, + }); + const [job] = await tx + .insert(delayedExecutions) + .values({ + tenantId: input.tenantId, + agentId: input.agentId, + id: input.id, + grantId: delayed.grantId, + ownerEpoch: delayed.ownerEpoch, + ...sealed, + executeAfter: sql`now() + ${delayed.delaySeconds} * interval '1 second'`, + }) + .returning({ executeAfter: delayedExecutions.executeAfter }); + if (!job) throw new Error("timelock_insert_failed"); + await tx + .update(operations) + .set({ + result: { + status: "timelocked", + near_account_id: delayed.nearAccountId, + provider_request_id: null, + action: delayed.action, + execute_after: job.executeAfter.toISOString(), + delay_seconds: delayed.delaySeconds, + }, + }) + .where( + and( + eq(operations.tenantId, input.tenantId), + eq(operations.agentId, input.agentId), + eq(operations.id, input.id), + ), + ); + } + return inserted; + }); +} + +export async function findOperation( + tenantId: string, + agentId: string, + id: string, +): Promise { + return ( + await getDatabase() + .select() + .from(operations) + .where( + and( + eq(operations.tenantId, tenantId), + eq(operations.agentId, agentId), + eq(operations.id, id), + ), + ) + )[0]; +} + +/** Resolves a correlation id within a tenant, without knowing its agent. */ +export async function findTenantOperation( + tenantId: string, + id: string, +): Promise { + return ( + await getDatabase() + .select() + .from(operations) + .where(and(eq(operations.tenantId, tenantId), eq(operations.id, id))) + .limit(1) + )[0]; +} + +/** The policy operation that produced a wallet_policies row, if one was reserved. */ +export async function findPolicyOperation( + tenantId: string, + agentId: string, + policyId: string, +): Promise { + return ( + await getDatabase() + .select() + .from(operations) + .where( + and( + eq(operations.tenantId, tenantId), + eq(operations.agentId, agentId), + eq(operations.kind, "policy"), + sql`${operations.result}->>'policy_id' = ${policyId}`, + ), + ) + .orderBy(desc(operations.updatedAt)) + .limit(1) + )[0]; +} + +export async function reservePendingDeletion(input: { + id: string; + tenantId: string; + agentId: string; + actorKeyId?: string | null; + requestHash: string; + initialResult: unknown; + /** Runs after every refusal check and returns the epochs the deletion commits under. */ + authorize: (tx: Tx) => Promise; +}) { + return getDatabase().transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(1935764847)`); + const [same] = await tx + .select() + .from(operations) + .where( + and( + eq(operations.tenantId, input.tenantId), + eq(operations.agentId, input.agentId), + eq(operations.id, input.id), + ), + ); + if (same) return { kind: "existing" as const, operation: same }; + const [retired] = await tx + .select({ id: operationTombstones.id }) + .from(operationTombstones) + .where( + and( + eq(operationTombstones.tenantId, input.tenantId), + eq(operationTombstones.agentId, input.agentId), + eq(operationTombstones.id, input.id), + ), + ); + if (retired) return { kind: "retired" as const }; + const [pending] = await tx + .select() + .from(operations) + .where( + and( + eq(operations.tenantId, input.tenantId), + eq(operations.agentId, input.agentId), + eq(operations.kind, "execute"), + inArray(operations.status, ["pending", "uncertain"]), + sql`${operations.result}->>'action' = 'delete'`, + ), + ) + .limit(1); + if (pending) return { kind: "pending" as const, operation: pending }; + const epochs = await input.authorize(tx); + await tx.insert(operationTombstones).values({ + tenantId: input.tenantId, + agentId: input.agentId, + id: input.id, + requestHash: input.requestHash, + }); + const [created] = await tx + .insert(operations) + .values({ + id: input.id, + tenantId: input.tenantId, + agentId: input.agentId, + actorKeyId: input.actorKeyId ?? null, + kind: "execute", + action: "delete", + requestHash: input.requestHash, + status: "pending", + authorizedOwnerEpoch: epochs.ownerEpoch, + authorizedPolicyEpoch: epochs.policyEpoch, + authorizedLifecycleEpoch: epochs.lifecycleEpoch, + result: redactOperationResult(input.initialResult, "execute"), + }) + .returning(); + if (!created) throw new Error("deletion_reservation_failed"); + return { kind: "created" as const, operation: created }; + }); +} + +type PreparedOperationArtifact = { + grantId: string; + ownerEpoch: number; + action: string; + sealed: ReturnType; +}; + +function prepareSigningArtifact( + tenantId: string, + agentId: string, + id: string, + kind: OperationKind, + status: OperationStatus, + result: unknown, + provenance: AuditProvenance | undefined, + artifact: SigningArtifactPersistence, +): PreparedOperationArtifact { + if (kind !== "sign" || status !== "completed") throw new Error("signing_artifact_status_invalid"); + if (artifact.grantId !== provenance?.grantId || !Number.isSafeInteger(artifact.ownerEpoch)) + throw new Error("signing_artifact_provenance_mismatch"); + const schema = artifact.action === "near_message" ? signatureSchema : evmSignatureSchema; + const parsed = schema.safeParse(result); + if (!parsed.success) throw new Error("signing_artifact_invalid"); + return { + grantId: artifact.grantId, + ownerEpoch: artifact.ownerEpoch, + action: artifact.action, + sealed: sealSecret(JSON.stringify(parsed.data), { + schemaVersion: 1, + tenantId, + agentId, + walletId: "", + purpose: "operation_signing_artifact", + operationId: id, + grantId: artifact.grantId, + ownerEpoch: artifact.ownerEpoch, + signingAction: artifact.action, + }), + }; +} + +function prepareOperationArtifact(input: { + tenantId: string; + agentId: string; + id: string; + kind: OperationKind; + status: OperationStatus; + result: unknown; + provenance?: AuditProvenance; + signingArtifact?: SigningArtifactPersistence; +}): PreparedOperationArtifact | undefined { + const { signingArtifact } = input; + if (signingArtifact) + return prepareSigningArtifact( + input.tenantId, + input.agentId, + input.id, + input.kind, + input.status, + input.result, + input.provenance, + signingArtifact, + ); + if (input.kind === "sign" && input.status === "completed") + throw new Error("signing_artifact_provenance_required"); + return undefined; +} + +async function insertOperationArtifact( + tx: Tx, + tenantId: string, + agentId: string, + id: string, + artifact: PreparedOperationArtifact, +) { + await requireSigningArtifactAgentActive(tx, tenantId, agentId); + await tx.insert(operationArtifacts).values({ + tenantId, + agentId, + operationId: id, + grantId: artifact.grantId, + ownerEpoch: artifact.ownerEpoch, + action: artifact.action, + ...artifact.sealed, + expiresAt: new Date(Date.now() + operationArtifactTtlMs), + }); +} + +export async function persistOperationResult( + tenantId: string, + agentId: string, + id: string, + kind: OperationKind, + status: OperationStatus, + result: unknown, + provenance?: AuditProvenance, + signingArtifact?: SigningArtifactPersistence, + onPersist?: (tx: Tx) => Promise, +): Promise { + const persistedResult = persistedOperationResult(result, kind, status); + const artifact = prepareOperationArtifact({ + tenantId, + agentId, + id, + kind, + status, + result, + provenance, + signingArtifact, + }); + + return getDatabase().transaction(async (tx) => { + const current = await lockOperation(tx, tenantId, agentId, id); + if (!current) throw new Error("operation_not_found"); + // A terminal outcome is final; a late or stale writer neither downgrades nor finalizes it. + if (current.status === "completed" || current.status === "failed") return current; + const [persisted] = await tx + .update(operations) + .set({ + status, + result: mergeOperationEvidence(current.result, persistedResult), + updatedAt: sql`clock_timestamp()`, + }) + .where(operationIdentity(tenantId, agentId, id)) + .returning(); + if (!persisted) throw new Error("operation_not_found"); + if (artifact) await insertOperationArtifact(tx, tenantId, agentId, id, artifact); + if (onPersist) await onPersist(tx); + await tx.insert(auditEvents).values({ + tenantId, + agentId, + action: `${kind}.${status}`, + resourceId: id, + ...auditProvenance(provenance), + }); + return persisted; + }); +} + +/** + * Journals a committed storage-funding transfer inside the caller's transaction, which already + * holds the operation's dispatch lock and has validated it. The sponsor reservation becomes a + * permanent charge in the same write: a transfer that may broadcast is real spend. + */ +export async function journalPolicyFundingInTransaction( + tx: Tx, + operation: OperationRecord, + result: unknown, +) { + const { tenantId, agentId, id } = operation; + await tx + .update(operations) + .set({ + result: mergeOperationEvidence( + operation.result, + persistedOperationResult(result, "policy", "uncertain"), + ), + updatedAt: sql`clock_timestamp()`, + }) + .where(operationIdentity(tenantId, agentId, id)); + await tx + .update(sponsorReservations) + .set({ state: "committed" }) + .where(and(eq(sponsorReservations.operationId, id), eq(sponsorReservations.state, "reserved"))); + await tx.insert(auditEvents).values({ + tenantId, + agentId, + action: "policy.funding_committed", + resourceId: id, + actorKeyId: operation.actorKeyId, + ownerEpoch: operation.authorizedOwnerEpoch, + policyEpoch: operation.authorizedPolicyEpoch, + lifecycleEpoch: operation.authorizedLifecycleEpoch, + requestHash: operation.requestHash, + }); +} + +/** Row-locks one operation in the caller's transaction. */ +export async function lockOperation(tx: Tx, tenantId: string, agentId: string, id: string) { + const [current] = await tx + .select() + .from(operations) + .where(operationIdentity(tenantId, agentId, id)) + .for("update"); + return current; +} + +/** + * Writes a terminal policy outcome inside the caller's transaction, which already holds the + * operation's row lock and has checked it is not terminal. + */ +export async function finishPolicyOperationInTransaction( + tx: Tx, + current: OperationRecord, + status: "completed" | "failed", + result: unknown, +): Promise { + const [persisted] = await tx + .update(operations) + .set({ + status, + result: mergeOperationEvidence(current.result, redactOperationResult(result, "policy")), + updatedAt: sql`clock_timestamp()`, + }) + .where(operationIdentity(current.tenantId, current.agentId, current.id)) + .returning(); + if (!persisted) throw new Error("operation_not_found"); + await tx.insert(auditEvents).values({ + tenantId: current.tenantId, + agentId: current.agentId, + action: `policy.${status}`, + resourceId: current.id, + }); + return persisted; +} + +function persistedOperationResult(result: unknown, kind: OperationKind, status: OperationStatus) { + if (kind !== "sign") return redactOperationResult(result, kind); + const projected = redactOperationResult(result, kind); + const record = + projected && typeof projected === "object" && !Array.isArray(projected) + ? (projected as Record) + : {}; + return { + redacted: true, + artifact_redacted: true, + status: typeof record.status === "string" ? record.status : status, + ...(typeof record.failure_code === "string" ? { failure_code: record.failure_code } : {}), + ...(typeof record.provider_request_id === "string" || record.provider_request_id === null + ? { provider_request_id: record.provider_request_id } + : {}), + }; +} + +const operationIdentity = (tenantId: string, agentId: string, id: string) => + and(eq(operations.tenantId, tenantId), eq(operations.agentId, agentId), eq(operations.id, id)); + +function mergeOperationEvidence(priorResult: unknown, nextResult: unknown): unknown { + if (!nextResult || typeof nextResult !== "object" || Array.isArray(nextResult)) return nextResult; + const prior = + priorResult && typeof priorResult === "object" && !Array.isArray(priorResult) + ? (priorResult as Record) + : {}; + const incoming = nextResult as Record; + for (const identity of ["action", "provider_request_id"]) { + if ( + prior[identity] != null && + incoming[identity] != null && + prior[identity] !== incoming[identity] + ) + throw new Error("operation_evidence_conflict"); + } + const evidence = { + ...(prior.evidence && typeof prior.evidence === "object" ? prior.evidence : {}), + ...(incoming.evidence && typeof incoming.evidence === "object" ? incoming.evidence : {}), + }; + return { + ...prior, + ...Object.fromEntries(Object.entries(incoming).filter(([, value]) => value != null)), + ...(Object.keys(evidence).length ? { evidence } : {}), + }; +} + +/** + * Claims an admitted operation for dispatch, conditional on the status it was admitted in. From + * here a provider may receive the request, so the operation reads `uncertain` until its result is + * persisted: a crash or failed completion write can no longer leave it looking undispatched. + */ +export async function claimOperationDispatch( + tenantId: string, + agentId: string, + id: string, + admitted: "pending" | "uncertain", + requestHash: string, +): Promise { + return getDatabase().transaction(async (tx) => { + // Revocation updates the same rows, so its commit serializes against the claim. + const authority = await lockDispatchAuthority(tx, tenantId, agentId, id); + if ("refusal" in authority) return false; + if (authority.operation.status !== admitted || authority.operation.requestHash !== requestHash) + return false; + const claimed = await tx + .update(operations) + .set({ + status: "uncertain", + dispatchClaimedAt: sql`coalesce(${operations.dispatchClaimedAt}, now())`, + updatedAt: sql`now()`, + }) + .where(and(operationIdentity(tenantId, agentId, id), eq(operations.status, admitted))) + .returning({ id: operations.id }); + return claimed.length === 1; + }); +} + +/** + * Leases an uncommitted `uncertain` operation for its first provider dispatch. A committed + * operation stays uncertain until observed or reconciled because provider idempotency retention + * is undocumented. A delayed job returns to `dispatching` so the fence admits the first write. + */ +export async function leaseOperationRecovery( + tenantId: string, + agentId: string, + id: string, +): Promise { + return getDatabase().transaction(async (tx) => { + const leased = await tx + .update(operations) + .set({ updatedAt: sql`now()` }) + .where( + and( + operationIdentity(tenantId, agentId, id), + eq(operations.status, "uncertain"), + lt(operations.updatedAt, sql`now() - interval '10 minutes'`), + isNull(operations.dispatchCommittedAt), + ), + ) + .returning({ id: operations.id }); + if (!leased.length) return false; + await tx + .update(delayedExecutions) + .set({ state: "dispatching", updatedAt: sql`now()` }) + .where( + and( + eq(delayedExecutions.tenantId, tenantId), + eq(delayedExecutions.agentId, agentId), + eq(delayedExecutions.id, id), + eq(delayedExecutions.state, "uncertain"), + ), + ); + return true; + }); +} + +/** Returns a recovered delayed job to the terminal state matching its operation. */ +export async function settleRecoveredDelayedJob( + tenantId: string, + agentId: string, + id: string, + status: OperationStatus, +): Promise { + await getDatabase() + .update(delayedExecutions) + .set({ state: status === "uncertain" ? "uncertain" : "finished", updatedAt: sql`now()` }) + .where( + and( + eq(delayedExecutions.tenantId, tenantId), + eq(delayedExecutions.agentId, agentId), + eq(delayedExecutions.id, id), + eq(delayedExecutions.state, "dispatching"), + ), + ); +} + +/** Fails an operation that was never claimed. A claimed or earlier uncertain one is untouched. */ +export async function refuseUnclaimedOperation( + tenantId: string, + agentId: string, + id: string, + kind: OperationKind, + failureCode: string, +): Promise { + await getDatabase().transaction(async (tx) => { + const refused = await tx + .update(operations) + .set({ + status: "failed", + result: persistedOperationResult( + { status: "failed", failure_code: failureCode }, + kind, + "failed", + ), + updatedAt: sql`now()`, + }) + .where(and(operationIdentity(tenantId, agentId, id), eq(operations.status, "pending"))) + .returning({ id: operations.id }); + if (refused.length) + await tx + .insert(auditEvents) + .values({ tenantId, agentId, action: `${kind}.failed`, resourceId: id }); + }); +} + +/** Bounds how many stuck operations one sweep pass fails, like the other maintenance sweeps. */ +const unclaimedOperationSweepBatchSize = 100; +export const dispatchNeverClaimedFailureCode = "dispatch_never_claimed"; + +/** + * Fails a `pending` operation whose process crashed before `claimOperationDispatch` ever ran. The + * claim precedes every provider call on the `runOperation` path, so a row still `pending` with no + * `dispatch_claimed_at` this long after admission provably never reached a provider. + * + * Excluded, and why each stays safe to leave `pending` indefinitely: + * - A delayed/timelocked execution is `pending` by design while its job waits, however long the + * delay is; a matching `delayed_executions` row (kept for the operation's whole lifetime) + * identifies it regardless of age. + * - Policy preparation and onboarding (`owner-policy-preparation.ts`, `owner-policy-submission.ts`, + * `onboarding-service.ts`) are claimed by `runOperation` before it re-persists `pending` with a + * `pending_wallet_signature` result, so `dispatch_claimed_at` is already set for them. + */ +export async function sweepUnclaimedOperations(): Promise { + return getDatabase().transaction(async (tx) => { + const candidates = await tx + .select({ + tenantId: operations.tenantId, + agentId: operations.agentId, + id: operations.id, + kind: operations.kind, + }) + .from(operations) + .where( + and( + eq(operations.status, "pending"), + isNull(operations.dispatchClaimedAt), + lt(operations.createdAt, sql`now() - interval '10 minutes'`), + notExists( + tx + .select({ id: delayedExecutions.id }) + .from(delayedExecutions) + .where( + and( + eq(delayedExecutions.tenantId, operations.tenantId), + eq(delayedExecutions.agentId, operations.agentId), + eq(delayedExecutions.id, operations.id), + ), + ), + ), + ), + ) + .orderBy(operations.createdAt) + .limit(unclaimedOperationSweepBatchSize) + .for("update", { skipLocked: true }); + + let failedCount = 0; + for (const candidate of candidates) { + const failed = await tx + .update(operations) + .set({ + status: "failed", + result: persistedOperationResult( + { status: "failed", failure_code: dispatchNeverClaimedFailureCode }, + candidate.kind, + "failed", + ), + updatedAt: sql`now()`, + }) + .where( + and( + operationIdentity(candidate.tenantId, candidate.agentId, candidate.id), + eq(operations.status, "pending"), + isNull(operations.dispatchClaimedAt), + ), + ) + .returning({ id: operations.id }); + if (!failed.length) continue; + failedCount += 1; + await tx.insert(auditEvents).values({ + tenantId: candidate.tenantId, + agentId: candidate.agentId, + resourceId: candidate.id, + action: `${candidate.kind}.failed`, + }); + } + return failedCount; + }); +} + +export const dispatchNeverCommittedFailureCode = "dispatch_never_committed"; + +/** + * Operation kinds whose every provider write is immediately preceded by `commitDispatch`. Policy + * operations are excluded: their preparation calls the provider before the owner signs, and their + * submission has its own claim. + */ +const committedDispatchKinds: OperationKind[] = ["execute", "sign", "relay"]; + +/** + * Fails an operation that was claimed but never committed. Commitment precedes every provider + * write of these kinds, so such an operation provably never reached a provider; it does not depend + * on provider idempotency. The idle hour leaves room for live preparation. Even when a slow attempt is swept, its later `commitDispatch` locks the + * same row, sees a terminal status and refuses, so the sweep can never race a write. + * + * A delayed job left `dispatching` or `uncertain` for the operation is finished with it. + */ +export async function sweepUncommittedOperations(): Promise { + return getDatabase().transaction(async (tx) => { + const candidates = await tx + .select({ + tenantId: operations.tenantId, + agentId: operations.agentId, + id: operations.id, + kind: operations.kind, + result: operations.result, + }) + .from(operations) + .where( + and( + eq(operations.status, "uncertain"), + inArray(operations.kind, committedDispatchKinds), + isNotNull(operations.dispatchClaimedAt), + isNull(operations.dispatchCommittedAt), + lt(operations.updatedAt, sql`now() - interval '1 hour'`), + ), + ) + .orderBy(operations.updatedAt) + .limit(unclaimedOperationSweepBatchSize) + .for("update", { skipLocked: true }); + + let failedCount = 0; + for (const candidate of candidates) { + const prior = + candidate.result && typeof candidate.result === "object" && !Array.isArray(candidate.result) + ? candidate.result + : {}; + const failed = await tx + .update(operations) + .set({ + status: "failed", + result: persistedOperationResult( + { ...prior, status: "failed", failure_code: dispatchNeverCommittedFailureCode }, + candidate.kind, + "failed", + ), + updatedAt: sql`now()`, + }) + .where( + and( + operationIdentity(candidate.tenantId, candidate.agentId, candidate.id), + eq(operations.status, "uncertain"), + isNull(operations.dispatchCommittedAt), + ), + ) + .returning({ id: operations.id }); + if (!failed.length) continue; + failedCount += 1; + await tx + .update(delayedExecutions) + .set({ + state: "finished", + ciphertext: null, + nonce: null, + keyId: null, + updatedAt: sql`now()`, + }) + .where( + and( + eq(delayedExecutions.tenantId, candidate.tenantId), + eq(delayedExecutions.agentId, candidate.agentId), + eq(delayedExecutions.id, candidate.id), + inArray(delayedExecutions.state, ["dispatching", "uncertain"]), + ), + ); + await tx.insert(auditEvents).values({ + tenantId: candidate.tenantId, + agentId: candidate.agentId, + resourceId: candidate.id, + action: `${candidate.kind}.failed`, + }); + } + return failedCount; + }); +} + +/** + * Keeps what the provider returned before any derived completion work runs, so a failure there + * still leaves the provider reference that refresh and reconciliation need. + */ +export async function recordDispatchEvidence( + tenantId: string, + agentId: string, + id: string, + kind: OperationKind, + result: unknown, + providerReference: string | null, +): Promise { + await getDatabase().transaction(async (tx) => { + const recorded = await tx + .update(operations) + .set({ + result: persistedOperationResult(result, kind, "uncertain"), + // A provider answer proves dispatch, so this row can never read as uncommitted. + dispatchCommittedAt: sql`coalesce(${operations.dispatchCommittedAt}, now())`, + updatedAt: sql`now()`, + }) + .where(and(operationIdentity(tenantId, agentId, id), eq(operations.status, "uncertain"))) + .returning({ id: operations.id }); + if (recorded.length) + await tx.insert(auditEvents).values({ + tenantId, + agentId, + action: `${kind}.dispatched`, + resourceId: id, + providerReference, + }); + }); +} + +export async function updateOperationResult( + tenantId: string, + agentId: string, + id: string, + kind: OperationKind, + status: OperationStatus, + result: unknown, + onPersist?: (tx: Tx) => Promise, +): Promise { + return getDatabase().transaction(async (tx) => { + const [current] = await tx + .select() + .from(operations) + .where(operationIdentity(tenantId, agentId, id)) + .for("update"); + if (!current) return null; + if ( + current.status === "completed" || + current.status === "failed" || + (current.status === "uncertain" && status === "pending") + ) + return current; + const merged = mergeOperationEvidence(current.result, redactOperationResult(result, kind)); + const [persisted] = await tx + .update(operations) + .set({ result: merged, status, updatedAt: sql`clock_timestamp()` }) + .where(operationIdentity(tenantId, agentId, id)) + .returning(); + if (onPersist) await onPersist(tx); + return persisted ?? null; + }); +} diff --git a/packages/agents-core/src/modules/operations/result-projection.ts b/packages/agents-core/src/modules/operations/result-projection.ts new file mode 100644 index 000000000..b542a183c --- /dev/null +++ b/packages/agents-core/src/modules/operations/result-projection.ts @@ -0,0 +1,485 @@ +import { + evmSignatureSchema, + executionRequestSchema, + executionResultSchema, + type OperationKind, + ownerWalletSchema, + policyOperationResultSchema, + signatureSchema, +} from "@near-intents-agent-api/contracts"; +import { z } from "zod"; +import { storageFundingSchema } from "../wallet/storage-funding.js"; + +const maxDepth = 6; +const maxPolicyRequestDepth = 10; +const maxNodes = 10_000; +const maxResultBytes = 512 * 1024; +const traversalByteBudget = maxResultBytes - 256; +const limitMarker = "[redacted: result inspection limit reached]"; +const statusSchema = z.enum([ + "timelocked", + "pending", + "pending_approval", + "pending_deposit", + "pending_wallet_signature", + "processing", + "approved", + "success", + "partially_failed", + "failed", + "refunded", + "rejected", + "expired", + "cancelled", + "needs_review", + "unknown", + "completed", + "uncertain", + "dispatching", + "submitted", + "applied", +]); +const secretNames = new Set([ + "apikey", + "authorization", + "bearer", + "credential", + "mnemonic", + "password", + "privatekey", + "secret", + "seed", + "seedphrase", + "sessiontoken", + "accesstoken", + "refreshtoken", +]); +const evidenceFields = new Set([ + "credential_erased", + "approval_id", + "request_hash", + "tx_hash", + "intent_hash", + "transfer_intent_hash", + "destination_tx_hash", + "receipt_id", + "receipt_hash", + "settlement_status", + "settlement_tx_hash", + "settled_at", + "refund_tx_hash", + "refund_status", + "failure_code", + "failure_reason", + "fee", + "fee_amount", + "fee_token", + "provider_fee", + "provider_fee_amount", + "provider_fee_token", + "solver_fee", + "solver_fee_amount", + "solver_fee_token", + "protocol_fee", + "protocol_fee_amount", + "protocol_fee_token", + "amount_out", + "deposit_address", + "intent_id", + "expires_at", + "estimated_time_secs", + "poll_url", + "token", + "amount", + "created_at", + "memo", + "required", + "approved", + "already_registered", + "promises", +]); +const evidenceRowFields = new Set([ + "receipt_id", + "receipt_hash", + "tx_hash", + "intent_hash", + "transfer_intent_hash", + "destination_tx_hash", + "settlement_tx_hash", + "settlement_status", + "refund_tx_hash", + "refund_status", + "token", + "amount", + "created_at", + "memo", + "status", +]); +const evidenceScalar = z.union([z.string().max(1024), z.number().finite(), z.boolean(), z.null()]); +const assetId = z + .string() + .max(256) + .regex(/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?(?::[A-Za-z0-9_.-]+)?$/) + .refine( + (value) => + value === "native" || + value.includes(".") || + value.includes(":") || + /^[A-Z0-9]{2,12}$/.test(value), + ); +const executeExtras: Record = { + execute_after: z.iso.datetime(), + delay_seconds: z.number().int().nonnegative(), + status: statusSchema, + wallet_id: z.string().max(256), + grant_id: z.string().max(256), + policy_hash: z.string().max(256), + request: executionRequestSchema, + beneficiary: z.string().max(256), + chain: z.string().max(64), + confirm_asset_loss: z.boolean(), + provider_status: statusSchema, +}; +const sponsorFields = { + sponsor_account_id: z.string().min(2).max(64), + sponsor_public_key: z.string().max(128), + /** Access-key nonce of the sponsor transaction; proves a never-seen hash was dropped. */ + sponsor_nonce: z.string().regex(/^[0-9]{1,20}$/), +}; +const policyExtras: Record = { + ...sponsorFields, + status: z.enum([ + "pending", + "pending_wallet_signature", + "dispatching", + "submitted", + "applied", + "failed", + ]), + policy_id: z.string().max(128), + onboarding: z.literal(true), + owner: ownerWalletSchema, + expires_at: z.iso.datetime(), + authorization: z.literal("wallet_signature"), + enforced_by: z.literal("outlayer_contract"), + failure_code: z.string().max(256), + storage_funding: storageFundingSchema, +}; +const relayFields: Record = { + ...sponsorFields, + transactionHash: z.string().max(128), + finalExecutionStatus: z.string().max(256), + status: statusSchema, + provider_request_id: z.string().max(128).nullable(), + failure_code: z.string().max(256), + purpose: z.literal("custody_working_balance"), + funding_kind: z.literal("native_balance_top_up"), + feePayer: z.literal("backend"), + sponsor_id: z.string().max(256), + receiver_id: z.string().max(256), + amount_yocto: z.string().regex(/^[0-9]{1,78}$/), + transaction_hash: z.string().max(128).nullable(), +}; + +type Flags = { + secretRemoved: boolean; + trimmed: boolean; + nodes: number; + bytes: number; + overflow: boolean; +}; +type RecordValue = Record; + +function asRecord(value: unknown): RecordValue | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as RecordValue) + : null; +} + +export function isSecretFieldName(key: string) { + const name = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + return ( + secretNames.has(name) || + name.endsWith("apikey") || + name.endsWith("accesstoken") || + name.endsWith("refreshtoken") || + name.endsWith("sessiontoken") || + name.endsWith("privatekey") || + (name.endsWith("key") && !name.endsWith("publickey")) || + name.endsWith("secret") || + name.endsWith("credential") || + (name !== "token" && name.endsWith("token")) + ); +} + +function parsed(schema: z.ZodType, value: unknown) { + const result = schema.safeParse(value); + return result.success ? result.data : undefined; +} + +function copyEvidenceField( + output: RecordValue, + key: string, + field: unknown, + flags: Flags, + row: boolean, +) { + if (isSecretFieldName(key)) { + flags.secretRemoved = true; + return; + } + if (!(row ? evidenceRowFields : evidenceFields).has(key)) { + flags.trimmed = true; + return; + } + if (!row && key === "promises") { + if (!Array.isArray(field) || field.length > 256) { + flags.trimmed = true; + return; + } + output[key] = field + .map((entry) => projectEvidence(entry, flags, true)) + .filter((entry) => entry !== undefined); + return; + } + const safe = parsed(key === "token" ? assetId : evidenceScalar, field); + if (safe === undefined) { + flags.trimmed = true; + if (key === "token") flags.secretRemoved = true; + } else output[key] = safe; +} + +function projectEvidence(value: unknown, flags: Flags, row = false): RecordValue | undefined { + const source = asRecord(value); + if (!source) { + flags.trimmed = true; + return undefined; + } + const output: RecordValue = {}; + for (const [key, field] of Object.entries(source)) + copyEvidenceField(output, key, field, flags, row); + return output; +} + +function projectField( + key: string, + value: unknown, + flags: Flags, + schemaFields: Record, + extraFields: Record, + extraProjection?: (key: string, value: unknown) => unknown, +): unknown { + if (schemaFields[key]) { + const safe = parsed(schemaFields[key], value); + return safe === undefined && extraFields[key] ? parsed(extraFields[key], value) : safe; + } + if (extraFields[key]) return parsed(extraFields[key], value); + if (isSecretFieldName(key)) { + flags.secretRemoved = true; + return undefined; + } + if (key === "evidence") return projectEvidence(value, flags); + if (evidenceFields.has(key)) { + const evidence = projectEvidence({ [key]: value }, flags); + return evidence && Object.hasOwn(evidence, key) ? evidence[key] : undefined; + } + return extraProjection?.(key, value); +} + +function projectFields( + source: RecordValue, + flags: Flags, + schemaFields: Record, + extraFields: Record, + extraProjection?: (key: string, value: unknown) => unknown, +): RecordValue { + const output: RecordValue = {}; + for (const [key, value] of Object.entries(source)) { + const safe = projectField(key, value, flags, schemaFields, extraFields, extraProjection); + if (safe === undefined) { + flags.trimmed = true; + continue; + } + output[key] = safe; + } + return output; +} + +function projectByKind(kind: OperationKind, value: unknown, flags: Flags): RecordValue { + const source = asRecord(value); + if (!source) { + flags.trimmed = true; + return {}; + } + if (kind === "sign") { + const signature = parsed(signatureSchema, source) ?? parsed(evmSignatureSchema, source); + if (signature !== undefined) return signature as RecordValue; + return projectFields(source, flags, {}, {}, (key, field) => { + if (key === "status") return parsed(statusSchema, field); + if (key === "provider_request_id") return parsed(z.string().max(128).nullable(), field); + if (key === "failure_code") return parsed(z.string().max(256), field); + return undefined; + }); + } + if (kind === "execute") + return projectFields( + source, + flags, + executionResultSchema.shape as Record, + executeExtras, + ); + if (kind === "policy") + return projectFields( + source, + flags, + policyOperationResultSchema.shape as Record, + policyExtras, + ); + return projectFields(source, flags, relayFields, {}); +} + +function reserveBytes(bytes: number, flags: Flags) { + if (flags.bytes + bytes > traversalByteBudget) { + flags.overflow = true; + flags.trimmed = true; + return false; + } + flags.bytes += bytes; + return true; +} + +function stringByteUpperBound(value: string) { + return Buffer.byteLength(value, "utf8") * 6 + 2; +} + +function cutoff(flags: Flags) { + flags.trimmed = true; + reserveBytes(stringByteUpperBound(limitMarker), flags); + return limitMarker; +} + +function cloneArray(value: unknown[], depth: number, flags: Flags, depthLimit: number) { + if (!reserveBytes(2, flags)) return limitMarker; + const output: unknown[] = []; + for (const entry of value) { + if (!reserveBytes(1, flags)) return limitMarker; + if (flags.nodes >= maxNodes) { + output.push(cutoff(flags)); + break; + } + output.push(boundedClone(entry, depth + 1, flags, depthLimit)); + if (flags.overflow) return limitMarker; + } + return output; +} + +function cloneRecord(value: RecordValue, depth: number, flags: Flags, depthLimit: number) { + if (!reserveBytes(2, flags)) return limitMarker; + const output: RecordValue = {}; + for (const [key, entry] of Object.entries(value)) { + const secretToken = key.toLowerCase() === "token" && !assetId.safeParse(entry).success; + if ( + (isSecretFieldName(key) && !(key === "authorization" && entry === "wallet_signature")) || + secretToken + ) { + flags.secretRemoved = true; + continue; + } + if (!reserveBytes(stringByteUpperBound(key) + 2, flags)) return limitMarker; + if (flags.nodes >= maxNodes) { + output[key] = cutoff(flags); + break; + } + output[key] = boundedClone(entry, depth + 1, flags, depthLimit); + if (flags.overflow) return limitMarker; + } + return output; +} + +function boundedClone(value: unknown, depth: number, flags: Flags, depthLimit: number): unknown { + flags.nodes += 1; + if (flags.nodes > maxNodes || depth > depthLimit) { + return cutoff(flags); + } + if (typeof value === "string") { + if ( + Buffer.byteLength(value, "utf8") > maxResultBytes || + !reserveBytes(stringByteUpperBound(value), flags) + ) { + flags.trimmed = true; + flags.overflow = true; + return limitMarker; + } + return value; + } + if (value === null || typeof value !== "object") { + const serialized = JSON.stringify(value); + if (serialized === undefined || !reserveBytes(Buffer.byteLength(serialized, "utf8"), flags)) { + flags.trimmed = true; + return limitMarker; + } + return value; + } + return Array.isArray(value) + ? cloneArray(value, depth, flags, depthLimit) + : cloneRecord(value as RecordValue, depth, flags, depthLimit); +} + +function sourceHasTypedWalletRequest(value: unknown) { + const source = asRecord(value); + return ( + source?.wallet_request !== undefined && + policyOperationResultSchema.shape.wallet_request.safeParse(source.wallet_request).success + ); +} + +/** Positive per-operation projection followed by bounded, fail-closed traversal. */ +export function redactOperationResult(result: T, kind: OperationKind): T { + const flags: Flags = { + secretRemoved: false, + trimmed: false, + nodes: 0, + bytes: 0, + overflow: false, + }; + const projected = projectByKind(kind, result, flags); + const prior = asRecord(result); + flags.secretRemoved ||= prior?.secret_redacted === true; + flags.trimmed ||= prior?.result_redacted === true; + const hasTypedPolicyWalletRequest = kind === "policy" && sourceHasTypedWalletRequest(result); + const cloned = boundedClone( + projected, + 0, + flags, + hasTypedPolicyWalletRequest ? maxPolicyRequestDepth : maxDepth, + ); + if (flags.overflow) + return { + result_redacted: true, + ...(flags.secretRemoved ? { secret_redacted: true } : {}), + } as T; + const output = asRecord(cloned) ?? { result_redacted: true }; + if (flags.secretRemoved) output.secret_redacted = true; + if (flags.trimmed) output.result_redacted = true; + if (Buffer.byteLength(JSON.stringify(output), "utf8") > maxResultBytes) + return { + result_redacted: true, + ...(flags.secretRemoved ? { secret_redacted: true } : {}), + } as T; + return output as T; +} + +/** Generic operation projections never carry signing artifacts. */ +export function projectOperationResult(kind: OperationKind, result: T): T | RecordValue { + const projected = redactOperationResult(result, kind) as RecordValue; + if (kind === "sign") { + return { + redacted: true, + ...(typeof projected.status === "string" ? { status: projected.status } : {}), + ...(typeof projected.provider_request_id === "string" + ? { provider_request_id: projected.provider_request_id } + : {}), + }; + } + return projected; +} diff --git a/packages/agents-core/src/modules/operations/schema.ts b/packages/agents-core/src/modules/operations/schema.ts new file mode 100644 index 000000000..e98e0a5c9 --- /dev/null +++ b/packages/agents-core/src/modules/operations/schema.ts @@ -0,0 +1 @@ +export type { OperationKind, OperationStatus } from "@near-intents-agent-api/contracts"; diff --git a/packages/agents-core/src/modules/operations/service.ts b/packages/agents-core/src/modules/operations/service.ts new file mode 100644 index 000000000..dcc1aa6a8 --- /dev/null +++ b/packages/agents-core/src/modules/operations/service.ts @@ -0,0 +1,308 @@ +import type { + Operation as OperationContract, + SigningArtifactAction, +} from "@near-intents-agent-api/contracts"; +import { canonical } from "@near-intents-agent-api/contracts"; +import type { Tx } from "@near-intents-agent-api/database"; +import { OutlayerError } from "@near-intents-agent-api/outlayer"; +import { RelayError } from "@near-intents-agent-api/relayer"; +import type { Actor } from "../../shared/actor.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { findAgent } from "../agents/repository.js"; +import { assertDispatchFence, currentEpochs, type DispatchEpochs } from "./dispatch-fence.js"; +import { toOperationDto } from "./mapper.js"; +import { + isPreBroadcastRefusalCode, + isProviderPreBroadcastRefusal, + operationStatusFor, +} from "./outcome.js"; +import { + claimOperationDispatch, + createPendingOperation, + findOperation, + type OperationRecord, + persistOperationResult, + recordDispatchEvidence, + refuseUnclaimedOperation, + type SigningArtifactPersistence, +} from "./repository.js"; +import type { OperationKind } from "./schema.js"; + +export type Operation = OperationContract; + +export { operationStatusFor } from "./outcome.js"; + +type RunOperationInput = { + actor: Actor; + agentId: string; + kind: OperationKind; + /** Recorded at admission; see `operations.action`. */ + action?: string; + idempotencyKey: string; + request: unknown; + run: (id: string) => Promise; + delayed?: Parameters[0]["delayed"]; + reserved?: boolean; + /** Existing identity when resuming a reserved operation. */ + reservedOperationId?: string; + /** + * Grant the action was authorized under. It namespaces the idempotency key, so two grants never + * share an operation, and is recorded so status and audit can attribute the delegation. + */ + grant?: { id: string; label: string }; + /** Epochs captured with the delegated grant decision. */ + authorizationEpochs?: DispatchEpochs; + /** Signing-artifact scope; required for a completed sign result. */ + signingAction?: SigningArtifactAction; + /** Keep action-specific terminal state in the same transaction as the operation result. */ + onPersist?: (tx: Tx, status: OperationRecord["status"]) => Promise; +}; + +/** + * Delegated work is namespaced by its grant, so the same key under two grants names two + * operations; owner-signed and service work have no grant. + */ +export function operationIdFor( + tenantId: string, + agentId: string, + kind: OperationKind, + idempotencyKey: string, + grantId?: string, +) { + return hashSecret( + canonical({ + tenant: tenantId, + agent: agentId, + kind, + idempotencyKey, + ...(grantId ? { grant: grantId } : {}), + }), + ); +} + +function operationIdForRun(input: RunOperationInput): string { + if (input.reserved) { + if (input.reservedOperationId) return input.reservedOperationId; + throw new ApiError("operation_not_found", 404); + } + return operationIdFor( + input.actor.tenantId, + input.agentId, + input.kind, + input.idempotencyKey, + input.grant?.id, + ); +} + +/** An idempotent replay returns its existing operation; otherwise the status it was admitted in. */ +type PreparedOperation = { existing: Operation } | { admitted: "pending" | "uncertain" }; + +/** A replay or resume must match the admitted operation's kind, body and grant exactly. */ +function assertSameRequest(prior: OperationRecord, input: RunOperationInput, requestHash: string) { + if ( + prior.kind !== input.kind || + prior.requestHash !== requestHash || + (prior.authorizedGrantId ?? null) !== (input.grant?.id ?? null) + ) + throw new ApiError("idempotency_conflict", 409); +} + +async function prepareOperation( + input: RunOperationInput, + id: string, + requestHash: string, +): Promise { + const { actor, agentId, kind } = input; + if (input.reserved) { + if (input.reservedOperationId !== id) throw new ApiError("operation_not_found", 404); + const prior = await findOperation(actor.tenantId, agentId, id); + if (!prior) throw new ApiError("operation_not_found", 404); + assertSameRequest(prior, input, requestHash); + if (prior.status !== "pending" && prior.status !== "uncertain") + throw new ApiError("operation_not_pending", 409); + return { admitted: prior.status }; + } + + const authorizationEpochs = + input.authorizationEpochs ?? (await currentEpochs(actor.tenantId, agentId)); + const inserted = await createPendingOperation({ + id, + tenantId: actor.tenantId, + agentId, + actorKeyId: actor.keyId, + kind, + action: input.action, + requestHash, + authorizationEpochs, + grant: input.grant, + delayed: input.delayed, + }); + if (inserted.length) return { admitted: "pending" }; + + const prior = await findOperation(actor.tenantId, agentId, id); + if (!prior) throw new ApiError("operation_retired", 409); + assertSameRequest(prior, input, requestHash); + return { existing: await toOperationDto(actor, prior) }; +} + +function signingArtifactForResult( + input: RunOperationInput, + status: OperationRecord["status"], +): SigningArtifactPersistence | undefined { + if (input.kind !== "sign" || status !== "completed") return; + if (!input.grant || !input.authorizationEpochs || !input.signingAction) + throw new Error("signing_artifact_provenance_required"); + return { + grantId: input.grant.id, + ownerEpoch: input.authorizationEpochs.ownerEpoch, + action: input.signingAction, + }; +} + +async function persistRunResult( + input: RunOperationInput, + id: string, + requestHash: string, + result: unknown, +) { + const status = operationStatusFor(input.kind, result); + const completionEpochs = await currentEpochs(input.actor.tenantId, input.agentId); + const signingArtifact = signingArtifactForResult(input, status); + const auditEpochs = + input.kind === "sign" && input.authorizationEpochs + ? input.authorizationEpochs + : completionEpochs; + const providerRequestId = providerReference(result); + const onPersist = input.onPersist; + const persisted = await persistOperationResult( + input.actor.tenantId, + input.agentId, + id, + input.kind, + status, + result, + { + actorKeyId: input.actor.keyId, + requestHash, + ...(input.grant ? { grantId: input.grant.id } : {}), + ...(providerRequestId ? { providerReference: providerRequestId } : {}), + ...auditEpochs, + }, + signingArtifact, + onPersist ? (tx) => onPersist(tx, status) : undefined, + ); + return toOperationDto(input.actor, persisted); +} + +export async function runOperation(input: RunOperationInput) { + const { actor, agentId, kind, request, run } = input; + const id = operationIdForRun(input); + const requestHash = hashSecret(canonical(request)); + const prepared = await prepareOperation(input, id, requestHash); + if ("existing" in prepared) return prepared.existing; + if (input.delayed) { + const queued = await findOperation(actor.tenantId, agentId, id); + if (!queued) throw new ApiError("operation_not_found", 404); + return toOperationDto(actor, queued); + } + + try { + // New operations persist their admission snapshot with insertion. Reserved resumes must use + // that same snapshot and can never inherit epochs from the current agent row. + await assertDispatchFence(actor.tenantId, agentId, id); + if ( + !(await claimOperationDispatch(actor.tenantId, agentId, id, prepared.admitted, requestHash)) + ) + throw new ApiError("operation_not_pending", 409); + } catch (error) { + // Nothing was handed to `run`, so no provider saw this attempt. + await refuseUnclaimedOperation(actor.tenantId, agentId, id, kind, failureCode(error)); + throw error; + } + + try { + const result = await run(id); + await recordDispatchEvidence( + actor.tenantId, + agentId, + id, + kind, + result, + providerReference(result), + ); + return await persistRunResult(input, id, requestHash, result); + } catch (error) { + // The claim already recorded uncertainty. Only a typed refusal proves this attempt never + // reached a provider, and a resumed operation's earlier attempt may still have landed. + if (prepared.admitted === "pending" && isPreBroadcastRefusalError(error)) + await persistOperationResult(actor.tenantId, agentId, id, kind, "failed", { + status: "failed", + failure_code: error.code, + }); + throw error; + } +} + +function failureCode(error: unknown): string { + return error instanceof ApiError || error instanceof RelayError || error instanceof OutlayerError + ? error.code + : "dispatch_not_started"; +} + +/** + * API errors that are provably decided before any provider write. Recording them as failed (rather + * than uncertain) is what lets a caller fix the input and retry, and what keeps a refused action + * from being counted as possibly-spent budget. + * + * The shared core plus local admission and dispatch-validation failures that prove no provider + * write started: grant admission, policy revisioning (including an unresolved earlier revision and + * the provider state a revision is prepared from), owner proof and nonce checks, wallet + * authorization, detached signing and sponsor-budget reservation, which precedes any sponsor or + * custody submission. + */ +const admissionRefusalCodes = new Set([ + "agent_grant_required", + "policy_revision_conflict", + "policy_reconciliation_required", + "policy_update_invalid", + "provider_policy_unavailable", + "owner_nonce_invalid", + "owner_counter_conflict", + "wallet_authorization_required", + "wallet_authorization_invalid", + "wallet_authorization_expired", + "signing_policy_denied", + "signing_identity_challenge_invalid", + "sponsor_daily_budget_exhausted", + "sponsor_busy", + "spend_budget_exceeded", + "spend_price_unavailable", +]); + +function isPreBroadcastRefusalError( + error: unknown, +): error is ApiError | RelayError | OutlayerError { + if (error instanceof RelayError) return true; + if (error instanceof OutlayerError) return isProviderPreBroadcastRefusal(error); + if (!(error instanceof ApiError)) return false; + return isPreBroadcastRefusalCode(error.code) || admissionRefusalCodes.has(error.code); +} + +/** Provider request identity from a result, for the audit trail. Never includes secrets. */ +function providerReference(result: unknown) { + if (!result || typeof result !== "object") return null; + const value = (result as { provider_request_id?: unknown }).provider_request_id; + return typeof value === "string" ? value : null; +} + +export async function readOperation( + actor: Actor, + agentId: string, + id: string, +): Promise { + if (!(await findAgent(actor.tenantId, agentId))) throw new ApiError("agent_not_found", 404); + const result = await findOperation(actor.tenantId, agentId, id); + if (!result) throw new ApiError("operation_not_found", 404); + return result; +} diff --git a/packages/agents-core/src/modules/operations/signing-artifact-owner-service.ts b/packages/agents-core/src/modules/operations/signing-artifact-owner-service.ts new file mode 100644 index 000000000..f059b0771 --- /dev/null +++ b/packages/agents-core/src/modules/operations/signing-artifact-owner-service.ts @@ -0,0 +1,320 @@ +import { + canonical, + type SigningArtifactDelivery, + type SigningArtifactOwnerAccess, + type SigningArtifactOwnerAction, + type SigningArtifactOwnerChallenge, + signingArtifactOwnerMessageSchema, +} from "@near-intents-agent-api/contracts"; +import { + agentGrants, + agents, + auditEvents, + operationArtifacts, + operations, + type Tx, +} from "@near-intents-agent-api/database"; +import { createOwnerSigningRequest } from "@near-intents-agent-api/relayer"; +import { and, eq, gt, isNull, sql } from "drizzle-orm"; +import { getRuntime } from "../../config/runtime.js"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import type { CommitEffect } from "../../shared/commit-effect.js"; +import { ApiError } from "../../shared/errors.js"; +import { consumeOwnerNonceInTransaction, issueOwnerNonce } from "../../shared/nonces.js"; +import { + ownerEnvelopeMismatch, + ownerKeyMismatch, + ownerMessageMaxLifetimeMs, + ownerMessageWindowInvalid, +} from "../../shared/owner-message.js"; +import { grantRestatesOwnerMessage } from "../agents/grant-integrity.js"; +import { ownerPrincipalId } from "../agents/owner-admin-command.js"; +import { verifyBoundOwner } from "../agents/owner-authorization.js"; +import { ownerMessageRecipient } from "../agents/service.js"; +import { + openSigningArtifact, + requireSigningArtifactAgentActive, +} from "./signing-artifact-service.js"; + +const signingActions = new Set([ + "near_message", + "evm_message", + "evm_typed_data", + "evm_transaction", +]); + +type OwnerArtifactContext = { + agent: typeof agents.$inferSelect; + artifact: typeof operationArtifacts.$inferSelect; + grant: { + ownerEpoch: number; + actions: string[]; + walletId: string; + }; + operation: { kind: string; status: string; requestHash: string }; +}; + +function notFound(): never { + throw new ApiError("signing_artifact_not_found", 404); +} + +async function ownerArtifactContext( + tx: Tx, + actor: Actor, + agentId: string, + operationId: string, +): Promise { + const [agent] = await tx + .select() + .from(agents) + .where(and(eq(agents.tenantId, actor.tenantId), eq(agents.id, agentId))) + .for("share"); + if (!agent) notFound(); + await requireSigningArtifactAgentActive(tx, actor.tenantId, agentId, agent.lifecycle); + + const [artifact] = await tx + .select() + .from(operationArtifacts) + .where( + and( + eq(operationArtifacts.tenantId, actor.tenantId), + eq(operationArtifacts.agentId, agentId), + eq(operationArtifacts.operationId, operationId), + ), + ) + .for("update"); + if (!artifact || !signingActions.has(artifact.action)) notFound(); + if (artifact.ownerEpoch !== agent.ownerEpoch) notFound(); + + const [grant] = await tx + .select() + .from(agentGrants) + .where( + and( + eq(agentGrants.tenantId, actor.tenantId), + eq(agentGrants.agentId, agentId), + eq(agentGrants.id, artifact.grantId), + ), + ) + .for("share"); + const grantOwnerEpoch = grant?.ownerEpoch; + if (!grant || typeof grantOwnerEpoch !== "number") notFound(); + if ( + grant.ownerEpoch !== artifact.ownerEpoch || + !grant.actions.includes(`sign:${artifact.action}`) || + !grantRestatesOwnerMessage(grant) + ) + notFound(); + + const [operation] = await tx + .select({ + kind: operations.kind, + status: operations.status, + requestHash: operations.requestHash, + }) + .from(operations) + .where( + and( + eq(operations.tenantId, actor.tenantId), + eq(operations.agentId, agentId), + eq(operations.id, operationId), + ), + ); + if (operation?.kind !== "sign" || operation.status !== "completed") notFound(); + + const [completedEvent] = await tx + .select({ requestHash: auditEvents.requestHash }) + .from(auditEvents) + .where( + and( + eq(auditEvents.tenantId, actor.tenantId), + eq(auditEvents.agentId, agentId), + eq(auditEvents.action, "sign.completed"), + eq(auditEvents.resourceId, operationId), + eq(auditEvents.grantId, artifact.grantId), + eq(auditEvents.ownerEpoch, artifact.ownerEpoch), + ), + ); + if (!completedEvent || completedEvent.requestHash !== operation.requestHash) notFound(); + + return { + agent, + artifact, + grant: { ...grant, ownerEpoch: grantOwnerEpoch }, + operation, + }; +} + +function assertOwnerArtifactMessage( + actor: Actor, + agentId: string, + operationId: string, + action: SigningArtifactOwnerAction, + context: OwnerArtifactContext, + message: SigningArtifactOwnerAccess["message"], +) { + const { agent, artifact, operation } = context; + if ( + !agent.ownerIdentity || + !agent.ownerAccountId || + !agent.ownerPublicKey || + ownerEnvelopeMismatch(message, actor.tenantId, agentId) || + ownerKeyMismatch(message, agent) || + canonical(message.owner) !== canonical(agent.ownerIdentity) || + message.recipient !== ownerMessageRecipient() || + message.operation_id !== operationId || + message.request_hash !== operation.requestHash || + message.requesting_key_id !== actor.keyId || + message.grant_id !== artifact.grantId || + message.owner_epoch !== artifact.ownerEpoch || + message.owner_epoch !== agent.ownerEpoch || + message.signing_action !== artifact.action || + message.action !== action || + ownerMessageWindowInvalid(message, Date.now()) + ) + throw new ApiError("signing_artifact_owner_mismatch", 409); + if (artifact.expiresAt <= new Date() && !(action === "ack" && artifact.consumedAt)) + throw new ApiError("signing_artifact_expired", 410); +} + +export async function signingArtifactOwnerChallenge( + actor: Actor, + agentId: string, + operationId: string, + action: SigningArtifactOwnerAction, +): Promise { + const context = await getDatabase().transaction((tx) => + ownerArtifactContext(tx, actor, agentId, operationId), + ); + const { agent, artifact, operation } = context; + if (action === "read" && artifact.consumedAt) + throw new ApiError("signing_artifact_consumed", 410); + if (artifact.expiresAt <= new Date() && !(action === "ack" && artifact.consumedAt)) + throw new ApiError("signing_artifact_expired", 410); + if (!agent.ownerIdentity || !agent.ownerAccountId || !agent.ownerPublicKey) + throw new ApiError("agent_not_bound", 409); + const issuedAtMs = Date.now(); + const recipient = ownerMessageRecipient(); + const message = signingArtifactOwnerMessageSchema.parse({ + domain: "near-intents-agent-api.signing-artifact-owner.v2", + owner: agent.ownerIdentity, + tenant_id: actor.tenantId, + agent_id: agentId, + network: getRuntime().network, + account_id: agent.ownerAccountId, + public_key: agent.ownerPublicKey, + recipient, + nonce: await issueOwnerNonce(actor.tenantId, agentId), + issued_at_ms: issuedAtMs, + expires_at_ms: issuedAtMs + ownerMessageMaxLifetimeMs, + operation_id: operationId, + request_hash: operation.requestHash, + requesting_key_id: actor.keyId, + grant_id: artifact.grantId, + owner_epoch: artifact.ownerEpoch, + signing_action: artifact.action, + action, + }); + return { + message, + signing: createOwnerSigningRequest(message), + nep413: { message: canonical(message), nonce: message.nonce, recipient: message.recipient }, + }; +} + +async function verifyOwnerAccess( + actor: Actor, + agentId: string, + operationId: string, + action: SigningArtifactOwnerAction, + input: SigningArtifactOwnerAccess, +) { + const context = await getDatabase().transaction((tx) => + ownerArtifactContext(tx, actor, agentId, operationId), + ); + assertOwnerArtifactMessage(actor, agentId, operationId, action, context, input.message); + await verifyBoundOwner(context.agent, input.message, input.proof); +} + +export async function readSigningArtifactAsOwner( + actor: Actor, + agentId: string, + operationId: string, + input: SigningArtifactOwnerAccess, + onCommit?: CommitEffect, +): Promise { + await verifyOwnerAccess(actor, agentId, operationId, "read", input); + return getDatabase().transaction(async (tx) => { + const context = await ownerArtifactContext(tx, actor, agentId, operationId); + assertOwnerArtifactMessage(actor, agentId, operationId, "read", context, input.message); + if (context.artifact.consumedAt) throw new ApiError("signing_artifact_consumed", 410); + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, input.message.nonce); + await tx.insert(auditEvents).values({ + tenantId: actor.tenantId, + agentId, + action: "signing_artifact.owner_read", + resourceId: operationId, + actorKeyId: actor.keyId, + actorOwnerId: ownerPrincipalId(input.message.owner), + grantId: context.artifact.grantId, + ownerEpoch: context.artifact.ownerEpoch, + requestHash: context.operation.requestHash, + }); + const result = { + operation_id: operationId, + artifact: openSigningArtifact(actor, agentId, context.artifact), + expires_at: context.artifact.expiresAt.toISOString(), + }; + await onCommit?.(tx, result); + return result; + }); +} + +export async function acknowledgeSigningArtifactAsOwner( + actor: Actor, + agentId: string, + operationId: string, + input: SigningArtifactOwnerAccess, + onCommit?: CommitEffect<{ acknowledged: true }>, +) { + await verifyOwnerAccess(actor, agentId, operationId, "ack", input); + return getDatabase().transaction(async (tx) => { + const context = await ownerArtifactContext(tx, actor, agentId, operationId); + assertOwnerArtifactMessage(actor, agentId, operationId, "ack", context, input.message); + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, input.message.nonce); + if (!context.artifact.consumedAt) { + const cleared = await tx + .update(operationArtifacts) + .set({ ciphertext: null, keyId: null, nonce: null, consumedAt: new Date() }) + .where( + and( + eq(operationArtifacts.tenantId, actor.tenantId), + eq(operationArtifacts.agentId, agentId), + eq(operationArtifacts.operationId, operationId), + eq(operationArtifacts.grantId, context.artifact.grantId), + eq(operationArtifacts.ownerEpoch, context.artifact.ownerEpoch), + eq(operationArtifacts.action, context.artifact.action), + isNull(operationArtifacts.consumedAt), + gt(operationArtifacts.expiresAt, sql`now()`), + ), + ) + .returning({ operationId: operationArtifacts.operationId }); + if (!cleared.length) throw new ApiError("signing_artifact_expired", 410); + } + await tx.insert(auditEvents).values({ + tenantId: actor.tenantId, + agentId, + action: "signing_artifact.owner_ack", + resourceId: operationId, + actorKeyId: actor.keyId, + actorOwnerId: ownerPrincipalId(input.message.owner), + grantId: context.artifact.grantId, + ownerEpoch: context.artifact.ownerEpoch, + requestHash: context.operation.requestHash, + }); + const result = { acknowledged: true as const }; + await onCommit?.(tx, result); + return result; + }); +} diff --git a/packages/agents-core/src/modules/operations/signing-artifact-service.ts b/packages/agents-core/src/modules/operations/signing-artifact-service.ts new file mode 100644 index 000000000..424132b17 --- /dev/null +++ b/packages/agents-core/src/modules/operations/signing-artifact-service.ts @@ -0,0 +1,406 @@ +import { + evmSignatureSchema, + grantTokenPattern, + type SigningArtifactDelivery, + signatureSchema, +} from "@near-intents-agent-api/contracts"; +import { + agentGrants, + agents, + apiKeys, + auditEvents, + operationArtifacts, + operations, + type Tx, + walletPolicies, +} from "@near-intents-agent-api/database"; +import { and, asc, desc, eq, gt, isNull, lte, or, sql } from "drizzle-orm"; +import { databaseClock, getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { openSecret } from "../../shared/secrets.js"; +import { grantRestatesOwnerMessage } from "../agents/grant-integrity.js"; +import { grantCredentialHash } from "../agents/grant-service.js"; + +const signingArtifactSchema = signatureSchema.or(evmSignatureSchema); +const signingActions = new Set([ + "near_message", + "evm_message", + "evm_typed_data", + "evm_transaction", +]); +type ArtifactFamily = "signing"; +type ArtifactRow = typeof operationArtifacts.$inferSelect; + +function familyOf(action: string): ArtifactFamily | undefined { + if (signingActions.has(action)) return "signing"; + return undefined; +} + +function artifactError(_family: ArtifactFamily, suffix: string) { + return new ApiError( + `signing_artifact_${suffix}`, + suffix === "not_found" ? 404 : suffix === "unavailable" ? 503 : 410, + ); +} + +async function authorizedArtifact( + tx: Tx, + actor: Actor, + agentId: string, + operationId: string, + family: ArtifactFamily, + grantToken: string | undefined, +): Promise<{ artifact: ArtifactRow; consumed: boolean; decidedAt: Date }> { + const [agent] = await tx + .select({ ownerEpoch: agents.ownerEpoch, lifecycle: agents.lifecycle }) + .from(agents) + .where(and(eq(agents.tenantId, actor.tenantId), eq(agents.id, agentId))) + .for("share"); + if (!agent) throw new ApiError("agent_not_found", 404); + // Delivery is its own revocation-ordered decision: key revocation waits for this share lock, and + // a revocation that committed first excludes the key even though the request authenticated. + const [key] = await tx + .select({ expiresAt: apiKeys.expiresAt }) + .from(apiKeys) + .where( + and( + eq(apiKeys.tenantId, actor.tenantId), + eq(apiKeys.id, actor.keyId), + isNull(apiKeys.revokedAt), + ), + ) + .for("share"); + if (!key) throw artifactError(family, "not_found"); + await requirePrivilegedArtifactDeliveryAllowed( + tx, + actor.tenantId, + agentId, + family, + agent.lifecycle, + ); + const [artifact] = await tx + .select() + .from(operationArtifacts) + .where( + and( + eq(operationArtifacts.tenantId, actor.tenantId), + eq(operationArtifacts.agentId, agentId), + eq(operationArtifacts.operationId, operationId), + ), + ) + .for("update"); + if (!artifact || familyOf(artifact.action) !== family) throw artifactError(family, "not_found"); + const grant = await requireLiveArtifactGrant( + tx, + actor, + agentId, + artifact, + agent.ownerEpoch, + family, + grantToken, + ); + await requireCompletedArtifactOperation(tx, actor, agentId, artifact, family); + await requireArtifactAudit(tx, actor, agentId, artifact, family); + const decidedAt = await databaseClock(tx); + if (key.expiresAt <= decidedAt || grant.expiresAt <= decidedAt) + throw artifactError(family, "not_found"); + return { artifact, consumed: artifact.consumedAt !== null, decidedAt }; +} + +/** + * A privileged artifact is unavailable once archive or a confirmed/pending freeze is authoritative. + * Pending signed freeze rows are deliberately fail-closed: provider freeze can succeed before + * policy readback commits, and a failed DB confirmation must not make stored artifacts readable. + */ +export async function requirePrivilegedArtifactDeliveryAllowed( + tx: Tx, + tenantId: string, + agentId: string, + family: ArtifactFamily, + lifecycle?: string, +) { + let currentLifecycle = lifecycle; + if (currentLifecycle === undefined) { + const [agent] = await tx + .select({ lifecycle: agents.lifecycle }) + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .for("share"); + currentLifecycle = agent?.lifecycle; + } + if (currentLifecycle !== "active") throw artifactError(family, "not_found"); + + const [applied] = await tx + .select({ version: walletPolicies.version, rules: walletPolicies.rules }) + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.status, "applied"), + ), + ) + .orderBy(desc(walletPolicies.version)) + .limit(1); + const appliedFrozen = + applied?.rules !== null && + typeof applied?.rules === "object" && + (applied.rules as { frozen?: unknown }).frozen === true; + if (appliedFrozen) throw artifactError(family, "not_found"); + + const [pendingFreeze] = await tx + .select({ version: walletPolicies.version }) + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.status, "signed"), + applied ? gt(walletPolicies.version, applied.version) : undefined, + sql`${walletPolicies.rules}->>'frozen' = 'true'`, + ), + ) + .limit(1); + if (pendingFreeze) throw artifactError(family, "not_found"); +} + +export function requireSigningArtifactAgentActive( + tx: Tx, + tenantId: string, + agentId: string, + lifecycle?: string, +) { + return requirePrivilegedArtifactDeliveryAllowed(tx, tenantId, agentId, "signing", lifecycle); +} + +async function requireLiveArtifactGrant( + tx: Tx, + actor: Actor, + agentId: string, + artifact: ArtifactRow, + currentOwnerEpoch: number, + family: ArtifactFamily, + grantToken: string | undefined, +) { + // Only the token of the grant that authorized the signature can collect it. + if (!grantToken || !grantTokenPattern.test(grantToken)) throw artifactError(family, "not_found"); + const [grant] = await tx + .select() + .from(agentGrants) + .where( + and( + eq(agentGrants.tenantId, actor.tenantId), + eq(agentGrants.agentId, agentId), + eq(agentGrants.id, artifact.grantId), + ), + ) + .for("share"); + const grantAction = `sign:${artifact.action}`; + if ( + !grant || + grant.credentialHash !== grantCredentialHash(grantToken) || + grant.ownerEpoch !== artifact.ownerEpoch || + grant.ownerEpoch !== currentOwnerEpoch || + grant.revokedAt !== null || + !grant.actions.includes(grantAction) || + !grantRestatesOwnerMessage(grant) + ) + throw artifactError(family, "not_found"); + return grant; +} + +async function requireCompletedArtifactOperation( + tx: Tx, + actor: Actor, + agentId: string, + artifact: ArtifactRow, + family: ArtifactFamily, +) { + const [operation] = await tx + .select({ kind: operations.kind, status: operations.status }) + .from(operations) + .where( + and( + eq(operations.tenantId, actor.tenantId), + eq(operations.agentId, agentId), + eq(operations.id, artifact.operationId), + ), + ); + if (operation?.kind !== "sign" || operation?.status !== "completed") + throw artifactError(family, "not_found"); +} + +async function requireArtifactAudit( + tx: Tx, + actor: Actor, + agentId: string, + artifact: ArtifactRow, + _family: ArtifactFamily, +) { + return requireSigningArtifactAudit(tx, actor, agentId, artifact); +} + +async function requireSigningArtifactAudit( + tx: Tx, + actor: Actor, + agentId: string, + artifact: ArtifactRow, +) { + const [event] = await tx + .select({ operationId: auditEvents.resourceId }) + .from(auditEvents) + .where( + and( + eq(auditEvents.tenantId, actor.tenantId), + eq(auditEvents.agentId, agentId), + eq(auditEvents.action, "sign.completed"), + eq(auditEvents.resourceId, artifact.operationId), + eq(auditEvents.grantId, artifact.grantId), + eq(auditEvents.ownerEpoch, artifact.ownerEpoch), + ), + ); + if (!event) throw artifactError("signing", "not_found"); +} + +function openArtifact( + actor: Actor, + agentId: string, + artifact: ArtifactRow, + family: ArtifactFamily, + schema: { safeParse(value: unknown): { success: true; data: T } | { success: false } }, +): T { + if (!artifact.ciphertext || !artifact.keyId || !artifact.nonce) + throw artifactError(family, "consumed"); + try { + const plaintext = openSecret( + { ciphertext: artifact.ciphertext, keyId: artifact.keyId, nonce: artifact.nonce }, + { + schemaVersion: 1, + tenantId: actor.tenantId, + agentId, + walletId: "", + purpose: "operation_signing_artifact", + operationId: artifact.operationId, + grantId: artifact.grantId, + ownerEpoch: artifact.ownerEpoch, + signingAction: artifact.action, + }, + ); + const parsed = schema.safeParse(JSON.parse(plaintext)); + if (!parsed.success) throw new Error("invalid operation artifact payload"); + return parsed.data; + } catch { + throw artifactError(family, "unavailable"); + } +} + +export function openSigningArtifact(actor: Actor, agentId: string, artifact: ArtifactRow) { + return openArtifact(actor, agentId, artifact, "signing", signingArtifactSchema); +} + +export async function readSigningArtifact( + actor: Actor, + agentId: string, + operationId: string, + grantToken: string | undefined, +): Promise { + const family = "signing"; + return getDatabase().transaction(async (tx) => { + const { artifact, consumed, decidedAt } = await authorizedArtifact( + tx, + actor, + agentId, + operationId, + family, + grantToken, + ); + if (consumed) throw artifactError(family, "consumed"); + if (artifact.expiresAt <= decidedAt) throw artifactError(family, "expired"); + return { + operation_id: operationId, + artifact: openArtifact(actor, agentId, artifact, family, signingArtifactSchema), + expires_at: artifact.expiresAt.toISOString(), + }; + }); +} + +async function acknowledgeArtifact( + actor: Actor, + agentId: string, + operationId: string, + family: ArtifactFamily, + grantToken: string | undefined, +) { + return getDatabase().transaction(async (tx) => { + const { artifact, consumed, decidedAt } = await authorizedArtifact( + tx, + actor, + agentId, + operationId, + family, + grantToken, + ); + if (consumed) return { acknowledged: true as const }; + if (artifact.expiresAt <= decidedAt) throw artifactError(family, "expired"); + const cleared = await tx + .update(operationArtifacts) + .set({ ciphertext: null, keyId: null, nonce: null, consumedAt: new Date() }) + .where( + and( + eq(operationArtifacts.tenantId, actor.tenantId), + eq(operationArtifacts.agentId, agentId), + eq(operationArtifacts.operationId, operationId), + eq(operationArtifacts.grantId, artifact.grantId), + isNull(operationArtifacts.consumedAt), + gt(operationArtifacts.expiresAt, sql`now()`), + ), + ) + .returning({ operationId: operationArtifacts.operationId }); + if (!cleared.length) throw artifactError(family, "expired"); + return { acknowledged: true as const }; + }); +} + +export function acknowledgeSigningArtifact( + actor: Actor, + agentId: string, + operationId: string, + grantToken: string | undefined, +) { + return acknowledgeArtifact(actor, agentId, operationId, "signing", grantToken); +} + +const artifactCleanupBatchSize = 500; + +export async function sweepExpiredOperationArtifacts() { + const rows = await getDatabase().transaction(async (tx) => { + const expired = await tx + .select({ + tenantId: operationArtifacts.tenantId, + agentId: operationArtifacts.agentId, + operationId: operationArtifacts.operationId, + }) + .from(operationArtifacts) + .where(lte(operationArtifacts.expiresAt, sql`now()`)) + .orderBy(asc(operationArtifacts.expiresAt)) + .limit(artifactCleanupBatchSize) + .for("update", { skipLocked: true }); + if (!expired.length) return []; + const predicate = or( + ...expired.map(({ tenantId, agentId, operationId }) => + and( + eq(operationArtifacts.tenantId, tenantId), + eq(operationArtifacts.agentId, agentId), + eq(operationArtifacts.operationId, operationId), + ), + ), + ); + if (!predicate) return []; + return tx + .delete(operationArtifacts) + .where(predicate) + .returning({ operationId: operationArtifacts.operationId }); + }); + return { removed: rows.length }; +} diff --git a/packages/agents-core/src/modules/operations/signing-service.ts b/packages/agents-core/src/modules/operations/signing-service.ts new file mode 100644 index 000000000..719f4cb03 --- /dev/null +++ b/packages/agents-core/src/modules/operations/signing-service.ts @@ -0,0 +1,232 @@ +import { + canonical, + type EvmSignMessage, + type EvmSignTransaction, + type EvmSignTypedData, + evmSignatureSchema, + policySchema, + type Signature, + type SignMessage, +} from "@near-intents-agent-api/contracts"; +import { getOutlayer } from "../../lib/outlayer.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { type GrantCheck, grantCheckForIdentitySigning } from "../agents/grant-constraints.js"; +import { authorizeDelegatedAction } from "../agents/grant-service.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { requireReadyPolicy } from "../wallet/policy-readiness.js"; +import { latestAppliedPolicy } from "../wallet/repository.js"; +import { custodyCredential } from "../wallet/service.js"; +import { assertDispatchFence, commitDispatch } from "./dispatch-fence.js"; +import { parseIdentitySigningMessage } from "./identity-signing.js"; +import { type Operation, runOperation } from "./service.js"; + +type SigningWallet = Awaited>["wallet"]; + +async function runSigningOperation( + actor: Actor, + agentId: string, + grantToken: string | undefined, + action: "near_message" | "evm_message", + input: TInput, + check: GrantCheck, + run: (wallet: SigningWallet, operationId: string) => Promise, + prepare?: (wallet: SigningWallet) => void | Promise, + validateBeforeDispatch?: () => void | Promise, +): Promise { + const { wallet } = await requireBoundAgent(actor, agentId); + await requireReadyPolicy(actor.tenantId, agentId); + // Detached signatures are authorization artifacts. The owner must have delegated this exact + // signing action to the grant the caller's token names, not merely have issued a tenant key. + const decision = await authorizeDelegatedAction({ + actor, + agentId, + grant: { token: grantToken }, + action: `sign:${action}`, + check, + walletId: wallet.providerWalletId, + }); + await prepare?.(wallet); + return runOperation({ + actor, + agentId, + kind: "sign", + action: `sign:${action}`, + idempotencyKey: input.idempotencyKey, + request: input, + grant: { id: decision.grantId, label: decision.label }, + authorizationEpochs: decision.authorizationEpochs, + signingAction: action, + run: async (operationId) => { + // A detached signature is as good as the grant it was authorized under: re-check the + // epochs at the commitment point so a revocation between admission and signing fails. + await assertDispatchFence(actor.tenantId, agentId, operationId); + await requireReadyPolicy(actor.tenantId, agentId); + await assertDispatchFence(actor.tenantId, agentId, operationId); + await validateBeforeDispatch?.(); + // Identity-policy validation reads the current policy asynchronously. Commit after that + // read, immediately before requesting the detached signature. + await commitDispatch(actor.tenantId, agentId, operationId); + return run(wallet, operationId); + }, + }); +} + +/** + * NEP-413 signing with the agent custody wallet. The provider enforces its own + * recipient allowlist; policy is the authority boundary, not this server. + */ +export async function signMessage( + actor: Actor, + agentId: string, + input: SignMessage, + grantToken: string | undefined, +) { + if (!input.recipient) throw new ApiError("sign_recipient_required"); + const message = parseIdentitySigningMessage( + decodeMessage(input.message, input.encoding), + "near", + input.recipient, + ); + return runSigningOperation( + actor, + agentId, + grantToken, + "near_message", + input, + grantCheckForIdentitySigning(message.audience), + async (wallet, operationId) => { + const nonce = Buffer.from(operationId, "hex").toString("base64"); + const response = await getOutlayer().signNearMessage(custodyCredential(wallet), { + message: canonical(message), + recipient: message.audience, + nonce, + }); + return { + ...signatureSchemaFor(response, wallet.nearAccountId), + nonce, + recipient: message.audience, + }; + }, + () => requireIdentitySigningPolicy(actor.tenantId, agentId, message), + async () => { + parseIdentitySigningMessage(canonical(message), "near", message.audience); + await requireIdentitySigningPolicy(actor.tenantId, agentId, message); + }, + ); +} + +export async function signEvmMessage( + actor: Actor, + agentId: string, + input: EvmSignMessage, + grantToken: string | undefined, +) { + const message = parseIdentitySigningMessage( + decodeMessage(input.message, input.encoding), + input.chain, + ); + return runSigningOperation( + actor, + agentId, + grantToken, + "evm_message", + input, + grantCheckForIdentitySigning(message.audience), + async (wallet) => { + const response = await getOutlayer().signEvmMessage(custodyCredential(wallet), { + chain: input.chain, + encoding: "utf8", + message: canonical(message), + }); + return normalizeEvmSignature(response.address, response.signature, wallet.evmAddress); + }, + () => requireIdentitySigningPolicy(actor.tenantId, agentId, message), + async () => { + parseIdentitySigningMessage(canonical(message), input.chain, message.audience); + await requireIdentitySigningPolicy(actor.tenantId, agentId, message); + }, + ); +} + +export async function signEvmTypedData( + _actor: Actor, + _agentId: string, + _input: EvmSignTypedData, +): Promise { + throw new ApiError("signing_format_unsupported", 403); +} + +export async function signEvmTransaction( + _actor: Actor, + _agentId: string, + _input: EvmSignTransaction, +): Promise { + throw new ApiError("signing_format_unsupported", 403); +} + +async function requireIdentitySigningPolicy( + tenantId: string, + agentId: string, + challenge: ReturnType, +) { + const record = await latestAppliedPolicy(tenantId, agentId); + if (!record) throw new ApiError("policy_not_ready", 409); + const parsed = policySchema.safeParse(record.rules); + if (!parsed.success) throw new ApiError("policy_not_ready", 409); + const allowed = + challenge.chain === "near" + ? parsed.data.capabilities.sign_message.allowed && + !parsed.data.capabilities.sign_message.requires_approval && + parsed.data.capabilities.sign_message.allowed_recipients.includes(challenge.audience) + : parsed.data.capabilities.evm_sign.allowed; + if (!allowed) throw new ApiError("signing_policy_denied", 403); +} + +function signatureSchemaFor( + response: { public_key?: string; signature?: string; signature_base64?: unknown }, + fallbackAccountId: string, +): Omit { + const signature = normalizeNearSignature(response); + if (!signature) throw new ApiError("provider_signature_invalid", 502); + if (!response.public_key) throw new ApiError("provider_signature_invalid", 502); + return { + near_account_id: fallbackAccountId, + public_key: response.public_key, + signature, + }; +} + +function normalizeNearSignature(response: { signature?: string; signature_base64?: unknown }) { + if (response.signature && /^[0-9a-f]{128}$/.test(response.signature)) return response.signature; + if (typeof response.signature_base64 !== "string") return null; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(response.signature_base64)) return null; + const bytes = Buffer.from(response.signature_base64, "base64"); + return bytes.length === 64 ? bytes.toString("hex") : null; +} + +function normalizeEvmSignature( + address: string | null | undefined, + signature: string | null | undefined, + fallbackAddress: string, +) { + const resolvedAddress = address ?? fallbackAddress; + if (!/^0x[0-9a-fA-F]{40}$/.test(fallbackAddress) || !/^0x[0-9a-fA-F]{40}$/.test(resolvedAddress)) + throw new ApiError("evm_address_unavailable", 409); + if (resolvedAddress.toLowerCase() !== fallbackAddress.toLowerCase()) + throw new ApiError("provider_signature_invalid", 502); + if (!signature || !/^0x[0-9a-fA-F]{130}$/.test(signature)) + throw new ApiError("provider_signature_invalid", 502); + return evmSignatureSchema.parse({ evm_address: fallbackAddress.toLowerCase(), signature }); +} + +function decodeMessage(message: string, encoding: "utf8" | "hex" | undefined) { + if (encoding === "hex" && !/^(?:[0-9a-fA-F]{2})*$/.test(message)) + throw new ApiError("invalid_hex_payload"); + if (encoding !== "hex") return message; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(message, "hex")); + } catch { + throw new ApiError("invalid_utf8_payload"); + } +} diff --git a/packages/agents-core/src/modules/operations/spend-budget.ts b/packages/agents-core/src/modules/operations/spend-budget.ts new file mode 100644 index 000000000..eb7316724 --- /dev/null +++ b/packages/agents-core/src/modules/operations/spend-budget.ts @@ -0,0 +1,260 @@ +import type { BudgetView, ExecutionRequest } from "@near-intents-agent-api/contracts"; +import { agents, auditEvents, spendCharges, type Tx } from "@near-intents-agent-api/database"; +import { and, eq, gte, isNull, min, sql, sum } from "drizzle-orm"; +import { databaseClock, getDatabase } from "../../lib/db.js"; +import { type AssetPrice, isPriceFresh, usdMicros } from "../../lib/prices.js"; +import { ApiError } from "../../shared/errors.js"; + +/** + * The owner's USD spend budget, enforced by this API on top of OutLayer's per-asset limits. + * + * OutLayer caps each token separately; it has no notion of "$200 a day across everything". The + * owner signs up to three caps (rolling 24 h, 7 d, 30 d) and every execution that moves value out + * of the agent is valued in USD and charged against them at its dispatch commitment. + * + * The charge is decided and inserted inside the commitment's own transaction (`commitDispatch`), + * which already holds the agent row a budget change needs. So an owner's cap change and a dispatch + * are strictly ordered: a change that commits first governs the dispatch, and one that commits + * after cannot retract it. There is no separate reservation between admission and commitment, so + * a crashed or stalled attempt leaves nothing to strand, and a losing attempt has nothing to + * refund. The per-agent advisory lock only serializes the sum-then-insert of concurrent charges. + * + * As with sponsorship, only a write that provably never reached the provider is refunded, and only + * by the attempt that won its commitment; anything that may have broadcast stays counted. + */ + +const hourMs = 60 * 60 * 1000; +export const budgetWindows = { + daily: 24 * hourMs, + weekly: 7 * 24 * hourMs, + monthly: 30 * 24 * hourMs, +} as const; +type Window = keyof typeof budgetWindows; +const windowNames = Object.keys(budgetWindows) as Window[]; + +/** One cent in millionths of a dollar, the unit charges are counted in. */ +const microsPerCent = 10_000; + +/** The asset and atomic amount an execution moves out of the agent, or none if it stays inside. */ +export function spendOf(input: ExecutionRequest): { asset: string; amount: string } | undefined { + switch (input.action) { + case "swap": + return { asset: input.request.token_in, amount: input.request.amount_in }; + case "withdraw": + case "intents_transfer": + case "confidential_transfer": + return { asset: input.request.token, amount: input.request.amount }; + default: + // Shield, unshield and deposits move the owner's own funds between the agent's balances. + return undefined; + } +} + +/** `"1000.50"` → cents. The contract already restricts the shape. */ +export function centsFromUsd(usd: string | null): number | null { + if (usd === null) return null; + const [whole = "0", fraction = ""] = usd.split("."); + return Number(whole) * 100 + Number(fraction.padEnd(2, "0")); +} + +export function usdFromCents(cents: number | null): string | null { + if (cents === null) return null; + return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`; +} + +function usdFromMicros(micros: number): string { + return `${Math.floor(micros / 1_000_000)}.${String(micros % 1_000_000).padStart(6, "0")}`; +} + +type BudgetCaps = Pick< + typeof agents.$inferSelect, + "budgetDailyCents" | "budgetWeeklyCents" | "budgetMonthlyCents" +>; + +function capsOf(agent: BudgetCaps): Record { + return { + daily: agent.budgetDailyCents, + weekly: agent.budgetWeeklyCents, + monthly: agent.budgetMonthlyCents, + }; +} + +/** Counted spend: every charge not refunded, from `since`. */ +function counted(tenantId: string, agentId: string, since: Date) { + return and( + eq(spendCharges.tenantId, tenantId), + eq(spendCharges.agentId, agentId), + isNull(spendCharges.releasedAt), + gte(spendCharges.chargedAt, since), + ); +} + +async function spentSince(tx: Tx, tenantId: string, agentId: string, since: Date) { + const [row] = await tx + .select({ total: sum(spendCharges.usdMicros), oldest: min(spendCharges.chargedAt) }) + .from(spendCharges) + .where(counted(tenantId, agentId, since)); + return { micros: Number(row?.total ?? 0), oldest: row?.oldest ?? null }; +} + +/** + * The charge needs a price the caller has not supplied, or the one supplied went stale while the + * transaction waited for its locks. Internal: the caller rolls back, prices outside any lock and + * decides again from the top, so a cap that appeared in the meantime is never skipped. + */ +export class PriceRequired extends Error { + constructor() { + super("price_required"); + } +} + +/** An amount too large to value is larger than any cap can hold. */ +function chargeValue(amount: string, price: AssetPrice): number { + try { + return usdMicros(amount, price); + } catch (error) { + if (error instanceof RangeError) throw new ApiError("spend_budget_exceeded", 403); + throw error; + } +} + +/** + * Charges an execution's USD value against the agent's caps inside its dispatch commitment, or + * refuses it, which rolls the commitment back. `agent` is the row the commitment holds locked, so + * its caps are current and cannot change until the transaction ends. A no-op for uncounted actions + * and accounts never configured with any budget. Configured accounts keep tracking with fresh + * prices even when every cap is null. + */ +export async function chargeSpend( + tx: Tx, + agent: typeof agents.$inferSelect, + operationId: string, + token: string, + input: ExecutionRequest, + price: AssetPrice | undefined, +): Promise { + const spend = spendOf(input); + if (!spend) return; + const caps = capsOf(agent); + // Once the owner has configured this account's budget, keep recording during uncapped + // periods too. Temporarily clearing caps must not erase spend when caps are restored. + if (agent.budgetRevision === 0 && windowNames.every((name) => caps[name] === null)) return; + if (!price) throw new PriceRequired(); + + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`spend:${agent.tenantId}:${agent.id}`}, 0))`, + ); + // Decided at database time after every wait, so the windows, the row's timestamp and the price's + // age all describe the same instant. + const decidedAt = await databaseClock(tx); + if (!isPriceFresh(price, decidedAt.getTime())) throw new PriceRequired(); + const micros = chargeValue(spend.amount, price); + for (const name of windowNames) { + const cap = caps[name]; + if (cap === null) continue; + const { micros: spent } = await spentSince( + tx, + agent.tenantId, + agent.id, + new Date(decidedAt.getTime() - budgetWindows[name]), + ); + if (spent + micros > cap * microsPerCent) throw new ApiError("spend_budget_exceeded", 403); + } + await tx.insert(spendCharges).values({ + operationId, + tenantId: agent.tenantId, + agentId: agent.id, + action: input.action, + asset: spend.asset, + amount: spend.amount, + usdMicros: micros, + decimals: price.decimals, + priceCoefficient: price.coefficient.toString(), + priceScale: price.scale, + priceUpdatedAt: new Date(price.updatedAtMs), + budgetRevision: agent.budgetRevision, + dispatchToken: token, + state: "committed", + chargedAt: decidedAt, + }); + await tx.insert(auditEvents).values({ + tenantId: agent.tenantId, + agentId: agent.id, + action: "spend.charged", + resourceId: operationId, + }); +} + +/** + * Refunds the charge of a write the provider refused before admitting it. Bound to the receipt of + * the commitment that created the charge, so no other attempt at the same operation can refund it, + * and conditional on `committed`, so a repeat is a no-op rather than a second credit. + */ +export async function refundSpend( + tenantId: string, + agentId: string, + operationId: string, + token: string, +): Promise { + await getDatabase().transaction(async (tx) => { + const released = await tx + .update(spendCharges) + .set({ state: "released", releasedAt: sql`clock_timestamp()` }) + .where( + and( + eq(spendCharges.tenantId, tenantId), + eq(spendCharges.agentId, agentId), + eq(spendCharges.operationId, operationId), + eq(spendCharges.dispatchToken, token), + eq(spendCharges.state, "committed"), + ), + ) + .returning({ id: spendCharges.operationId }); + if (released.length !== 1) return; + await tx + .insert(auditEvents) + .values({ tenantId, agentId, action: "spend.released", resourceId: operationId }); + }); +} + +/** + * Caps and how much of each window is charged, for the owner and the agent to read. Charged + * usage includes work whose outcome is still uncertain, since that may have moved value. Read as + * one snapshot at database time, so the caps and every window agree with what a dispatch decides. + */ +export async function readBudgetFor( + tenantId: string, + agentId: string, + tx?: Tx, +): Promise { + if (!tx) { + return getDatabase().transaction((snapshot) => readBudgetFor(tenantId, agentId, snapshot), { + isolationLevel: "repeatable read", + accessMode: "read only", + }); + } + const [agent] = await tx + .select() + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))); + if (!agent) throw new ApiError("agent_not_found", 404); + const caps = capsOf(agent); + const now = (await databaseClock(tx)).getTime(); + const usage = {} as Record; + for (const name of windowNames) { + const { micros: spent, oldest } = await spentSince( + tx, + tenantId, + agentId, + new Date(now - budgetWindows[name]), + ); + const cap = caps[name]; + usage[name] = { + limit_usd: usdFromCents(cap), + spent_usd: usdFromMicros(spent), + remaining_usd: cap === null ? null : usdFromMicros(Math.max(0, cap * microsPerCent - spent)), + resets_at: oldest ? new Date(oldest.getTime() + budgetWindows[name]).toISOString() : null, + }; + } + return { ...usage, revision: agent.budgetRevision, enforced_by: "agent_api" }; +} diff --git a/packages/agents-core/src/modules/operations/sponsor-budget.ts b/packages/agents-core/src/modules/operations/sponsor-budget.ts new file mode 100644 index 000000000..3fd014133 --- /dev/null +++ b/packages/agents-core/src/modules/operations/sponsor-budget.ts @@ -0,0 +1,120 @@ +import { auditEvents, sponsorReservations, tenants } from "@near-intents-agent-api/database"; +import { and, count, eq, gte, isNull, sql } from "drizzle-orm"; +import { getRuntime } from "../../config/runtime.js"; +import { getDatabase } from "../../lib/db.js"; +import { ApiError } from "../../shared/errors.js"; + +/** + * Sponsorship budget accounting used to count reservations and subtract releases in one query that + * correlated the release with the *requesting* operation instead of the reservation being counted. + * The effect was that a released reservation stayed charged to every later operation until it aged + * out, so abandoned work could exhaust an agent's whole daily budget. + * + * The accounting is now an explicit reservation row with one state. Released reservations are + * excluded by joining the reservation to its own release state, and the state transition is atomic + * so a double release can never credit twice. + */ + +/** Cross-process serialization; survives restarts, unlike an in-memory limiter. */ +const budgetLockKey = 1935764846; + +export async function reserveSponsorship(tenantId: string, agentId: string, operationId: string) { + const runtime = getRuntime(); + await getDatabase().transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${budgetLockKey})`); + const [tenantRow] = await tx + .select() + .from(tenants) + .where(eq(tenants.id, tenantId)) + .for("share"); + if (!tenantRow) throw new ApiError("tenant_not_found", 404); + const tenantLimit = tenantRow.quotaProfile.sponsorDaily ?? runtime.sponsorDailyTenantLimit; + const agentLimit = + tenantRow.quotaProfile.sponsorDailyPerAgent ?? runtime.sponsorDailyAgentLimit; + const [existing] = await tx + .select({ id: sponsorReservations.operationId }) + .from(sponsorReservations) + .where(eq(sponsorReservations.operationId, operationId)) + .limit(1); + if (existing) return; + const charged = and( + gte(sponsorReservations.createdAt, new Date(Date.now() - 86400000)), + isNull(sponsorReservations.releasedAt), + ); + const [global] = await tx.select({ value: count() }).from(sponsorReservations).where(charged); + const [tenant] = await tx + .select({ value: count() }) + .from(sponsorReservations) + .where(and(charged, eq(sponsorReservations.tenantId, tenantId))); + const [agent] = await tx + .select({ value: count() }) + .from(sponsorReservations) + .where( + and( + charged, + eq(sponsorReservations.tenantId, tenantId), + eq(sponsorReservations.agentId, agentId), + ), + ); + if ( + (global?.value ?? 0) >= runtime.sponsorDailyGlobalLimit || + (tenant?.value ?? 0) >= tenantLimit || + (agent?.value ?? 0) >= agentLimit + ) + throw new ApiError("sponsor_daily_budget_exhausted", 429); + await tx.insert(sponsorReservations).values({ + operationId, + tenantId, + agentId, + state: "reserved", + }); + await tx + .insert(auditEvents) + .values({ tenantId, agentId, action: "sponsor.reserved", resourceId: operationId }); + }); +} + +/** + * Refunds a reservation for work that provably never broadcast. The row transition is conditional + * on `reserved`, so concurrent or repeated releases are a no-op rather than a second credit. Never + * call this once a transaction hash exists: that spend is real even if it later fails. + */ +export async function releaseSponsorship( + tenantId: string, + agentId: string, + operationId: string, +): Promise { + await getDatabase().transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${budgetLockKey})`); + const released = await tx + .update(sponsorReservations) + .set({ state: "released", releasedAt: new Date() }) + .where( + and( + eq(sponsorReservations.operationId, operationId), + eq(sponsorReservations.state, "reserved"), + ), + ) + .returning({ id: sponsorReservations.operationId }); + if (released.length !== 1) return; + await tx + .insert(auditEvents) + .values({ tenantId, agentId, action: "sponsor.released", resourceId: operationId }); + }); +} + +/** + * Marks a reservation as committed: the sponsored work may have broadcast, so the budget slot is + * consumed permanently and a later release cannot refund it. + */ +export async function commitSponsorship(operationId: string): Promise { + await getDatabase() + .update(sponsorReservations) + .set({ state: "committed" }) + .where( + and( + eq(sponsorReservations.operationId, operationId), + eq(sponsorReservations.state, "reserved"), + ), + ); +} diff --git a/packages/agents-core/src/modules/operations/timelock-policy-service.ts b/packages/agents-core/src/modules/operations/timelock-policy-service.ts new file mode 100644 index 000000000..5607368be --- /dev/null +++ b/packages/agents-core/src/modules/operations/timelock-policy-service.ts @@ -0,0 +1,314 @@ +import { + canonical, + type ExecutionCancellation, + type ScheduledExecution, + type TimelockSettings, + type TimelockView, + type TimelockWrite, +} from "@near-intents-agent-api/contracts"; +import { + agents, + auditEvents, + type Db, + delayedExecutions, + operations, + type Tx, +} from "@near-intents-agent-api/database"; +import { and, asc, eq, gt, inArray, or, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import type { CommitEffect } from "../../shared/commit-effect.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { consumeOwnerNonceInTransaction } from "../../shared/nonces.js"; +import { + assertOwnerAdminCommand, + ownerAdminCommandChallenge, + ownerPrincipalId, + verifyOwnerAdminCommand, +} from "../agents/owner-admin-command.js"; +import { recordOwnerReceipt } from "../agents/owner-receipts.js"; +import { requireBoundAgent } from "../agents/service.js"; + +type ScheduledState = "waiting" | "dispatching"; +const scheduledStates: ScheduledState[] = ["waiting", "dispatching"]; + +async function requireAgentRow(tenantId: string, agentId: string, db: Db | Tx) { + const [agent] = await db + .select() + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))); + if (!agent) throw new ApiError("agent_not_found", 404); + return agent; +} + +export async function readTimelockFor( + tenantId: string, + agentId: string, + db: Db | Tx = getDatabase(), +): Promise { + const agent = await requireAgentRow(tenantId, agentId, db); + const scheduled = await db.$count( + delayedExecutions, + and( + eq(delayedExecutions.tenantId, tenantId), + eq(delayedExecutions.agentId, agentId), + inArray(delayedExecutions.state, scheduledStates), + ), + ); + return { + delay_seconds: agent.timelockDelaySeconds, + revision: agent.timelockRevision, + scheduled_count: scheduled, + enforced_by: "agent_api", + }; +} +export async function readTimelock(actor: Actor, agentId: string) { + return readTimelockFor(actor.tenantId, agentId); +} + +/** + * Release time at the millisecond precision a cursor carries, so paging neither skips nor repeats + * executions released within the same millisecond. + */ +const releaseMs = sql`date_trunc('milliseconds', ${delayedExecutions.executeAfter})`; + +/** Cursor: `|` of the last execution returned. */ +function parseScheduledCursor(cursor: string | undefined) { + if (!cursor) return undefined; + const [at, id] = cursor.split("|"); + const executeAfter = at ? new Date(at) : new Date(Number.NaN); + if (!id || Number.isNaN(executeAfter.getTime())) throw new ApiError("invalid_cursor", 400); + return { executeAfter, id }; +} + +/** Scheduled executions, earliest release first, one page at a time. */ +export async function listScheduledExecutionsFor( + tenantId: string, + agentId: string, + query: { cursor?: string; limit: number }, +): Promise<{ scheduled: ScheduledExecution[]; next_cursor: string | null }> { + const db = getDatabase(); + await requireAgentRow(tenantId, agentId, db); + const after = parseScheduledCursor(query.cursor); + const rows = await db + .select({ + id: delayedExecutions.id, + executeAfter: delayedExecutions.executeAfter, + state: delayedExecutions.state, + result: operations.result, + }) + .from(delayedExecutions) + .innerJoin( + operations, + and( + eq(operations.tenantId, delayedExecutions.tenantId), + eq(operations.agentId, delayedExecutions.agentId), + eq(operations.id, delayedExecutions.id), + ), + ) + .where( + and( + eq(delayedExecutions.tenantId, tenantId), + eq(delayedExecutions.agentId, agentId), + inArray(delayedExecutions.state, scheduledStates), + after + ? or( + gt(releaseMs, after.executeAfter), + and(eq(releaseMs, after.executeAfter), gt(delayedExecutions.id, after.id)), + ) + : undefined, + ), + ) + .orderBy(asc(releaseMs), asc(delayedExecutions.id)) + .limit(query.limit + 1); + const page = rows.slice(0, query.limit); + const last = page.at(-1); + return { + scheduled: page.map((row) => ({ + operation_id: row.id, + execute_after: row.executeAfter.toISOString(), + state: row.state as ScheduledState, + action: + row.result && + typeof row.result === "object" && + "action" in row.result && + typeof row.result.action === "string" + ? row.result.action + : null, + })), + next_cursor: + rows.length > query.limit && last ? `${last.executeAfter.toISOString()}|${last.id}` : null, + }; +} +export async function listScheduledExecutions( + actor: Actor, + agentId: string, + query: { cursor?: string; limit: number }, +) { + return listScheduledExecutionsFor(actor.tenantId, agentId, query); +} +function target(delay_seconds: number, expected_revision: number) { + return hashSecret(canonical({ delay_seconds, expected_revision })); +} +export async function timelockChallenge(actor: Actor, agentId: string, settings: TimelockSettings) { + const { agent } = await requireBoundAgent(actor, agentId); + return { + ...(await ownerAdminCommandChallenge( + actor, + agent, + "set_timelock", + target(settings.delay_seconds, agent.timelockRevision), + )), + delay_seconds: settings.delay_seconds, + expected_revision: agent.timelockRevision, + }; +} +export async function setTimelock( + actor: Actor, + agentId: string, + input: TimelockWrite, + onCommit?: CommitEffect>>, + original?: unknown, +) { + const { agent } = await requireBoundAgent(actor, agentId); + const { message, proof } = input; + assertOwnerAdminCommand( + actor, + agent, + "set_timelock", + target(message.delay_seconds, message.expected_revision), + message, + ); + const verification = await verifyOwnerAdminCommand(agent, message, proof); + return getDatabase().transaction(async (tx) => { + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, message.nonce); + const changed = await tx + .update(agents) + .set({ + timelockDelaySeconds: message.delay_seconds, + timelockRevision: sql`${agents.timelockRevision} + 1`, + policyEpoch: sql`${agents.policyEpoch} + 1`, + }) + .where( + and( + eq(agents.tenantId, actor.tenantId), + eq(agents.id, agentId), + eq(agents.timelockRevision, message.expected_revision), + eq(agents.ownerEpoch, message.owner_epoch), + ), + ) + .returning({ id: agents.id }); + if (!changed.length) throw new ApiError("timelock_revision_conflict", 409); + await tx.insert(auditEvents).values({ + tenantId: actor.tenantId, + agentId, + resourceId: agentId, + action: "timelock.updated", + actorKeyId: actor.keyId, + actorOwnerId: ownerPrincipalId(message.owner), + requestHash: hashSecret(canonical(message)), + }); + await recordOwnerReceipt(tx, { + tenantId: actor.tenantId, + agentId, + action: "timelock_set", + targetId: message.target_id, + ownerEpoch: message.owner_epoch, + message, + proof, + verification, + original, + }); + const result = await readTimelockFor(actor.tenantId, agentId, tx); + await onCommit?.(tx, result); + return result; + }); +} +export async function cancellationChallenge(actor: Actor, agentId: string, operationId: string) { + const { agent } = await requireBoundAgent(actor, agentId); + const [job] = await getDatabase() + .select() + .from(delayedExecutions) + .where( + and( + eq(delayedExecutions.tenantId, actor.tenantId), + eq(delayedExecutions.agentId, agentId), + eq(delayedExecutions.id, operationId), + ), + ); + if (!job) throw new ApiError("operation_not_found", 404); + if (job.state !== "waiting") throw new ApiError("execution_not_cancellable", 409); + return ownerAdminCommandChallenge(actor, agent, "cancel_execution", operationId); +} +export async function cancelExecution( + actor: Actor, + agentId: string, + operationId: string, + input: ExecutionCancellation, + onCommit?: CommitEffect<{ cancelled: true }>, + original?: unknown, +) { + const { agent } = await requireBoundAgent(actor, agentId); + assertOwnerAdminCommand(actor, agent, "cancel_execution", operationId, input.message); + const verification = await verifyOwnerAdminCommand(agent, input.message, input.proof); + return getDatabase().transaction(async (tx) => { + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, input.message.nonce); + const changed = await tx + .update(delayedExecutions) + .set({ + state: "cancelled", + ciphertext: null, + nonce: null, + keyId: null, + updatedAt: sql`now()`, + }) + .where( + and( + eq(delayedExecutions.tenantId, actor.tenantId), + eq(delayedExecutions.agentId, agentId), + eq(delayedExecutions.id, operationId), + eq(delayedExecutions.state, "waiting"), + ), + ) + .returning({ id: delayedExecutions.id }); + if (!changed.length) throw new ApiError("execution_not_cancellable", 409); + await tx + .update(operations) + .set({ + status: "failed", + result: { status: "cancelled", failure_code: "owner_cancelled" }, + updatedAt: sql`now()`, + }) + .where( + and( + eq(operations.tenantId, actor.tenantId), + eq(operations.agentId, agentId), + eq(operations.id, operationId), + ), + ); + await tx.insert(auditEvents).values({ + tenantId: actor.tenantId, + agentId, + resourceId: operationId, + action: "execute.cancelled", + actorKeyId: actor.keyId, + actorOwnerId: ownerPrincipalId(input.message.owner), + requestHash: hashSecret(canonical(input.message)), + }); + await recordOwnerReceipt(tx, { + tenantId: actor.tenantId, + agentId, + action: "execution_cancel", + targetId: operationId, + ownerEpoch: input.message.owner_epoch, + message: input.message, + proof: input.proof, + verification, + original, + }); + const result = { cancelled: true as const }; + await onCommit?.(tx, result); + return result; + }); +} diff --git a/packages/agents-core/src/modules/operations/timelock-worker.ts b/packages/agents-core/src/modules/operations/timelock-worker.ts new file mode 100644 index 000000000..3da65a3bf --- /dev/null +++ b/packages/agents-core/src/modules/operations/timelock-worker.ts @@ -0,0 +1,214 @@ +import { executeSchema } from "@near-intents-agent-api/contracts"; +import { apiKeys, delayedExecutions, operations } from "@near-intents-agent-api/database"; +import { and, eq, gt, isNull, lte, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { errorFields, logger } from "../../shared/logger.js"; +import { openSecret } from "../../shared/secrets.js"; +import { execute } from "./execution-service.js"; +import { findOperation, persistOperationResult } from "./repository.js"; + +type Job = typeof delayedExecutions.$inferSelect; +const identity = (job: Job) => + and( + eq(delayedExecutions.tenantId, job.tenantId), + eq(delayedExecutions.agentId, job.agentId), + eq(delayedExecutions.id, job.id), + ); + +function decodeJob(job: Job) { + if (!job.ciphertext || !job.nonce || !job.keyId) + throw new ApiError("timelock_payload_missing", 409); + return executeSchema.parse( + JSON.parse( + openSecret( + { ciphertext: job.ciphertext, nonce: job.nonce, keyId: job.keyId }, + { + schemaVersion: 1, + tenantId: job.tenantId, + agentId: job.agentId, + walletId: "", + purpose: "delayed_execution", + operationId: job.id, + grantId: job.grantId, + ownerEpoch: job.ownerEpoch, + }, + ), + ), + ); +} + +/** + * `runOperation` claims an operation (`uncertain`) before any provider sees it, so one still + * `pending` provably never dispatched: admission refused it after the delay, for example because + * its grant or key expired. A claimed operation keeps the outcome `runOperation` recorded. + */ +async function persistClaimFailure(job: Job, error: unknown) { + const operation = await findOperation(job.tenantId, job.agentId, job.id); + if (operation?.status !== "pending") return; + const priorResult = + operation.result && typeof operation.result === "object" && !Array.isArray(operation.result) + ? operation.result + : {}; + await persistOperationResult(job.tenantId, job.agentId, job.id, "execute", "failed", { + ...priorResult, + status: "failed", + failure_code: error instanceof ApiError ? error.code : "timelock_execution_failed", + }); +} + +async function runClaimed(job: Job) { + try { + const operation = await findOperation(job.tenantId, job.agentId, job.id); + if (!operation?.actorKeyId) throw new ApiError("invalid_api_key", 401); + // Revoking the key that sent the request stops its queued work, even though the grant lives on. + const [key] = await getDatabase() + .select() + .from(apiKeys) + .where( + and( + eq(apiKeys.tenantId, job.tenantId), + eq(apiKeys.id, operation.actorKeyId), + isNull(apiKeys.revokedAt), + gt(apiKeys.expiresAt, sql`now()`), + ), + ); + if (!key) throw new ApiError("invalid_api_key", 401); + const input = decodeJob(job); + const actor: Actor = { + tenantId: job.tenantId, + keyId: key.id, + }; + // The job resumes under the grant that admitted it, never whichever grant is newest. + await execute( + actor, + job.agentId, + input, + { admittedGrantId: job.grantId }, + { operationId: job.id, grantId: job.grantId }, + ); + } catch (error) { + await persistClaimFailure(job, error); + logger.warn("timelock_execution_stopped", { + ...errorFields(error), + operation_id: job.id, + code: error instanceof ApiError ? error.code : "timelock_execution_failed", + }); + } + await finishJob(job); +} + +async function finishJob(job: Job) { + const operation = await findOperation(job.tenantId, job.agentId, job.id); + if ( + operation?.status === "uncertain" && + operation.result && + typeof operation.result === "object" && + "status" in operation.result && + operation.result.status === "timelocked" + ) + await persistOperationResult(job.tenantId, job.agentId, job.id, "execute", "uncertain", { + ...operation.result, + status: "unknown", + failure_code: "timelock_dispatch_uncertain", + }); + await getDatabase() + .update(delayedExecutions) + .set({ + state: operation?.status === "uncertain" ? "uncertain" : "finished", + ciphertext: null, + nonce: null, + keyId: null, + updatedAt: sql`now()`, + }) + .where(identity(job)); +} + +/** Durable, at-most-once dispatch: a claimed job is never automatically submitted again. */ +export async function dispatchDueExecutions() { + const database = getDatabase(); + await database.transaction(async (tx) => { + const abandoned = await tx + .update(delayedExecutions) + .set({ + state: "uncertain", + ciphertext: null, + nonce: null, + keyId: null, + updatedAt: sql`now()`, + }) + .where( + and( + eq(delayedExecutions.state, "dispatching"), + sql`${delayedExecutions.updatedAt} < now() - interval '10 minutes'`, + ), + ) + .returning(); + for (const job of abandoned) + await tx + .update(operations) + .set({ + status: "uncertain", + result: sql`coalesce(${operations.result}, '{}'::jsonb) || '{"status":"unknown","failure_code":"timelock_worker_interrupted"}'::jsonb`, + updatedAt: sql`now()`, + }) + .where( + and( + eq(operations.tenantId, job.tenantId), + eq(operations.agentId, job.agentId), + eq(operations.id, job.id), + eq(operations.status, "pending"), + ), + ); + }); + const due = await database + .select() + .from(delayedExecutions) + .where( + and(eq(delayedExecutions.state, "waiting"), lte(delayedExecutions.executeAfter, sql`now()`)), + ) + .orderBy(delayedExecutions.executeAfter) + .limit(10); + for (const candidate of due) { + const [claimed] = await database + .update(delayedExecutions) + .set({ state: "dispatching", updatedAt: sql`now()` }) + .where( + and( + identity(candidate), + eq(delayedExecutions.state, "waiting"), + lte(delayedExecutions.executeAfter, sql`now()`), + ), + ) + .returning(); + if (claimed) await runClaimed(claimed); + } + return due.length; +} +let timer: NodeJS.Timeout | undefined; +let active: Promise | undefined; +let stopped = true; +export function startTimelockWorker() { + if (!stopped) return; + stopped = false; + const tick = () => { + active = dispatchDueExecutions() + .then( + () => {}, + (error) => logger.error("timelock_worker_failed", errorFields(error)), + ) + .finally(() => { + if (!stopped) { + timer = setTimeout(tick, 1000); + timer.unref(); + } + }); + }; + tick(); +} +export async function stopTimelockWorker() { + stopped = true; + clearTimeout(timer); + await active; +} diff --git a/packages/agents-core/src/modules/wallet/approval-service.ts b/packages/agents-core/src/modules/wallet/approval-service.ts new file mode 100644 index 000000000..82e883ce9 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/approval-service.ts @@ -0,0 +1,117 @@ +import { + type ApprovalVote, + approvalDetailSchema, + policySchema, +} from "@near-intents-agent-api/contracts"; +import { getDatabase } from "../../lib/db.js"; +import { getOutlayer } from "../../lib/outlayer.js"; +import type { Actor } from "../../shared/actor.js"; +import type { CommitEffect } from "../../shared/commit-effect.js"; +import { hashSecret } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { verifyNearOwnerProof } from "../../shared/near.js"; +import { consumeOwnerNonceInTransaction, issueOwnerNonce } from "../../shared/nonces.js"; +import { requireActiveAgent } from "../agents/lifecycle-service.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { latestAppliedPolicy, recordApprovalVote } from "./repository.js"; +import { custodyCredential } from "./service.js"; + +export async function readApproval(actor: Actor, agentId: string, approvalId: string) { + const { wallet } = await requireBoundAgent(actor, agentId); + const approval = approvalDetailSchema.parse( + await getOutlayer().approval(custodyCredential(wallet), approvalId), + ); + if (approval.id !== approvalId || approval.wallet_id !== wallet.providerWalletId) + throw new ApiError("approval_not_found", 404); + return approval; +} + +export async function prepareApproval( + actor: Actor, + agentId: string, + approvalId: string, + verdict: "approve" | "reject", +) { + const approval = await readApproval(actor, agentId, approvalId); + if ( + approval.status !== "pending" || + !Number.isFinite(Date.parse(approval.expires_at)) || + Date.parse(approval.expires_at) <= Date.now() + ) + throw new ApiError("approval_closed", 409); + return { + approval, + message: `${verdict}:${approval.id}:${approval.wallet_pubkey}:${approval.request_hash}`, + recipient: getOutlayer().contractId, + nonce: await issueOwnerNonce(actor.tenantId, agentId), + }; +} + +/** + * Submits one approver's signed vote. The signature is the authority: it binds the verdict to the + * approval id, the wallet and the exact request hash, and only the owner or an approver listed in + * the applied policy can produce it. OutLayer verifies the same signature. The API key only + * carries the vote, so no grant is needed and an agent cannot vote for itself. + */ +export type ApprovalVoteEvidence = { + walletId: string; + walletPublicKey: string; + signatureHash: string; +}; + +export async function voteApproval( + actor: Actor, + agentId: string, + approvalId: string, + input: ApprovalVote, + onAccepted?: CommitEffect, +) { + const { agent, wallet } = await requireBoundAgent(actor, agentId); + await requireActiveAgent(actor.tenantId, agentId); + const approval = await readApproval(actor, agentId, approvalId); + const policy = await latestAppliedPolicy(actor.tenantId, agentId); + const approvers = policy ? policySchema.parse(policy.rules).approval?.approvers : undefined; + const isOwner = + input.account_id === agent.ownerAccountId && input.public_key === agent.ownerPublicKey; + const isApprover = approvers?.some( + (approver) => approver.id === input.account_id && approver.pubkey === input.public_key, + ); + if (!isOwner && !isApprover) throw new ApiError("owner_mismatch", 403); + if ( + approval.status !== "pending" || + !Number.isFinite(Date.parse(approval.expires_at)) || + Date.parse(approval.expires_at) <= Date.now() + ) + throw new ApiError("approval_closed", 409); + if (approval.request_hash !== input.request_hash) throw new ApiError("approval_changed", 409); + await verifyNearOwnerProof({ + accountId: input.account_id, + publicKey: input.public_key, + message: `${input.verdict}:${approval.id}:${approval.wallet_pubkey}:${approval.request_hash}`, + recipient: getOutlayer().contractId, + nonceHex: input.nonce, + signatureHex: input.signature, + }); + await getDatabase().transaction(async (tx) => { + await consumeOwnerNonceInTransaction(tx, actor.tenantId, agentId, input.nonce); + await onAccepted?.(tx, { + walletId: approval.wallet_id, + walletPublicKey: approval.wallet_pubkey, + signatureHash: hashSecret(Buffer.from(input.signature, "hex").toString("base64")), + }); + }); + await recordApprovalVote({ + tenantId: actor.tenantId, + agentId, + approvalId, + actorKeyId: actor.keyId, + voter: `near:${input.account_id}:${input.public_key}`, + requestHash: hashSecret(`${approvalId}:${input.verdict}:${input.request_hash}`), + }); + return getOutlayer().vote(custodyCredential(wallet), approvalId, input.verdict, { + account_id: input.account_id, + public_key: input.public_key, + signature: Buffer.from(input.signature, "hex").toString("base64"), + nonce: Buffer.from(input.nonce, "hex").toString("base64"), + }); +} diff --git a/packages/agents-core/src/modules/wallet/balance-service.ts b/packages/agents-core/src/modules/wallet/balance-service.ts new file mode 100644 index 000000000..92dff0eaa --- /dev/null +++ b/packages/agents-core/src/modules/wallet/balance-service.ts @@ -0,0 +1,101 @@ +import { + type BalanceList, + type BalanceListQuery, + type Wallet, + walletSchema, +} from "@near-intents-agent-api/contracts"; +import { getOutlayer } from "../../lib/outlayer.js"; +import { getTokenCatalog } from "../../lib/token-catalog.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { nearProvider } from "../../shared/near.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { enrichBalance } from "./balance-utils.js"; +import { custodyCredential, toWallet } from "./service.js"; + +/** Native NEAR held by the custody account itself; only the deletion preview reads it. */ +export async function custodyNativeBalance(actor: Actor, id: string): Promise { + const { wallet } = await requireBoundAgent(actor, id); + const result = await getOutlayer().balance(custodyCredential(wallet), { chain: "near" }); + return result.balance; +} + +/** Native NEAR inside NEAR Intents is addressed as `nep141:native` by the provider. */ +const NATIVE_INTENTS_TOKEN = "nep141:native"; + +/** + * Multi-asset balances for the public and confidential sources. Public holds NEAR-native value + * under `nep141:native` plus every catalog token; confidential balances are provider-only. + * Only non-zero entries are returned so the list mirrors what the wallet actually holds. + */ +export async function balanceList( + actor: Actor, + id: string, + query: BalanceListQuery, +): Promise { + const { wallet } = await requireBoundAgent(actor, id); + const client = getOutlayer(); + const catalog = await getTokenCatalog().list(); + const catalogById = new Map(catalog.map((token) => [token.assetId, token])); + if (query.source === "confidential") { + const result = await client.confidentialBalance(custodyCredential(wallet)); + if (result.single) throw new ApiError("invalid_outlayer_response", 502); + return { + near_account_id: result.value.account_id, + source: "confidential", + balances: result.value.balances + .filter((entry) => BigInt(entry.balance) > 0n) + .map((entry) => enrichBalance(entry.token, entry.balance, catalogById, entry)), + } satisfies BalanceList; + } + + // The registry is the authority on what the wallet actually holds; the catalog supplies + // symbol/decimals. Held tokens that left the catalog still appear, with null metadata. + const held = await nearIntentsHeldTokens(wallet.nearAccountId); + const tokenIds = [...new Set([NATIVE_INTENTS_TOKEN, ...catalogById.keys(), ...held])]; + // One batch call on intents.near replaces one provider read per catalog token, which would + // otherwise fan out to hundreds of requests and trip the provider rate limit. + const balances = await nearIntentsBalances(wallet.nearAccountId, tokenIds); + return { + near_account_id: wallet.nearAccountId, + source: "public", + balances: tokenIds + .map((tokenId, index) => enrichBalance(tokenId, balances[index] ?? "0", catalogById)) + .filter((entry) => BigInt(entry.balanceRaw) > 0n), + } satisfies BalanceList; +} + +/** Tokens the wallet actually holds in intents.near, whether or not they remain in the catalog. */ +async function nearIntentsHeldTokens(accountId: string): Promise { + const result = await nearProvider().callFunction>({ + contractId: "intents.near", + method: "mt_tokens_for_owner", + args: { account_id: accountId }, + blockQuery: { finality: "final" }, + }); + if (!Array.isArray(result)) return []; + return result + .map((entry) => + entry && typeof entry === "object" && "token_id" in entry ? String(entry.token_id) : null, + ) + .filter((tokenId): tokenId is string => typeof tokenId === "string" && tokenId.length > 0); +} + +/** `mt_batch_balance_of` on intents.near returns one balance per requested token id, in order. */ +async function nearIntentsBalances(accountId: string, tokenIds: string[]): Promise { + if (tokenIds.length === 0) return []; + const result = await nearProvider().callFunction({ + contractId: "intents.near", + method: "mt_batch_balance_of", + args: { account_id: accountId, token_ids: tokenIds }, + blockQuery: { finality: "final" }, + }); + if (!Array.isArray(result) || result.some((value) => typeof value !== "string")) + throw new ApiError("invalid_intents_response", 502); + return result; +} + +export async function walletView(actor: Actor, id: string): Promise { + const { wallet } = await requireBoundAgent(actor, id); + return walletSchema.parse(toWallet(wallet)); +} diff --git a/packages/agents-core/src/modules/wallet/balance-utils.ts b/packages/agents-core/src/modules/wallet/balance-utils.ts new file mode 100644 index 000000000..2b1434554 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/balance-utils.ts @@ -0,0 +1,33 @@ +import type { BalanceEntry } from "@near-intents-agent-api/contracts"; +import type { CatalogToken } from "../../lib/token-catalog.js"; + +/** Exact token units without floating point or rounding. */ +export function tokenUnits(raw: string, decimals: number): string { + const value = BigInt(raw); + const divisor = 10n ** BigInt(decimals); + const whole = value / divisor; + const fraction = (value % divisor).toString().padStart(decimals, "0").replace(/0+$/, ""); + return fraction ? `${whole}.${fraction}` : whole.toString(); +} + +export function enrichBalance( + assetId: string, + balanceRaw: string, + catalog: ReadonlyMap, + provider?: { symbol?: string; decimals?: number }, +): BalanceEntry { + const token = catalog.get(assetId); + const native = assetId === "nep141:native"; + const decimals = token?.decimals ?? (native ? 24 : (provider?.decimals ?? null)); + return { + assetId, + symbol: token?.symbol ?? (native ? "NEAR" : (provider?.symbol ?? null)), + decimals, + blockchain: token?.blockchain ?? (native ? "near" : null), + price: token?.price ?? null, + priceUpdatedAt: token?.priceUpdatedAt ?? null, + priceExpiresAt: token?.priceExpiresAt ?? null, + balanceRaw, + balance: decimals === null ? null : tokenUnits(balanceRaw, decimals), + }; +} diff --git a/packages/agents-core/src/modules/wallet/near-policy-delegate.ts b/packages/agents-core/src/modules/wallet/near-policy-delegate.ts new file mode 100644 index 000000000..7b51be935 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/near-policy-delegate.ts @@ -0,0 +1,85 @@ +import { createHash } from "node:crypto"; +import { nearPolicyRequestSchema, type PolicyDelegate } from "@near-intents-agent-api/contracts"; +import { base58 } from "@scure/base"; +import { deserialize } from "borsh"; +import { encodeDelegateAction, PublicKey, SCHEMA } from "near-api-js"; +import { ApiError } from "../../shared/errors.js"; + +type Decoded = { + delegateAction: { + senderId: string; + receiverId: string; + nonce: bigint; + maxBlockHeight: bigint; + publicKey: { ed25519Key?: { data: number[] } }; + actions: Array<{ + functionCall?: { + methodName: string; + args: number[]; + gas: bigint; + deposit: bigint; + }; + }>; + }; + signature: { ed25519Signature?: { data: number[] } }; +}; + +/** Decode wallet-signed NEP-366 bytes; compare every action with prepared policy request. */ +export function parseNearPolicyDelegate(input: { + signedDelegate: string; + ownerAccountId: string; + ownerPublicKey: string; + prepared: unknown; +}): { delegate: PolicyDelegate; signatureHex: string } { + if (Buffer.from(input.signedDelegate, "base64").toString("base64") !== input.signedDelegate) + throw new ApiError("policy_delegate_invalid", 400); + let decoded: Decoded; + try { + decoded = deserialize( + SCHEMA.SignedDelegate, + Buffer.from(input.signedDelegate, "base64"), + ) as Decoded; + } catch { + throw new ApiError("policy_delegate_invalid", 400); + } + const action = decoded.delegateAction; + const key = action.publicKey.ed25519Key?.data; + const signature = decoded.signature.ed25519Signature?.data; + if ( + key?.length !== 32 || + !signature || + signature.length !== 64 || + action.senderId !== input.ownerAccountId || + `ed25519:${base58.encode(Uint8Array.from(key))}` !== input.ownerPublicKey + ) + throw new ApiError("policy_delegate_owner_mismatch", 403); + const digest = createHash("sha256") + .update(encodeDelegateAction(action as Parameters[0])) + .digest(); + if (!PublicKey.from(input.ownerPublicKey).verify(digest, Uint8Array.from(signature))) + throw new ApiError("policy_delegate_signature_invalid", 401); + const prepared = nearPolicyRequestSchema.parse(input.prepared); + const delegate: PolicyDelegate = { + senderId: action.senderId, + receiverId: action.receiverId, + publicKey: PublicKey.from(input.ownerPublicKey).toString(), + nonce: action.nonce.toString(), + maxBlockHeight: action.maxBlockHeight.toString(), + actions: action.actions.map((entry) => { + const call = entry.functionCall; + if (!call) throw new ApiError("policy_delegate_action_invalid", 400); + return { + methodName: call.methodName as PolicyDelegate["actions"][number]["methodName"], + argsBase64: Buffer.from(call.args).toString("base64"), + gas: call.gas.toString(), + depositYocto: call.deposit.toString(), + }; + }), + }; + if ( + action.receiverId !== prepared.receiver_id || + JSON.stringify(delegate.actions) !== JSON.stringify(prepared.actions) + ) + throw new ApiError("policy_delegate_action_mismatch", 409); + return { delegate, signatureHex: Buffer.from(signature).toString("hex") }; +} diff --git a/packages/agents-core/src/modules/wallet/owner-policy-preparation.ts b/packages/agents-core/src/modules/wallet/owner-policy-preparation.ts new file mode 100644 index 000000000..f9728a257 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/owner-policy-preparation.ts @@ -0,0 +1,332 @@ +import { randomBytes } from "node:crypto"; +import type { Policy, WalletRequestMessage } from "@near-intents-agent-api/contracts"; +import { + canonical, + evmWalletRequestMessageSchema, + nearPolicyRequestSchema, + policySchema, + walletRequestMessageSchema, +} from "@near-intents-agent-api/contracts"; +import { policyStorageDeposit } from "@near-intents-agent-api/relayer"; +import { getRuntime } from "../../config/runtime.js"; +import { withAdvisoryLock } from "../../lib/db.js"; +import { getOutlayer } from "../../lib/outlayer.js"; +import { defaultedSlot } from "../../lib/slot.js"; +import type { Actor } from "../../shared/actor.js"; +import { hashSecret, newId } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { nearProvider } from "../../shared/near.js"; +import { isArchived } from "../agents/lifecycle-repository.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { assertDispatchFence } from "../operations/dispatch-fence.js"; +import { runOperation } from "../operations/service.js"; +import { settleSupersededRevision } from "./owner-policy-reconciliation.js"; +import { assertPolicyWritable } from "./policy-write-rules.js"; +import { releaseExpiredPreparation } from "./prepared-policy.js"; +import { insertWalletPolicy, latestPolicy, latestPolicyVersion } from "./repository.js"; +import { custodyCredential } from "./service.js"; + +/** + * Policy preparation: turn an owner-signed revision into an exact provider transaction. + * + * No controller key signs anything here. The owner's signature is verified against the bound + * identity, the provider encrypts and signs the policy under its own custody key, and the result + * is persisted as `pending_wallet_signature` for the owner to authorize on chain. + */ + +/** The bound owner identity every policy ceremony is verified against. */ +type BoundWallet = Awaited>["wallet"]; + +const slot = defaultedSlot(policyStorageDeposit); + +/** Integration tests substitute a deterministic quote; production uses the NEAR RPC estimator. */ +export function configurePolicyStorageEstimator(next?: typeof policyStorageDeposit) { + slot.set(next); +} + +function policyRequests(input: { + ownerType: "near" | "passkey" | "evm"; + ownerAccountId: string; + contractId: string; + actions: WalletRequestMessage["request"]["external"][number]["actions"]; +}) { + const { ownerType, ownerAccountId, contractId, actions } = input; + const header = { + chain_id: getRuntime().network, + signer_id: ownerAccountId, + nonce: randomBytes(4).readUInt32LE(0), + created_at: new Date(Date.now() - 30_000).toISOString().replace(/\.\d{3}Z$/, "Z"), + // Ten minutes, the same window a NEAR delegate preparation stays relayable. + timeout_secs: 600, + }; + if (ownerType === "passkey") + return { + walletRequest: walletRequestMessageSchema.parse({ + ...header, + request: { external: [{ receiver_id: contractId, actions }] }, + }), + }; + const calls = actions.map((action) => { + if (action.action !== "function_call") throw new Error("policy_action_invalid"); + return action.payload; + }); + if (ownerType === "evm") + return { + walletRequest: evmWalletRequestMessageSchema.parse({ + ...header, + request: { + ops: [], + out: { + after: [], + // biome-ignore lint/suspicious/noThenProperty: Required wallet-contract request field. + then: [ + { + receiver_id: contractId, + actions: calls.map((call) => ({ + action: "function_call", + function_name: call.function_name, + args: call.args ?? "", + deposit: call.deposit ?? "0", + min_gas: call.gas ?? "0", + })), + }, + ], + }, + }, + }), + }; + return { + nearPolicyRequest: nearPolicyRequestSchema.parse({ + receiver_id: contractId, + actions: calls.map((call) => ({ + methodName: call.function_name, + argsBase64: call.args ?? "", + gas: call.gas ?? "0", + depositYocto: call.deposit ?? "0", + })), + }), + }; +} + +async function preparedProviderPolicy(input: { + ownerAccountId: string; + wallet: Awaited>["wallet"]; + policy: Policy; + frozenBefore: boolean; + beforeSign: () => Promise; +}) { + const { ownerAccountId, wallet, policy, frozenBefore, beforeSign } = input; + const outlayer = getOutlayer(); + const credential = custodyCredential(wallet); + const providerPolicy = policy; + const encrypted = await outlayer.encryptPolicy( + credential, + providerPolicy, + wallet.providerWalletId, + ); + await beforeSign(); + const signed = await outlayer.signPolicy(credential, { + caller: ownerAccountId, + encryptedData: encrypted.encrypted_base64, + }); + // Keys in PostgreSQL jsonb order (shorter first). Owners sign these args as a JSON object that + // wallets re-serialize with `JSON.stringify`; this order survives jsonb storage and JSON + // transport unchanged, so the bytes the wallet signs are the bytes prepared here. + const args = { + wallet_pubkey: `ed25519:${signed.public_key_hex}`, + encrypted_data: encrypted.encrypted_base64, + wallet_signature: signed.signature_hex, + }; + const argsBase64 = Buffer.from(JSON.stringify(args)).toString("base64"); + let deposit: string; + try { + deposit = await slot.get()( + nearProvider(), + outlayer.contractId, + ownerAccountId, + argsBase64, + getRuntime().walletPolicyStorageLimitYocto ?? "0", + true, + ); + } catch (error) { + if (error instanceof Error && error.message === "policy_owner_mismatch") + throw new ApiError("policy_owner_mismatch", 409); + throw error; + } + const gas = frozenBefore !== policy.frozen ? "50000000000000" : "100000000000000"; + const actions: WalletRequestMessage["request"]["external"][number]["actions"] = [ + { + action: "function_call", + payload: { function_name: "store_wallet_policy", args: argsBase64, deposit, gas }, + }, + ]; + if (frozenBefore !== policy.frozen) + actions.push({ + action: "function_call", + payload: { + function_name: policy.frozen ? "freeze_wallet" : "unfreeze_wallet", + args: Buffer.from(JSON.stringify({ wallet_pubkey: args.wallet_pubkey })).toString("base64"), + deposit: "0", + gas, + }, + }); + return { encrypted, signed, deposit, actions }; +} + +/** + * The custody wallet's freeze state as the provider holds it now. A bound wallet always has a + * stored policy, so anything but a readback for this exact wallet refuses the preparation. + * + * The newest local revision is only a proposal record: a failed or expired unfreeze leaves the + * earlier freeze in force, and `store_wallet_policy` preserves a freeze. The contract rejects a + * redundant freeze or unfreeze, so the exact transition is derived from this state, never assumed. + */ +async function providerFreezeState(wallet: BoundWallet) { + const outlayer = getOutlayer(); + const credential = custodyCredential(wallet); + const provider = await outlayer + .invalidatePolicyCache(credential, { walletId: wallet.providerWalletId }) + .then(() => outlayer.policy(credential)) + .catch(() => null); + if (provider?.wallet_id !== wallet.providerWalletId || typeof provider.frozen !== "boolean") + throw new ApiError("provider_policy_unavailable", 503); + return provider.frozen; +} + +/** + * Encrypts and custody-signs one policy revision, stores it as `signed` and returns the exact + * on-chain request its owner must sign. Shared by owner policy updates and agent onboarding. + */ +export async function stagePolicyRevision(input: { + tenantId: string; + agentId: string; + operationId: string; + ownerType: "near" | "passkey" | "evm"; + ownerAccountId: string; + wallet: Awaited>["wallet"]; + policy: Policy; + policyHash: string; + revision: number; + frozenBefore: boolean; +}) { + const { tenantId, agentId, operationId, ownerType, ownerAccountId, wallet, policy } = input; + const outlayer = getOutlayer(); + const { encrypted, signed, deposit, actions } = await preparedProviderPolicy({ + ownerAccountId, + wallet, + policy, + frozenBefore: input.frozenBefore, + beforeSign: () => assertDispatchFence(tenantId, agentId, operationId), + }); + const { walletRequest, nearPolicyRequest } = policyRequests({ + ownerType, + ownerAccountId, + contractId: outlayer.contractId, + actions, + }); + const policyId = newId(); + await assertDispatchFence(tenantId, agentId, operationId); + await insertWalletPolicy({ + id: policyId, + tenantId, + agentId, + walletId: wallet.providerWalletId, + version: input.revision + 1, + policyHash: input.policyHash, + encryptedData: encrypted.encrypted_base64, + signatureHex: signed.signature_hex, + publicKeyHex: signed.public_key_hex, + rules: policy, + status: "draft", + }); + return { + operation_id: operationId, + status: "pending_wallet_signature" as const, + policy_id: policyId, + wallet_id: wallet.providerWalletId, + policy_hash: input.policyHash, + expected_revision: input.revision, + transaction_hash: null, + submitted: false, + provider_policy_readback: false, + feePayer: "backend" as const, + controller_id: ownerAccountId, + receiver_id: outlayer.contractId, + gas: "100000000000000", + storage_deposit_yocto: deposit, + ...(walletRequest ? { wallet_request: walletRequest } : {}), + ...(nearPolicyRequest ? { near_policy_request: nearPolicyRequest } : {}), + authorization: "wallet_signature" as const, + enforced_by: "outlayer_contract" as const, + }; +} + +/** + * Stages one policy revision and returns the exact on-chain request the bound owner signs. + * + * No owner consent is collected up front: the owner's single signature over that request is the + * consent. It names this agent's custody wallet and the encrypted, custody-signed policy, so it + * cannot install anything else. `expectedRevision` rejects a concurrent edit before any provider + * call; the dispatch fence and the stored preparation bind the rest. + */ +export async function preparePolicyRevision( + actor: Actor, + agentId: string, + input: { policy: Policy; expectedRevision: number; idempotencyKey: string }, +) { + // A predecessor whose outcome is already decided is settled first, so the owner never has to + // refresh it by hand and its epoch change cannot make this admission stale. + await settleSupersededRevision(actor, agentId); + const { agent, wallet } = await requireBoundAgent(actor, agentId); + const ownerType = agent.ownerIdentity?.type; + if (ownerType !== "passkey" && ownerType !== "evm" && ownerType !== "near") + throw new ApiError("wallet_policy_signer_unsupported", 409); + const ownerAccountId = agent.ownerAccountId; + if (!ownerAccountId) throw new ApiError("agent_not_bound", 409); + const policy = policySchema.parse(input.policy); + assertPolicyWritable(policy); + if (policy.frozen && (await isArchived(actor.tenantId, agentId))) + throw new ApiError("agent_archived", 409); + const policyHash = hashSecret(canonical(policy)); + // Refuse a stale revision before admitting an operation; the locked check below still decides. + if (((await latestPolicyVersion(wallet.providerWalletId)) ?? 0) !== input.expectedRevision) + throw new ApiError("policy_revision_conflict", 409); + + return runOperation({ + actor, + agentId, + kind: "policy", + action: "policy", + idempotencyKey: input.idempotencyKey, + request: { policy_hash: policyHash, expected_revision: input.expectedRevision }, + authorizationEpochs: { + ownerEpoch: agent.ownerEpoch, + policyEpoch: agent.policyEpoch, + lifecycleEpoch: agent.lifecycleEpoch, + }, + run: async (operationId) => + withAdvisoryLock(`policy:${wallet.providerWalletId}`, async () => { + await assertDispatchFence(actor.tenantId, agentId, operationId); + const previous = await latestPolicy(actor.tenantId, agentId); + if (previous?.status === "signed") + await releaseExpiredPreparation(actor, agentId, previous.id); + const revision = (await latestPolicyVersion(wallet.providerWalletId)) ?? 0; + if (revision !== input.expectedRevision) + throw new ApiError("policy_revision_conflict", 409); + await assertDispatchFence(actor.tenantId, agentId, operationId); + const frozenBefore = await providerFreezeState(wallet); + return stagePolicyRevision({ + tenantId: actor.tenantId, + agentId, + operationId, + ownerType, + ownerAccountId, + wallet, + policy, + policyHash, + revision, + frozenBefore, + }); + }), + }); +} diff --git a/packages/agents-core/src/modules/wallet/owner-policy-reconciliation.ts b/packages/agents-core/src/modules/wallet/owner-policy-reconciliation.ts new file mode 100644 index 000000000..f33a30170 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/owner-policy-reconciliation.ts @@ -0,0 +1,312 @@ +import { type OwnerWallet, type Policy, policySchema } from "@near-intents-agent-api/contracts"; +import { getOutlayer } from "../../lib/outlayer.js"; +import { getOwnerPolicySponsor } from "../../lib/owner-policy-sponsor.js"; +import { getWalletSponsor } from "../../lib/wallet-sponsor.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { invalidatePolicyOwnerCache, nearPolicyOwnerMatches } from "../../shared/near.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { findOperation, findPolicyOperation } from "../operations/repository.js"; +import { finalizePolicyOperation } from "./policy-finalization.js"; +import { matchesProviderPolicy } from "./policy-readiness.js"; +import { + type PreparedPolicy, + preparedSchema, + releaseExpiredPreparation, +} from "./prepared-policy.js"; +import { type CustodyWalletRecord, latestPolicy } from "./repository.js"; +import { custodyCredential } from "./service.js"; +import { type StorageFunding, storedFunding } from "./storage-funding.js"; + +/** + * Policy reconciliation: observation only. + * + * Nothing here broadcasts. A finalized transaction is confirmed against provider readback and the + * on-chain owner before the revision is marked applied, so `applied` always means the provider + * agrees rather than that a transaction hash was returned. Unresolved evidence remains unresolved regardless of age. + */ + +async function reconcilePendingPolicy( + actor: Actor, + agentId: string, + operationId: string, + operation: NonNullable>>, + prepared: PreparedPolicy, +) { + if (!prepared.policy_id) return operation; + try { + await releaseExpiredPreparation(actor, agentId, prepared.policy_id); + } catch (error) { + if (error instanceof ApiError && error.code === "policy_reconciliation_required") + return operation; + throw error; + } + return (await findOperation(actor.tenantId, agentId, operationId)) ?? operation; +} + +/** + * A claimed revision persists its relay hash before broadcast, so one still without a hash this + * long after its last write never broadcast its policy transaction. Storage funding is a separate + * transfer journaled before its own broadcast; see `unrelayedPolicyOutcome`. + */ +export const unbroadcastClaimTtlMs = 10 * 60_000; + +/** Absence is evidence only after a broadcast had time to reach every endpoint. */ +const droppedProofDelayMs = 2 * 60_000; +/** Non-archival RPC nodes keep at least five epochs (about 2.5 days); stay well inside that. */ +const droppedProofWindowMs = 24 * 60 * 60_000; + +/** + * Provider caches can trail a final transaction. A succeeded write whose readback still differs + * this long after its broadcast was persisted is judged on that readback. + */ +export const readbackGraceMs = 10 * 60_000; + +type SponsorTransaction = { + transaction_hash?: string; + sponsor_account_id?: string; + sponsor_public_key?: string; + sponsor_nonce?: string; +}; + +/** + * Chain status of one sponsor transaction; unreachable RPC reads as still pending. + * + * `dropped` needs the sponsor nonce persisted at broadcast and an operation young enough that a + * transaction that did execute would still be retrievable. + */ +async function sponsorTransactionStatus( + ownerType: OwnerWallet["type"] | undefined, + transaction: SponsorTransaction, + updatedAt: Date, +) { + if (!transaction.transaction_hash) return "pending" as const; + const sponsor = ownerType === "near" ? getOwnerPolicySponsor() : getWalletSponsor()?.relaySigned; + if (!sponsor) return "pending" as const; + const age = Date.now() - updatedAt.getTime(); + const witness = + transaction.sponsor_public_key && + transaction.sponsor_nonce && + age >= droppedProofDelayMs && + age <= droppedProofWindowMs + ? { publicKey: transaction.sponsor_public_key, nonce: BigInt(transaction.sponsor_nonce) } + : undefined; + try { + return await sponsor.status( + transaction.transaction_hash, + transaction.sponsor_account_id, + witness, + ); + } catch { + return "pending" as const; + } +} + +/** Chain status of a relayed policy transaction. */ +export function policyTransactionStatus( + ownerType: OwnerWallet["type"] | undefined, + operation: { result: unknown; updatedAt: Date }, +) { + return sponsorTransactionStatus( + ownerType, + (operation.result ?? {}) as SponsorTransaction, + operation.updatedAt, + ); +} + +/** + * A claimed revision with no relay hash. Its storage funding, if any was journaled, is settled on + * chain first: while that transfer's outcome is unknown the operation stays uncertain, whatever + * the relay did. Once it is settled, the relay that never left is `not_broadcast` after the claim + * TTL, and the settled funding travels with the failure so it is never mistaken for no effect. + */ +export async function unrelayedPolicyOutcome( + ownerType: OwnerWallet["type"] | undefined, + operation: { result: unknown; updatedAt: Date }, +): Promise<{ outcome: "pending" } | { outcome: "not_broadcast"; funding?: StorageFunding }> { + let funding = storedFunding(operation.result); + if (funding?.status === "submitted") { + const status = await sponsorTransactionStatus(ownerType, funding, operation.updatedAt); + // Unavailable or incomplete evidence never proves funding settled. + if (status === "pending") return { outcome: "pending" }; + funding = { ...funding, status: status === "succeeded" ? "succeeded" : "failed" }; + } + if (Date.now() - operation.updatedAt.getTime() <= unbroadcastClaimTtlMs) + return { outcome: "pending" }; + return { outcome: "not_broadcast", funding }; +} + +/** + * Applied means both the finalized on-chain policy owner and the provider's decrypted readback + * match the signed revision, after dropping every cache that could answer with the old policy. + */ +export async function providerConfirmsPolicy(input: { + wallet: CustodyWalletRecord; + expectedOwner: string | null; + policy: Policy; +}) { + const outlayer = getOutlayer(); + invalidatePolicyOwnerCache(outlayer.contractId, input.wallet.nearAccountId); + const credential = custodyCredential(input.wallet); + await outlayer.invalidatePolicyCache(credential, { walletId: input.wallet.providerWalletId }); + const provider = await outlayer.policy(credential); + return ( + (await nearPolicyOwnerMatches({ + contractId: outlayer.contractId, + nearAccountId: input.wallet.nearAccountId, + expectedOwner: input.expectedOwner, + })) && matchesProviderPolicy(provider, input.wallet.providerWalletId, input.policy) + ); +} + +/** + * The claimed revision's outcome: the relay's chain status when it has a hash, otherwise whatever + * its journaled funding and the claim TTL prove. Unknown relay outcomes remain pending. + */ +export async function policyWriteOutcome( + ownerType: OwnerWallet["type"] | undefined, + operation: { result: unknown; updatedAt: Date }, +) { + const transactionHash = (operation.result as { transaction_hash?: string } | null) + ?.transaction_hash; + if (!transactionHash) return unrelayedPolicyOutcome(ownerType, operation); + const outcome = await policyTransactionStatus(ownerType, operation); + return { outcome, funding: undefined }; +} + +export const policyFailureCodes = { + failed: "policy_transaction_failed", + dropped: "policy_transaction_dropped", + not_broadcast: "policy_not_broadcast", + /** The relay succeeded, but what the provider holds is not the signed revision. */ + mismatch: "policy_readback_mismatch", +} as const; + +export type PolicySettlement = + | { settle: "wait" } + | { settle: "applied"; transactionHash: string } + | { + settle: "failed"; + failureCode: string; + funding?: StorageFunding; + /** The relay may have changed provider state, so work admitted before it must be fenced. */ + providerChanged: boolean; + }; + +/** + * How a claimed revision settles, from facts alone: + * + * - A relay that failed, was dropped or never left fails; nothing it signed took effect. + * - A relay that succeeded is applied once readback confirms the signed revision. If readback + * still differs after the grace period, what landed is not that revision: it fails, and the + * caller fences work admitted before it, since provider state may have changed. + * - An unavailable or incomplete chain result remains unresolved, at any age. + * + * `confirm` reads the provider back; it runs only once the transaction can no longer change. + * The owner corrects a failed revision by signing a new one, prepared from the provider's state. + */ +export async function settlePolicyWrite( + ownerType: OwnerWallet["type"] | undefined, + operation: { result: unknown; updatedAt: Date }, + confirm: () => Promise, +): Promise { + const write = await policyWriteOutcome(ownerType, operation); + if (write.outcome === "pending") return { settle: "wait" }; + if (write.outcome !== "succeeded") + return { + settle: "failed", + failureCode: policyFailureCodes[write.outcome], + funding: write.funding, + providerChanged: false, + }; + const transactionHash = (operation.result as { transaction_hash: string }).transaction_hash; + if (await confirm()) return { settle: "applied", transactionHash }; + if (Date.now() - operation.updatedAt.getTime() < readbackGraceMs) return { settle: "wait" }; + return { + settle: "failed", + failureCode: policyFailureCodes.mismatch, + providerChanged: true, + }; +} + +/** + * Settles the revision a new preparation would replace when its outcome is already decided, before + * that preparation is admitted: an applied predecessor advances the policy epoch, which would + * otherwise make the new admission stale. One still undecided is left for the preparation to + * refuse with `policy_reconciliation_required`. + */ +export async function settleSupersededRevision(actor: Actor, agentId: string) { + const previous = await latestPolicy(actor.tenantId, agentId); + if (previous?.status !== "signed") return; + const operation = await findPolicyOperation(actor.tenantId, agentId, previous.id); + if (operation?.status === "uncertain") await reconcileOwnerPolicy(actor, agentId, operation.id); +} + +/** Read only: provider and finalized onchain owner must both match signed revision. */ +export async function reconcileOwnerPolicy(actor: Actor, agentId: string, operationId: string) { + const { agent, wallet } = await requireBoundAgent(actor, agentId); + const operation = await findOperation(actor.tenantId, agentId, operationId); + if (operation?.kind !== "policy") throw new ApiError("operation_not_found", 404); + if (operation.status === "completed" || operation.status === "failed") return operation; + const prepared = preparedSchema.safeParse(operation?.result); + if (!prepared.success) throw new ApiError("operation_not_found", 404); + if (operation.status === "pending") + return reconcilePendingPolicy(actor, agentId, operationId, operation, prepared.data); + const settlement = await settlePolicyWrite(agent.ownerIdentity?.type, operation, () => + confirmedApplication(actor, agent.ownerAccountId, wallet, operation), + ); + if (settlement.settle === "wait") return operation; + const settle = { + tenantId: actor.tenantId, + agentId, + operationId, + policyId: prepared.data.policy_id, + observed: operation, + }; + const finalized = + settlement.settle === "applied" + ? await finalizePolicyOperation({ + ...settle, + outcome: { status: "applied", transactionHash: settlement.transactionHash }, + result: { + ...(operation.result as object), + ...prepared.data, + status: "applied", + transaction_hash: settlement.transactionHash, + provider_policy_readback: true, + }, + }) + : await finalizePolicyOperation({ + ...settle, + outcome: { + status: "failed", + failureCode: settlement.failureCode, + advanceEpoch: settlement.providerChanged, + }, + result: { + ...(operation.result as object), + ...(settlement.funding ? { storage_funding: settlement.funding } : {}), + status: "failed", + failure_code: settlement.failureCode, + }, + }); + return finalized ?? (await findOperation(actor.tenantId, agentId, operationId)) ?? operation; +} + +/** Whether the provider and on-chain owner both confirm the claimed revision. */ +async function confirmedApplication( + actor: Actor, + ownerAccountId: string | null, + wallet: CustodyWalletRecord, + operation: { agentId: string; result: unknown }, +) { + const result = operation.result as { policy_id: string }; + const record = await latestPolicy(actor.tenantId, operation.agentId); + if (!record || record.id !== result.policy_id) + throw new ApiError("policy_revision_conflict", 409); + return providerConfirmsPolicy({ + wallet, + expectedOwner: ownerAccountId, + policy: policySchema.parse(record.rules), + }); +} diff --git a/packages/agents-core/src/modules/wallet/owner-policy-submission.ts b/packages/agents-core/src/modules/wallet/owner-policy-submission.ts new file mode 100644 index 000000000..67fb10420 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/owner-policy-submission.ts @@ -0,0 +1,330 @@ +import type { + OnboardingSignature, + OwnerWallet, + SignedNearPolicy, + SignedWalletRequest, +} from "@near-intents-agent-api/contracts"; +import { + canonical, + evmWalletRequestMessageSchema, + walletRequestHash, + walletRequestMessageSchema, +} from "@near-intents-agent-api/contracts"; +import { + verifyEvmWalletRequest, + verifyPasskeyChallengeProof, +} from "@near-intents-agent-api/owner-auth"; +import { type NonceWitness, RelayError } from "@near-intents-agent-api/relayer"; +import { getRuntime } from "../../config/runtime.js"; +import { requireOwnerPolicySponsor } from "../../lib/owner-policy-sponsor.js"; +import { sponsorIdentity, withSponsorKey } from "../../lib/sponsor-pool.js"; +import { requireWalletSponsor } from "../../lib/wallet-sponsor.js"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { invalidatePolicyOwnerCache } from "../../shared/near.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { assertDispatchFence, commitDispatch } from "../operations/dispatch-fence.js"; +import { findOperation, persistOperationResult } from "../operations/repository.js"; +import { commitSponsorship, reserveSponsorship } from "../operations/sponsor-budget.js"; +import { parseNearPolicyDelegate } from "./near-policy-delegate.js"; +import { reconcileOwnerPolicy } from "./owner-policy-reconciliation.js"; +import { claimPreparedWalletPolicy } from "./policy-claim-service.js"; +import { commitPolicyFunding } from "./policy-funding.js"; +import { type PreparedPolicy, preparedSchema } from "./prepared-policy.js"; +import { type StorageFunding, storedFunding } from "./storage-funding.js"; + +/** + * Policy submission: relay only the request that was prepared for this exact revision and signed + * by the bound wallet. + * + * The preparation is the contract. A submission that does not match the stored preparation byte + * for byte is refused before any sponsor key is used, and a single atomic claim means one + * dispatcher owns the broadcast even across processes. + */ + +/** The identity a prepared request must be signed by: the bound owner, or the onboarding owner. */ +export type PolicySigner = { + ownerIdentity: OwnerWallet | null; + ownerAccountId: string | null; + ownerPublicKey: string | null; +}; + +/** A signed prepared request, as submitted by the owner's wallet. */ +export type SubmittedPolicy = SignedWalletRequest | SignedNearPolicy | OnboardingSignature; + +/** + * Verifies the owner's signature over the exact prepared request, locally and before any sponsor + * key is used. Returns the decoded NEAR delegate for NEAR owners. + */ +export function verifySubmittedPolicy( + agent: PolicySigner, + prepared: PreparedPolicy, + signed: SubmittedPolicy, +) { + if ( + agent.ownerIdentity?.type === "near" && + "signedDelegate" in signed && + prepared.near_policy_request + ) { + if (!agent.ownerAccountId || !agent.ownerPublicKey) throw new ApiError("agent_not_bound", 409); + return parseNearPolicyDelegate({ + signedDelegate: signed.signedDelegate, + ownerAccountId: agent.ownerAccountId, + ownerPublicKey: agent.ownerPublicKey, + prepared: prepared.near_policy_request, + }); + } + if ("msg" in signed && prepared.wallet_request) { + verifySignedWalletPolicy(agent, prepared.wallet_request, signed); + return undefined; + } + throw new ApiError("wallet_request_type_mismatch", 409); +} + +function verifySignedWalletPolicy( + agent: PolicySigner, + prepared: NonNullable, + signed: Pick, +) { + if (canonical(signed.msg) !== canonical(prepared)) + throw new ApiError("wallet_request_mismatch", 409); + const created = Date.parse(signed.msg.created_at); + if ( + !Number.isFinite(created) || + created > Date.now() || + Date.now() - created >= signed.msg.timeout_secs * 1000 + ) + throw new ApiError("wallet_request_expired", 409); + try { + if (agent.ownerIdentity?.type === "passkey" && "external" in signed.msg.request) + verifyPasskeyChallengeProof({ + owner: agent.ownerIdentity, + proofText: signed.proof, + challenge: walletRequestHash(walletRequestMessageSchema.parse(signed.msg)), + }); + else if (agent.ownerIdentity?.type === "evm" && "ops" in signed.msg.request) + verifyEvmWalletRequest({ + publicKey: agent.ownerIdentity.publicKey, + message: evmWalletRequestMessageSchema.parse(signed.msg), + proof: signed.proof, + }); + else throw new Error("wallet_request_type_mismatch"); + } catch { + throw new ApiError("wallet_request_signature_invalid", 401); + } +} + +/** Mutable funding state for one dispatch; `current` is what the operation has journaled. */ +type FundingState = { current?: StorageFunding }; + +/** A journaled transfer never observed final: it may or may not have paid. */ +const fundingUnresolved = (funding: FundingState) => funding.current?.status === "submitted"; + +type Funder = ( + receiverId: string, + amount: bigint, + journal: (transactionHash: string, witness?: NonceWitness) => Promise, +) => Promise; + +async function relayPreparedPolicy(input: { + actor: Actor; + agentId: string; + operationId: string; + ownerAccountId: string; + prepared: PreparedPolicy; + dispatch: PreparedPolicy; + nearSigned?: ReturnType; + signed: SubmittedPolicy; + funding: FundingState; +}) { + const { actor, agentId, operationId, ownerAccountId, prepared, dispatch, nearSigned, signed } = + input; + const amount = BigInt(prepared.storage_deposit_yocto); + if (amount > BigInt(getRuntime().walletPolicyStorageLimitYocto ?? "0")) + throw new ApiError("policy_deposit_limit", 409); + // Only a transfer observed final waives funding; a failed one is due again. + const fundingDue = input.funding.current?.status === "succeeded" ? 0n : amount; + const withFunding = () => + input.funding.current ? { ...dispatch, storage_funding: input.funding.current } : dispatch; + const fund = async (transfer: Funder, sponsorAccountId: string) => { + if (fundingDue === 0n) return; + await transfer(ownerAccountId, fundingDue, async (transactionHash, witness) => { + const journaled: StorageFunding = { + status: "submitted", + transaction_hash: transactionHash, + sponsor_account_id: sponsorAccountId, + sponsor_public_key: witness?.publicKey, + sponsor_nonce: witness?.nonce.toString(), + receiver_id: ownerAccountId, + amount_yocto: fundingDue.toString(), + }; + // The transfer's own commitment point, with the exact signed hash: current authority is + // re-checked and the journal committed before it can leave. If this refuses, it never does. + await commitPolicyFunding({ + tenantId: actor.tenantId, + agentId, + operationId, + policyId: prepared.policy_id, + funding: journaled, + result: dispatch, + }); + input.funding.current = journaled; + }); + input.funding.current = { ...(input.funding.current as StorageFunding), status: "succeeded" }; + }; + let signingIdentity: ReturnType; + const onBroadcast = async (transactionHash: string, witness?: NonceWitness) => { + signingIdentity = sponsorIdentity(); + await commitSponsorship(operationId); + await persistOperationResult(actor.tenantId, agentId, operationId, "policy", "uncertain", { + ...withFunding(), + status: "submitted", + transaction_hash: transactionHash, + // The signing key and nonce let reconciliation prove a hash that never appears was dropped. + sponsor_public_key: witness?.publicKey, + sponsor_nonce: witness?.nonce.toString(), + ...sponsorIdentity(), + }); + // This callback runs after transaction signing and immediately before provider broadcast. + await commitDispatch(actor.tenantId, agentId, operationId); + sponsorIdentity(); // Fail closed if the lock session died during persistence. + }; + if (nearSigned) { + const sponsor = requireOwnerPolicySponsor(); + return withSponsorKey(sponsor.accountId, async () => { + // As the delegate's relayer the sponsor pays its storage deposit directly, so a NEAR owner + // is never funded separately. + await sponsor.assertBalance(nearSigned.delegate); + return sponsor.submit(nearSigned.delegate, nearSigned.signatureHex, onBroadcast, () => + assertDispatchFence(actor.tenantId, agentId, operationId), + ); + }).then((outcome) => ({ ...outcome, ...signingIdentity })); + } + const sponsor = requireWalletSponsor(); + if (!("msg" in signed)) throw new ApiError("wallet_request_type_mismatch", 409); + return withSponsorKey(sponsor.accountId, async () => { + await assertDispatchFence(actor.tenantId, agentId, operationId); + // A `0s` wallet attaches the storage deposit from its own balance, so the sponsor funds it + // first. Funding and relay are paid together; refuse both before either can take effect. + await sponsor.relaySigned.assertBalance({ depositYocto: fundingDue, gas: 0n }); + await fund(sponsor.fundPolicyStorage, sponsor.accountId); + await assertDispatchFence(actor.tenantId, agentId, operationId); + return sponsor.relaySigned(signed.msg, signed.proof, onBroadcast); + }).then((outcome) => ({ ...outcome, ...signingIdentity })); +} + +/** + * Verifies, claims and relays one prepared request. `afterVerify` runs once the signature is + * proven locally and before anything is claimed, so a failure there leaves the request signable. + * On return the transaction is final and the operation records `submitted` with its hash. + */ +export async function dispatchPreparedPolicy(input: { + actor: Actor; + agentId: string; + operationId: string; + signer: PolicySigner; + prepared: PreparedPolicy; + /** Stored operation result; fields outside the preparation schema are persisted unchanged. */ + stored: unknown; + signed: SubmittedPolicy; + afterVerify?: () => Promise; + afterClaim?: () => Promise; +}) { + const { actor, agentId, operationId, signer, prepared, signed } = input; + if (!signer.ownerAccountId) throw new ApiError("agent_not_bound", 409); + const nearSigned = verifySubmittedPolicy(signer, prepared, signed); + await input.afterVerify?.(); + await reserveSponsorship(actor.tenantId, agentId, operationId); + await assertDispatchFence(actor.tenantId, agentId, operationId); + const dispatch = { ...(input.stored as object), ...prepared, status: "dispatching" as const }; + const funding: FundingState = { current: storedFunding(input.stored) }; + // A transfer never observed final may or may not have paid; reconciliation settles it first. + if (fundingUnresolved(funding)) throw new ApiError("storage_funding_unresolved", 409); + if (!(await claimPreparedWalletPolicy(actor.tenantId, agentId, operationId, dispatch))) + throw new ApiError("policy_not_pending", 409); + try { + await input.afterClaim?.(); + const outcome = await relayPreparedPolicy({ + actor, + agentId, + operationId, + ownerAccountId: signer.ownerAccountId, + prepared, + dispatch, + nearSigned, + signed, + funding, + }); + await commitSponsorship(operationId); + await persistOperationResult(actor.tenantId, agentId, operationId, "policy", "uncertain", { + ...dispatch, + ...(funding.current ? { storage_funding: funding.current } : {}), + status: "submitted", + transaction_hash: outcome.transactionHash, + sponsor_account_id: outcome.sponsor_account_id, + sponsor_public_key: outcome.sponsor_public_key, + }); + invalidatePolicyOwnerCache(); + } catch (error) { + if (refusedBeforeBroadcast(error) && !fundingUnresolved(funding)) { + // Keep the exact preparation signable, with any funding that completed; a transfer still + // unobserved keeps the operation uncertain below instead. + await persistOperationResult(actor.tenantId, agentId, operationId, "policy", "pending", { + ...(input.stored as object), + ...(funding.current ? { storage_funding: funding.current } : {}), + }); + throw error; + } + // A funding transfer or wallet call may have broadcast. Keep reservation and operation. + await commitSponsorship(operationId); + // A broadcast whose finality was not observed in time is not a failed request: the persisted + // hash stays `uncertain` and the caller's refresh reconciles it. + if (isFinalityTimeout(error)) return; + throw error; + } +} + +/** Stale delegates, a busy pool and an underfunded sponsor are refused before the relay leaves. */ +function refusedBeforeBroadcast(error: unknown) { + return ( + (error instanceof RelayError && + (error.code === "policy_delegate_expired" || + error.code === "sponsor_balance_insufficient")) || + (error instanceof ApiError && error.code === "sponsor_busy") + ); +} + +function isFinalityTimeout(error: unknown) { + return ( + error instanceof Error && (error as { code?: unknown }).code === "transaction_finality_timeout" + ); +} + +/** Relay only request prepared for this policy revision and signed by its bound wallet. */ +export async function submitOwnerPolicy( + actor: Actor, + agentId: string, + operationId: string, + signed: SignedWalletRequest | SignedNearPolicy, +) { + const { agent } = await requireBoundAgent(actor, agentId); + const operation = await findOperation(actor.tenantId, agentId, operationId); + const prepared = preparedSchema.safeParse(operation?.result); + if ( + operation?.kind !== "policy" || + operation.status !== "pending" || + !prepared.success || + prepared.data.status !== "pending_wallet_signature" + ) + throw new ApiError("policy_not_pending", 409); + await dispatchPreparedPolicy({ + actor, + agentId, + operationId, + signer: agent, + prepared: prepared.data, + stored: operation.result, + signed, + }); + return reconcileOwnerPolicy(actor, agentId, operationId); +} diff --git a/packages/agents-core/src/modules/wallet/policy-claim-service.ts b/packages/agents-core/src/modules/wallet/policy-claim-service.ts new file mode 100644 index 000000000..4d081c3ba --- /dev/null +++ b/packages/agents-core/src/modules/wallet/policy-claim-service.ts @@ -0,0 +1,107 @@ +import { + agents, + operations, + ownerIntents, + type Tx, + walletPolicies, +} from "@near-intents-agent-api/database"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import { ApiError } from "../../shared/errors.js"; +import { commitOwnerIntent } from "../intents/commit-service.js"; +import { lockOperation } from "../operations/repository.js"; +import { redactOperationResult } from "../operations/result-projection.js"; + +/** One process claims owner-signed OutLayer policy submission. */ +export async function claimPreparedWalletPolicy( + tenantId: string, + agentId: string, + id: string, + result: unknown, +) { + return getDatabase().transaction(async (tx) => { + // Same lock as artifact delivery/finalization: authorization becomes visible before dispatch. + await tx + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .for("update"); + const operation = await lockOperation(tx, tenantId, agentId, id); + if ( + operation?.status !== "pending" || + (operation.result as { status?: string })?.status !== "pending_wallet_signature" + ) + return false; + const [intent] = await tx + .select() + .from(ownerIntents) + .where(and(eq(ownerIntents.tenantId, tenantId), eq(ownerIntents.operationId, id))) + .for("update"); + if (intent?.state === "pending_signature") + await commitOwnerIntent(tx, tenantId, intent.id, null, "submitted"); + else if (intent && intent.state !== "submitted") throw new ApiError("intent_not_pending", 409); + await authorizePolicyDraft( + tx, + tenantId, + agentId, + (operation.result as { policy_id: string }).policy_id, + ); + await tx + .update(operations) + .set({ + status: "uncertain", + result: redactOperationResult(result, "policy"), + updatedAt: sql`clock_timestamp()`, + }) + .where( + and( + eq(operations.tenantId, tenantId), + eq(operations.agentId, agentId), + eq(operations.id, id), + ), + ); + return true; + }); +} + +async function authorizePolicyDraft(tx: Tx, tenantId: string, agentId: string, policyId: string) { + const [policy] = await tx + .select() + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.id, policyId), + ), + ) + .for("update"); + if (!policy || !["draft", "signed"].includes(policy.status)) + throw new ApiError("policy_not_pending", 409); + if (policy.status === "draft") { + const [latest] = await tx + .select() + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + inArray(walletPolicies.status, ["signed", "applied", "failed"]), + ), + ) + .orderBy(desc(walletPolicies.version)) + .limit(1); + if (latest?.status === "signed" || (latest?.version ?? 0) !== policy.version - 1) + throw new ApiError("policy_revision_conflict", 409); + await tx + .update(walletPolicies) + .set({ status: "signed" }) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.id, policyId), + ), + ); + } +} diff --git a/packages/agents-core/src/modules/wallet/policy-finalization.ts b/packages/agents-core/src/modules/wallet/policy-finalization.ts new file mode 100644 index 000000000..6391a8bf3 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/policy-finalization.ts @@ -0,0 +1,137 @@ +import { agents, type Tx } from "@near-intents-agent-api/database"; +import { and, eq } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import { ApiError } from "../../shared/errors.js"; +import { + finishPolicyOperationInTransaction, + lockOperation, + type OperationRecord, +} from "../operations/repository.js"; +import { + advancePolicyEpoch, + applyPolicyRecord, + failPolicyRecord, + latestPolicyIdInTransaction, + lockPolicyRecord, +} from "./repository.js"; + +export type PolicyOutcome = + | { status: "applied"; transactionHash: string } + | { + status: "failed"; + failureCode: string; + /** The failed write may still have changed provider state; see `advancePolicyEpoch`. */ + advanceEpoch?: boolean; + }; + +type Finalization = { + tenantId: string; + agentId: string; + operationId: string; + policyId: string; + observed: { updatedAt: Date }; + outcome: PolicyOutcome; + result: Record; + /** Onboarding binds the owner in the same transaction as the first application. */ + bindOwner?: (tx: Tx) => Promise; + /** Onboarding abandons the agent in the same transaction as the failure. */ + onFailed?: (tx: Tx) => Promise; +}; + +type Locked = { + operation: OperationRecord; + policy: NonNullable>>; +}; + +/** + * Settles a policy operation and its revision in one transaction. + * + * Observation (chain status, provider readback) happens before this, without locks. Here the + * current state is re-read under the dispatch lock order, agent → operation → revision, and the + * outcome is written only if the observation still describes it: + * + * - A terminal operation is returned as it is; nothing is written. + * - `applied` must name the relay hash the locked operation holds and the agent's newest revision. + * The epoch advances only on the revision's `signed → applied` transition, so a repeated or + * concurrent observation never changes it twice. A revision an earlier, interrupted finalization + * already applied is completed without applying it again. + * - `failed` must have been decided on the operation as it still is (same `updatedAt`); an applied + * revision is never failed. A failure whose write may still have changed provider state advances + * the epoch with it, on the same `signed → failed` transition. + * + * Returns null when the observation is stale; the caller re-reads the operation. + */ +export async function finalizePolicyOperation( + input: Finalization, +): Promise { + return getDatabase().transaction(async (tx) => { + const locked = await lockSettlement(tx, input); + if (!("policy" in locked)) return locked.operation; + return input.outcome.status === "applied" + ? settleApplied(tx, input, locked, input.outcome.transactionHash) + : settleFailed(tx, input, locked, input.outcome); + }); +} + +/** Agent → operation → revision. A terminal operation is returned without the revision. */ +async function lockSettlement( + tx: Tx, + input: Finalization, +): Promise { + const { tenantId, agentId } = input; + const [agent] = await tx + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .for("update"); + if (!agent) throw new ApiError("agent_not_found", 404); + const operation = await lockOperation(tx, tenantId, agentId, input.operationId); + if (!operation) throw new ApiError("operation_not_found", 404); + if (operation.status === "completed" || operation.status === "failed") return { operation }; + const policy = await lockPolicyRecord(tx, tenantId, agentId, input.policyId); + if (!policy) throw new ApiError("operation_not_found", 404); + return { operation, policy }; +} + +async function settleApplied( + tx: Tx, + input: Finalization, + { operation, policy }: Locked, + transactionHash: string, +) { + const { tenantId, agentId, policyId } = input; + const held = (operation.result as { transaction_hash?: unknown } | null)?.transaction_hash; + if (held !== transactionHash) return null; + if ((await latestPolicyIdInTransaction(tx, tenantId, agentId)) !== policyId) + throw new ApiError("policy_revision_conflict", 409); + if (policy.status !== "signed" && policy.status !== "applied") + throw new ApiError("policy_state_conflict", 409); + if (policy.status === "signed") { + if (input.bindOwner && !(await input.bindOwner(tx))) + throw new ApiError("onboarding_conflict", 409); + await applyPolicyRecord(tx, tenantId, agentId, policyId, transactionHash); + } + return finishPolicyOperationInTransaction(tx, operation, "completed", input.result); +} + +async function settleFailed( + tx: Tx, + input: Finalization, + { operation, policy }: Locked, + outcome: Extract, +) { + if (operation.updatedAt.getTime() !== input.observed.updatedAt.getTime()) return null; + // Applied is final: a failure decided from older evidence must not contradict it. + if (policy.status === "applied") throw new ApiError("policy_state_conflict", 409); + const failed = await failPolicyRecord( + tx, + input.tenantId, + input.agentId, + input.policyId, + outcome.failureCode, + ); + if (failed && outcome.advanceEpoch) + await advancePolicyEpoch(tx, input.tenantId, input.agentId, policy.rules); + await input.onFailed?.(tx); + return finishPolicyOperationInTransaction(tx, operation, "failed", input.result); +} diff --git a/packages/agents-core/src/modules/wallet/policy-funding.ts b/packages/agents-core/src/modules/wallet/policy-funding.ts new file mode 100644 index 000000000..be598b8af --- /dev/null +++ b/packages/agents-core/src/modules/wallet/policy-funding.ts @@ -0,0 +1,59 @@ +import { getDatabase } from "../../lib/db.js"; +import { ApiError } from "../../shared/errors.js"; +import { lockDispatchAuthority, refuseDispatch } from "../operations/dispatch-fence.js"; +import { journalPolicyFundingInTransaction } from "../operations/repository.js"; +import { type StorageFunding, storedFunding } from "./storage-funding.js"; + +/** + * The commitment point of a policy's storage-funding transfer, taken with its exact signed hash + * immediately before that transfer can broadcast. Funding is an effect of its own: the policy + * relay that follows takes a separate commitment, so a funded owner never authorizes that relay. + * + * Under the dispatch lock order (agent → delayed job → operation, then the operation's sponsor + * reservation) it re-checks the operation's admission authority and binds the transfer to the + * claimed revision: its policy id, receiver and deposit. The journal, the permanent budget charge + * and a `policy.funding_committed` audit event commit together. A revocation that commits first + * refuses the transfer; one that commits after cannot retract it, and the journal reports it. + * + * Only a transfer the stored result does not already account for may commit: an earlier one that + * succeeded or is still unresolved refuses, and the same hash again is idempotent. + */ +export async function commitPolicyFunding(input: { + tenantId: string; + agentId: string; + operationId: string; + policyId: string; + funding: StorageFunding; + result: Record; +}): Promise { + const { tenantId, agentId, operationId, funding } = input; + await getDatabase().transaction(async (tx) => { + const authority = await lockDispatchAuthority(tx, tenantId, agentId, operationId); + if ("refusal" in authority) refuseDispatch(authority.refusal); + const { operation } = authority; + const claimed = (operation.result ?? {}) as Record; + if ( + operation.kind !== "policy" || + operation.status !== "uncertain" || + claimed.status !== "dispatching" + ) + throw new ApiError("operation_not_pending", 409); + if ( + claimed.policy_id !== input.policyId || + claimed.controller_id !== funding.receiver_id || + claimed.storage_deposit_yocto !== funding.amount_yocto + ) + throw new ApiError("storage_funding_mismatch", 409); + const prior = storedFunding(claimed); + if ( + prior && + prior.status !== "failed" && + !(prior.status === "submitted" && prior.transaction_hash === funding.transaction_hash) + ) + throw new ApiError("storage_funding_unresolved", 409); + await journalPolicyFundingInTransaction(tx, operation, { + ...input.result, + storage_funding: funding, + }); + }); +} diff --git a/packages/agents-core/src/modules/wallet/policy-readiness.ts b/packages/agents-core/src/modules/wallet/policy-readiness.ts new file mode 100644 index 000000000..b0fa5d257 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/policy-readiness.ts @@ -0,0 +1,44 @@ +import { canonical, type Policy, policySchema } from "@near-intents-agent-api/contracts"; +import { getOutlayer } from "../../lib/outlayer.js"; +import { ApiError } from "../../shared/errors.js"; +import { nearPolicyOwnerMatches } from "../../shared/near.js"; +import { requireActiveAgent } from "../agents/lifecycle-service.js"; +import { requireBoundOwnerAgent } from "../agents/service.js"; +import { latestPolicy } from "./repository.js"; +import { custodyCredential, requireActiveWallet } from "./service.js"; + +/** Read back every signed field. Cache invalidation alone is not policy evidence. */ +export function matchesProviderPolicy(value: unknown, walletId: string, policy: Policy) { + if (!value || typeof value !== "object") return false; + const provider = value as Record; + return ( + provider.wallet_id === walletId && + provider.frozen === policy.frozen && + canonical(provider.rules ?? null) === canonical(policy.rules) && + canonical(provider.capabilities ?? null) === canonical(policy.capabilities) && + canonical(provider.approval ?? null) === canonical(policy.approval ?? null) + ); +} + +export async function requireReadyPolicy(tenantId: string, agentId: string) { + await requireActiveAgent(tenantId, agentId); + const policy = await latestPolicy(tenantId, agentId); + if (policy?.status !== "applied" || policy.failureReason) + throw new ApiError("policy_not_ready", 409); + const wallet = await requireActiveWallet(tenantId, agentId); + const owner = await requireBoundOwnerAgent(tenantId, agentId); + const parsed = policySchema.safeParse(policy.rules); + if (!parsed.success) throw new ApiError("policy_not_ready", 409); + const rules = parsed.data; + if (rules.frozen) throw new ApiError("agent_paused", 409); + const current = await getOutlayer().policy(custodyCredential(wallet)); + if ( + !matchesProviderPolicy(current, wallet.providerWalletId, rules) || + !(await nearPolicyOwnerMatches({ + contractId: getOutlayer().contractId, + nearAccountId: wallet.nearAccountId, + expectedOwner: owner.ownerAccountId, + })) + ) + throw new ApiError("provider_policy_mismatch", 409); +} diff --git a/packages/agents-core/src/modules/wallet/policy-service.ts b/packages/agents-core/src/modules/wallet/policy-service.ts new file mode 100644 index 000000000..17a70436b --- /dev/null +++ b/packages/agents-core/src/modules/wallet/policy-service.ts @@ -0,0 +1,96 @@ +import { + type LimitsView, + type PolicyHistory, + type PolicyView, + policySchema, +} from "@near-intents-agent-api/contracts"; +import { getOutlayer } from "../../lib/outlayer.js"; +import type { Actor } from "../../shared/actor.js"; +import { nearPolicyOwnerMatches } from "../../shared/near.js"; +import { requireBoundAgent, requireBoundTenantAgent } from "../agents/service.js"; +import { custodyCredential } from "../wallet/service.js"; +import { matchesProviderPolicy } from "./policy-readiness.js"; +import { latestPolicy, listPolicyVersions } from "./repository.js"; + +/** Exact provider rules and local application status; no fabricated local counters. */ +export async function readPolicyFor(tenantId: string, agentId: string): Promise { + const { agent, wallet } = await requireBoundTenantAgent(tenantId, agentId); + const record = await latestPolicy(tenantId, agentId); + const policy = record ? policySchema.parse(record.rules) : null; + const provider = + record?.status === "applied" ? await getOutlayer().policy(custodyCredential(wallet)) : null; + const synced = + provider !== null && + (await nearPolicyOwnerMatches({ + contractId: getOutlayer().contractId, + nearAccountId: wallet.nearAccountId, + expectedOwner: agent.ownerAccountId, + freshness: "cached", + })) && + record?.status === "applied" && + record.failureReason === null && + policy !== null && + matchesProviderPolicy(provider, wallet.providerWalletId, policy); + return { + wallet_id: wallet.providerWalletId, + policy_hash: record?.policyHash ?? null, + revision: record?.version ?? null, + status: record?.status ?? "none", + applied_at: record?.appliedAt?.toISOString() ?? null, + transaction_hash: record?.transactionHash ?? null, + provider_policy_synced: synced, + policy, + }; +} + +export async function readLimitsFor(tenantId: string, agentId: string): Promise { + const view = await readPolicyFor(tenantId, agentId); + const policy = view.policy; + return { + wallet_id: view.wallet_id, + frozen: policy?.frozen ?? null, + capabilities: policy?.capabilities ?? null, + limits: policy?.rules.limits ?? null, + rate_limit: policy?.rules.rate_limit ?? null, + addresses: policy?.rules.addresses ?? null, + allowed_tokens: policy?.rules.allowed_tokens ?? null, + transaction_types: policy?.rules.transaction_types ?? null, + approval: policy?.approval ?? null, + policy_synced: view.provider_policy_synced, + }; +} + +export async function readPolicy(actor: Actor, agentId: string): Promise { + return readPolicyFor(actor.tenantId, agentId); +} + +export async function readLimits(actor: Actor, agentId: string): Promise { + return readLimitsFor(actor.tenantId, agentId); +} + +export async function readPolicyHistory( + actor: Actor, + agentId: string, + query: { limit: number; beforeRevision?: number }, +): Promise { + await requireBoundAgent(actor, agentId); + const rows = await listPolicyVersions( + actor.tenantId, + agentId, + query.limit + 1, + query.beforeRevision, + ); + const page = rows.slice(0, query.limit); + return { + policies: page.map((record) => ({ + wallet_id: record.walletId, + policy_hash: record.policyHash, + revision: record.version, + status: record.status, + applied_at: record.appliedAt?.toISOString() ?? null, + transaction_hash: record.transactionHash, + policy: policySchema.parse(record.rules), + })), + next_before_revision: rows.length > query.limit ? (page.at(-1)?.version ?? null) : null, + }; +} diff --git a/packages/agents-core/src/modules/wallet/policy-settlement.ts b/packages/agents-core/src/modules/wallet/policy-settlement.ts new file mode 100644 index 000000000..ee0d4f659 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/policy-settlement.ts @@ -0,0 +1,202 @@ +import { agents, operations } from "@near-intents-agent-api/database"; +import { and, eq, inArray, isNull, lt, notExists, sql } from "drizzle-orm"; +import { databaseClock, getDatabase } from "../../lib/db.js"; +import type { Actor } from "../../shared/actor.js"; +import { errorFields, logger } from "../../shared/logger.js"; +import { refreshOnboarding } from "../agents/onboarding-service.js"; +import { abandonAgentInTransaction } from "../agents/repository.js"; +import { + finishPolicyOperationInTransaction, + lockOperation, + type OperationRecord, +} from "../operations/repository.js"; +import { reconcileOwnerPolicy } from "./owner-policy-reconciliation.js"; + +/** + * Policy settlement: no policy operation depends on a client refreshing it. + * + * Reconciliation is observation only and finalization re-checks everything under its locks, so + * running this beside client refreshes, or in several processes at once, is safe. Candidates are + * sampled at random, so a few that stay undecided can never starve the rest. Settling promptly + * also keeps a dropped transaction inside the window in which its nonce can prove the drop. + */ +const settlementBatchSize = 50; + +/** Settlement acts for the tenant that owns the operation; it signs and admits nothing. */ +const settlementActor = (tenantId: string): Actor => ({ tenantId, keyId: "policy-settlement" }); + +/** Open policy operations of agents that can still become or stay live, settled if decided. */ +export async function sweepUnsettledPolicies(): Promise { + const candidates = await getDatabase() + .select({ + tenantId: operations.tenantId, + agentId: operations.agentId, + id: operations.id, + status: operations.status, + result: operations.result, + updatedAt: operations.updatedAt, + lifecycle: agents.lifecycle, + }) + .from(operations) + .innerJoin( + agents, + and(eq(agents.tenantId, operations.tenantId), eq(agents.id, operations.agentId)), + ) + .where( + and( + eq(operations.kind, "policy"), + inArray(operations.status, ["pending", "uncertain"]), + inArray(agents.lifecycle, ["pending", "active", "archived"]), + lt(operations.updatedAt, sql`now() - interval '2 minutes'`), + ), + ) + .orderBy(sql`random()`) + .limit(settlementBatchSize); + let settled = 0; + for (const candidate of candidates) { + try { + if (await settle(candidate)) settled += 1; + } catch (error) { + logger.warn("policy_settlement_failed", { + operation_id: candidate.id, + ...errorFields(error), + }); + } + } + return settled; +} + +type Candidate = { + tenantId: string; + agentId: string; + id: string; + status: string; + result: unknown; + updatedAt: Date; + lifecycle: string; +}; + +async function settle(candidate: Candidate) { + const actor = settlementActor(candidate.tenantId); + const result = (candidate.result ?? {}) as { policy_id?: unknown; onboarding?: unknown }; + if (typeof result.policy_id !== "string") return failInterruptedPreparation(candidate); + if (result.onboarding === true) { + const { onboarding } = await refreshOnboarding(actor, candidate.agentId); + return onboarding.status === "applied" || onboarding.status === "failed"; + } + const operation = await reconcileOwnerPolicy(actor, candidate.agentId, candidate.id); + return operation.status === "completed" || operation.status === "failed"; +} + +const idleHourMs = 60 * 60_000; + +/** + * A claimed preparation that never stored its preparation stopped before any request existed for + * its owner to sign, so nothing it did can reach the chain. After the idle hour that live + * preparation work leaves room for, it fails; a revision it may have stored is released by the + * next preparation, and an agent it was onboarding is abandoned. + * + * The candidate was sampled without locks, so the decision is made again under the dispatch lock + * order, agent → operation, against the operation as it is now. A worker that resumed and stored + * its preparation, or any other write since the sample, makes this a no-op: cleanup never asserts + * an outcome the operation's own state machine would refuse. + */ +async function failInterruptedPreparation(candidate: Candidate) { + const { tenantId, agentId, id } = candidate; + return getDatabase().transaction(async (tx) => { + const [agent] = await tx + .select({ lifecycle: agents.lifecycle }) + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .for("update"); + if (!agent) return false; + const operation = await lockOperation(tx, tenantId, agentId, id); + if (!operation || !stillInterrupted(operation, candidate, await databaseClock(tx))) + return false; + await finishPolicyOperationInTransaction(tx, operation, "failed", { + status: "failed", + failure_code: "preparation_interrupted", + }); + if (agent.lifecycle === "pending") + await abandonAgentInTransaction(tx, tenantId, agentId, "onboarding_preparation_failed"); + return true; + }); +} + +/** Unchanged since sampled, idle for the hour, and holding no preparation or dispatch evidence. */ +function stillInterrupted(operation: OperationRecord, observed: Candidate, now: Date) { + const result = (operation.result ?? {}) as Record; + return ( + operation.kind === "policy" && + operation.status === "uncertain" && + operation.updatedAt.getTime() === observed.updatedAt.getTime() && + operation.updatedAt.getTime() < now.getTime() - idleHourMs && + typeof result.policy_id !== "string" && + typeof result.transaction_hash !== "string" && + result.storage_funding === undefined + ); +} + +/** + * An agent whose creation stopped before its onboarding operation was admitted has no request its + * owner could sign and never becomes live, so it is abandoned after the idle hour. + */ +export async function sweepUnstartedOnboardings(): Promise { + const stale = await getDatabase() + .select({ tenantId: agents.tenantId, id: agents.id }) + .from(agents) + .where(and(unstartedOnboarding(), notExists(onboardingOperation()))) + .limit(settlementBatchSize); + let abandoned = 0; + for (const agent of stale) + if (await abandonUnstartedOnboarding(agent.tenantId, agent.id)) abandoned += 1; + return abandoned; +} + +/** + * Admission inserts its operation under a key-share lock on the agent row (the foreign key), so + * once this holds the row, an admission that committed first is visible to the recheck below, and + * one that commits after meets an abandoned agent at its dispatch fence, before any provider call. + */ +async function abandonUnstartedOnboarding(tenantId: string, agentId: string) { + return getDatabase().transaction(async (tx) => { + const [agent] = await tx + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId), unstartedOnboarding())) + .for("update"); + if (!agent) return false; + const [admitted] = await tx + .select({ id: operations.id }) + .from(operations) + .where( + and( + eq(operations.tenantId, tenantId), + eq(operations.agentId, agentId), + eq(operations.kind, "policy"), + ), + ) + .limit(1); + if (admitted) return false; + return abandonAgentInTransaction(tx, tenantId, agentId, "onboarding_preparation_failed"); + }); +} + +const unstartedOnboarding = () => + and( + eq(agents.lifecycle, "pending"), + isNull(agents.ownerAccountId), + lt(agents.createdAt, sql`now() - interval '1 hour'`), + ); + +const onboardingOperation = () => + getDatabase() + .select({ id: operations.id }) + .from(operations) + .where( + and( + eq(operations.tenantId, agents.tenantId), + eq(operations.agentId, agents.id), + eq(operations.kind, "policy"), + ), + ); diff --git a/packages/agents-core/src/modules/wallet/policy-write-rules.ts b/packages/agents-core/src/modules/wallet/policy-write-rules.ts new file mode 100644 index 000000000..06a726145 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/policy-write-rules.ts @@ -0,0 +1,14 @@ +import type { Policy } from "@near-intents-agent-api/contracts"; +import { ApiError } from "../../shared/errors.js"; + +/** + * Refuses policy combinations OutLayer stores but then refuses on every matching operation, so an + * owner never signs a policy that cannot work. OutLayer evaluates the address rule on every + * operation, and shield and unshield name no recipient, so a whitelist refuses them with + * `policy_denied`, including the agent's own confidential balance. Only new writes are checked; + * stored policies are read as they are. + */ +export function assertPolicyWritable(policy: Policy) { + if (policy.capabilities.confidential.allowed && policy.rules.addresses?.mode === "whitelist") + throw new ApiError("policy_confidential_whitelist_conflict", 400); +} diff --git a/packages/agents-core/src/modules/wallet/prepared-policy.ts b/packages/agents-core/src/modules/wallet/prepared-policy.ts new file mode 100644 index 000000000..f0764e7bf --- /dev/null +++ b/packages/agents-core/src/modules/wallet/prepared-policy.ts @@ -0,0 +1,86 @@ +import { + evmWalletRequestMessageSchema, + nearPolicyRequestSchema, + policyOperationResultSchema, + walletRequestMessageSchema, +} from "@near-intents-agent-api/contracts"; +import { z } from "zod"; +import type { Actor } from "../../shared/actor.js"; +import { ApiError } from "../../shared/errors.js"; +import { findPolicyOperation } from "../operations/repository.js"; +import { finalizePolicyOperation } from "./policy-finalization.js"; +import { markPolicyFailed } from "./repository.js"; + +/** + * A stored policy preparation: the exact provider transaction its owner signs, persisted in the + * policy operation's result, and the release of one that can no longer be signed. + */ + +const preparedResultMetadataSchema = policyOperationResultSchema + .pick({ + operation_id: true, + expected_revision: true, + transaction_hash: true, + submitted: true, + provider_policy_readback: true, + feePayer: true, + controller_id: true, + receiver_id: true, + gas: true, + }) + .partial(); + +export const preparedSchema = z + .object({ + status: z.enum(["pending_wallet_signature", "dispatching", "submitted", "applied"]), + policy_id: z.string(), + wallet_id: z.string(), + policy_hash: z.string(), + storage_deposit_yocto: z.string().regex(/^[0-9]+$/), + wallet_request: z.union([walletRequestMessageSchema, evmWalletRequestMessageSchema]).optional(), + near_policy_request: nearPolicyRequestSchema.optional(), + }) + .extend(preparedResultMetadataSchema.shape) + .extend({ + sponsor_account_id: z.string().optional(), + sponsor_public_key: z.string().optional(), + authorization: z.literal("wallet_signature").optional(), + enforced_by: z.literal("outlayer_contract").optional(), + }); + +/** A stored policy preparation, as persisted in the operation result. */ +export type PreparedPolicy = z.infer; + +export async function releaseExpiredPreparation(actor: Actor, agentId: string, policyId: string) { + const operation = await findPolicyOperation(actor.tenantId, agentId, policyId); + const prepared = preparedSchema.safeParse(operation?.result); + const age = operation ? Date.now() - operation.updatedAt.getTime() : Infinity; + const expiresAt = + prepared.success && prepared.data.wallet_request + ? Date.parse(prepared.data.wallet_request.created_at) + + prepared.data.wallet_request.timeout_secs * 1000 + : operation + ? operation.updatedAt.getTime() + 600_000 + : 0; + // A revision without a live operation can never be relayed: a failed operation refuses every + // later dispatch commitment. Its revision is released instead of blocking the next one. + if (!operation || operation.status === "failed") { + await markPolicyFailed(actor.tenantId, agentId, policyId, "preparation_expired"); + return; + } + if (operation.status !== "pending" || age < 120_000 || Date.now() < expiresAt) + throw new ApiError("policy_reconciliation_required", 409); + await finalizePolicyOperation({ + tenantId: actor.tenantId, + agentId, + operationId: operation.id, + policyId, + observed: operation, + outcome: { status: "failed", failureCode: "preparation_expired" }, + result: { + ...(operation.result as object), + status: "failed", + failure_code: "preparation_expired", + }, + }); +} diff --git a/packages/agents-core/src/modules/wallet/query-projection.ts b/packages/agents-core/src/modules/wallet/query-projection.ts new file mode 100644 index 000000000..286dfdf30 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/query-projection.ts @@ -0,0 +1,44 @@ +import { isSecretFieldName } from "../operations/result-projection.js"; + +const maxDepth = 12; +const maxNodes = 10_000; +const limitMarker = "[redacted: provider query inspection limit reached]"; + +type Budget = { nodes: number }; + +function projectArray(value: unknown[], depth: number, budget: Budget): unknown[] { + const output: unknown[] = []; + for (const entry of value) { + if (budget.nodes >= maxNodes) { + output.push(limitMarker); + break; + } + output.push(projectValue(entry, depth + 1, budget)); + } + return output; +} + +function projectRecord(value: object, depth: number, budget: Budget) { + const entries: Array<[string, unknown]> = []; + for (const [key, field] of Object.entries(value)) { + if (budget.nodes >= maxNodes) { + entries.push(["result_redacted", true]); + break; + } + if (!isSecretFieldName(key)) entries.push([key, projectValue(field, depth + 1, budget)]); + } + return Object.fromEntries(entries); +} + +function projectValue(value: unknown, depth: number, budget: Budget): unknown { + budget.nodes += 1; + if (budget.nodes > maxNodes || depth > maxDepth) return limitMarker; + if (Array.isArray(value)) return projectArray(value, depth, budget); + if (value !== null && typeof value === "object") return projectRecord(value, depth, budget); + return value; +} + +/** Provider query payloads are untyped. Remove bearer-shaped fields before returning them. */ +export function projectWalletQueryData(data: Record | unknown[]) { + return projectValue(data, 0, { nodes: 0 }) as Record | unknown[]; +} diff --git a/packages/agents-core/src/modules/wallet/query-service.ts b/packages/agents-core/src/modules/wallet/query-service.ts new file mode 100644 index 000000000..a23f0b47e --- /dev/null +++ b/packages/agents-core/src/modules/wallet/query-service.ts @@ -0,0 +1,100 @@ +import type { WalletQuery, WalletQueryResult } from "@near-intents-agent-api/contracts"; +import { getOutlayer } from "../../lib/outlayer.js"; +import type { Actor } from "../../shared/actor.js"; +import { requireBoundAgent } from "../agents/service.js"; +import { projectWalletQueryData } from "./query-projection.js"; +import { custodyCredential } from "./service.js"; + +export async function queryWallet( + actor: Actor, + agentId: string, + input: WalletQuery, +): Promise { + const result = await queryWalletRaw(actor, agentId, input); + return { ...result, data: projectWalletQueryData(result.data) }; +} + +async function queryWalletRaw( + actor: Actor, + agentId: string, + input: WalletQuery, +): Promise { + const { wallet } = await requireBoundAgent(actor, agentId); + const credential = custodyCredential(wallet); + const provider = getOutlayer(); + const query = input.query; + switch (input.query) { + case "swap_quote": { + const { confidential, ...body } = input.request; + return { + query, + data: await provider.walletQuote( + confidential ? "confidential/swap/quote" : "intents/swap/quote", + credential, + body, + ), + }; + } + case "withdraw_preview": { + const { confidential, ...body } = input.request; + return { + query, + data: await provider.walletQuote( + confidential ? "confidential/withdraw/dry-run" : "intents/withdraw/dry-run", + credential, + body, + ), + }; + } + case "address": + return { + query, + data: await provider.walletRead( + { path: "address", params: { chain: input.chain } }, + credential, + ), + }; + case "requests": + return { + query, + data: await provider.walletRead( + { path: "requests", params: { limit: input.limit, type: input.type } }, + credential, + ), + }; + case "deposits": + return { + query, + data: await provider.walletRead( + { path: "intents/deposit/cross-chain/list", params: { limit: input.limit } }, + credential, + ), + }; + case "deposit_status": + return { + query, + data: await provider.walletRead( + { path: "intents/deposit/cross-chain/status", params: { id: input.id } }, + credential, + ), + }; + case "deposit_intent_status": + return { query, data: await provider.depositIntentStatus(credential, input.id) }; + case "deposit_history": + return { + query, + data: await provider.depositHistory(credential, { + limit: input.limit, + offset: input.offset, + }), + }; + default: { + const paths = { + confidential_balances: "confidential/balance", + pending_approvals: "pending_approvals", + audit: "audit", + } as const; + return { query, data: await provider.walletRead({ path: paths[input.query] }, credential) }; + } + } +} diff --git a/packages/agents-core/src/modules/wallet/repository.ts b/packages/agents-core/src/modules/wallet/repository.ts new file mode 100644 index 000000000..6c1b27b99 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/repository.ts @@ -0,0 +1,408 @@ +import type { Tx, WalletPolicyStatus } from "@near-intents-agent-api/database"; +import { + agents, + auditEvents, + custodyWallets, + operationArtifacts, + walletPolicies, +} from "@near-intents-agent-api/database"; +import { and, desc, eq, inArray, lt, notInArray, sql } from "drizzle-orm"; +import { getDatabase } from "../../lib/db.js"; +import type { SealedSecret } from "../../shared/secrets.js"; + +export type CustodyWalletRecord = typeof custodyWallets.$inferSelect; + +export async function findCustodyWallet(tenantId: string, agentId: string) { + return ( + await getDatabase() + .select() + .from(custodyWallets) + .where(and(eq(custodyWallets.tenantId, tenantId), eq(custodyWallets.agentId, agentId))) + )[0]; +} + +export async function insertProvisioningWallet(input: { + id: string; + tenantId: string; + agentId: string; + credential: SealedSecret; +}) { + return ( + await getDatabase() + .insert(custodyWallets) + .values({ + id: input.id, + tenantId: input.tenantId, + agentId: input.agentId, + providerWalletId: "", + nearAccountId: "", + evmAddress: "", + credentialCiphertext: input.credential.ciphertext, + credentialNonce: input.credential.nonce, + credentialKeyId: input.credential.keyId, + status: "provisioning", + }) + .onConflictDoNothing() + .returning({ id: custodyWallets.id }) + )[0]; +} + +export async function activateCustodyWallet(input: { + id: string; + tenantId: string; + providerWalletId: string; + nearAccountId: string; + evmAddress: string; + credential: SealedSecret; +}) { + await getDatabase().transaction(async (tx) => { + await tx + .update(custodyWallets) + .set({ + providerWalletId: input.providerWalletId, + nearAccountId: input.nearAccountId, + evmAddress: input.evmAddress, + credentialCiphertext: input.credential.ciphertext, + credentialNonce: input.credential.nonce, + credentialKeyId: input.credential.keyId, + status: "active", + failureReason: null, + }) + .where(eq(custodyWallets.id, input.id)); + await tx.insert(auditEvents).values({ + tenantId: input.tenantId, + action: "wallet.provisioned", + resourceId: input.providerWalletId, + }); + }); +} + +export async function failCustodyWallet(id: string) { + await getDatabase() + .update(custodyWallets) + .set({ status: "failed", failureReason: "wallet_provisioning_failed" }) + .where(eq(custodyWallets.id, id)); +} + +export async function markCustodyWalletDeletedInTransaction( + tx: Tx, + tenantId: string, + agentId: string, + walletId: string, +) { + // The custody credential is never needed again; erasing it retires the wallet even where the + // provider had nothing on chain to delete. + await tx + .update(custodyWallets) + .set({ status: "deleted", failureReason: null, credentialCiphertext: "", credentialNonce: "" }) + .where( + and( + eq(custodyWallets.tenantId, tenantId), + eq(custodyWallets.agentId, agentId), + eq(custodyWallets.providerWalletId, walletId), + ), + ); + // Deletion is authoritative state on the agent, not an inference from this audit row. + await tx + .update(agents) + .set({ lifecycle: "deleted", deletedAt: new Date() }) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))); + await tx + .insert(auditEvents) + .values({ tenantId, agentId, action: "agent.deleted", resourceId: walletId }); +} + +type EffectivePolicy = Omit & { + status: "signed" | "applied" | "failed"; +}; + +export async function latestPolicy( + tenantId: string, + agentId: string, +): Promise { + return ( + await getDatabase() + .select() + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + notInArray(walletPolicies.status, ["draft", "discarded"]), + ), + ) + .orderBy(desc(walletPolicies.version)) + .limit(1) + )[0] as EffectivePolicy | undefined; +} + +export async function latestPolicyVersion(walletId: string) { + return ( + await getDatabase() + .select({ version: walletPolicies.version }) + .from(walletPolicies) + .where( + and( + eq(walletPolicies.walletId, walletId), + notInArray(walletPolicies.status, ["draft", "discarded"]), + ), + ) + .orderBy(desc(walletPolicies.version)) + .limit(1) + )[0]?.version; +} + +export async function insertWalletPolicy(input: { + id: string; + tenantId: string; + agentId: string; + walletId: string; + version: number; + policyHash: string; + encryptedData: string; + signatureHex: string; + publicKeyHex: string; + rules: unknown; + status: WalletPolicyStatus; +}) { + await getDatabase().transaction(async (tx) => { + const [agent] = await tx + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.tenantId, input.tenantId), eq(agents.id, input.agentId))) + .for("update"); + if (!agent) throw new Error("wallet policy agent not found"); + await tx.insert(walletPolicies).values(input); + }); +} + +/** Row-locks one revision in the caller's transaction. */ +export async function lockPolicyRecord(tx: Tx, tenantId: string, agentId: string, id: string) { + const [policy] = await tx + .select({ id: walletPolicies.id, rules: walletPolicies.rules, status: walletPolicies.status }) + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.id, id), + ), + ) + .for("update"); + return policy; +} + +/** The newest revision of an agent's policy, read inside the caller's transaction. */ +export async function latestPolicyIdInTransaction(tx: Tx, tenantId: string, agentId: string) { + const [latest] = await tx + .select({ id: walletPolicies.id }) + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + notInArray(walletPolicies.status, ["draft", "discarded"]), + ), + ) + .orderBy(desc(walletPolicies.version)) + .limit(1); + return latest?.id; +} + +/** + * Fences work authorized against the previous provider policy, inside the caller's transaction, + * which holds the agent row lock: every operation admitted before is stale and must not dispatch. + * When the policy in question freezes the wallet, undelivered signing artifacts are dropped too. + */ +export async function advancePolicyEpoch( + tx: Tx, + tenantId: string, + agentId: string, + rules: unknown, +) { + await tx + .update(agents) + .set({ policyEpoch: sql`${agents.policyEpoch} + 1` }) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))); + if ( + rules !== null && + typeof rules === "object" && + (rules as { frozen?: unknown }).frozen === true + ) + await tx + .delete(operationArtifacts) + .where( + and( + eq(operationArtifacts.tenantId, tenantId), + eq(operationArtifacts.agentId, agentId), + inArray(operationArtifacts.action, [ + "near_message", + "evm_message", + "evm_typed_data", + "evm_transaction", + ]), + ), + ); +} + +/** + * Moves one signed revision to applied inside the caller's transaction, which holds the agent row + * lock. Only the `signed → applied` transition advances the policy epoch, records `policy.applied` + * and, for a freeze, drops undelivered signing artifacts; returns whether it happened, so a repeated + * application is a no-op instead of a second epoch change. + */ +export async function applyPolicyRecord( + tx: Tx, + tenantId: string, + agentId: string, + id: string, + transactionHash: string | null, +): Promise { + const applied = await tx + .update(walletPolicies) + .set({ + appliedAt: new Date(), + failureReason: null, + status: "applied", + transactionHash, + }) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.id, id), + eq(walletPolicies.status, "signed"), + ), + ) + .returning({ rules: walletPolicies.rules }); + const policy = applied[0]; + if (!policy) return false; + await advancePolicyEpoch(tx, tenantId, agentId, policy.rules); + await tx.insert(auditEvents).values({ + tenantId, + agentId, + action: "policy.applied", + resourceId: id, + }); + return true; +} + +/** + * Fails one still-signed revision inside the caller's transaction. An applied or already failed + * revision is left as it is; returns whether this call failed it. + */ +export async function failPolicyRecord( + tx: Tx, + tenantId: string, + agentId: string, + id: string, + reason: string, +): Promise { + const failed = await tx + .update(walletPolicies) + .set({ + failureReason: reason.slice(0, 200), + status: sql`case when ${walletPolicies.status} = 'draft' then 'discarded' else 'failed' end`, + }) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.id, id), + inArray(walletPolicies.status, ["draft", "signed"]), + ), + ) + .returning({ id: walletPolicies.id }); + return failed.length === 1; +} + +/** Fails a still-signed revision that has no operation to settle with it. */ +export async function markPolicyFailed( + tenantId: string, + agentId: string, + id: string, + reason: string, +) { + await getDatabase().transaction(async (tx) => { + const [agent] = await tx + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.tenantId, tenantId), eq(agents.id, agentId))) + .for("update"); + if (!agent) throw new Error("wallet policy agent not found"); + await failPolicyRecord(tx, tenantId, agentId, id, reason); + }); +} + +export async function latestAppliedPolicy(tenantId: string, agentId: string) { + return ( + await getDatabase() + .select() + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.status, "applied"), + ), + ) + .orderBy(desc(walletPolicies.version)) + .limit(1) + )[0]; +} + +export async function listPolicyVersions( + tenantId: string, + agentId: string, + limit: number, + beforeRevision?: number, +) { + return (await getDatabase() + .select() + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + notInArray(walletPolicies.status, ["draft", "discarded"]), + beforeRevision === undefined ? undefined : lt(walletPolicies.version, beforeRevision), + ), + ) + .orderBy(desc(walletPolicies.version)) + .limit(limit)) as EffectivePolicy[]; +} + +/** Audit record of a vote relayed to the provider; `voter` is the signing approver principal. */ +export async function recordApprovalVote(input: { + tenantId: string; + agentId: string; + approvalId: string; + actorKeyId: string; + voter: string; + requestHash: string; +}) { + await getDatabase().insert(auditEvents).values({ + tenantId: input.tenantId, + agentId: input.agentId, + action: "approval.voted", + resourceId: input.approvalId, + actorKeyId: input.actorKeyId, + actorOwnerId: input.voter, + requestHash: input.requestHash, + }); +} + +/** Exact proposal lookup; never use this to decide execution authority. */ +export async function findPolicyRecord(tenantId: string, agentId: string, id: string) { + const [row] = await getDatabase() + .select() + .from(walletPolicies) + .where( + and( + eq(walletPolicies.tenantId, tenantId), + eq(walletPolicies.agentId, agentId), + eq(walletPolicies.id, id), + ), + ); + return row; +} diff --git a/packages/agents-core/src/modules/wallet/service.ts b/packages/agents-core/src/modules/wallet/service.ts new file mode 100644 index 000000000..2f829a878 --- /dev/null +++ b/packages/agents-core/src/modules/wallet/service.ts @@ -0,0 +1,154 @@ +import type { Wallet } from "@near-intents-agent-api/contracts"; +import type { OutlayerWalletClient } from "@near-intents-agent-api/outlayer"; +import { getOutlayer } from "../../lib/outlayer.js"; +import { newId } from "../../shared/crypto.js"; +import { ApiError } from "../../shared/errors.js"; +import { isSupportedNearPolicyAccountId } from "../../shared/near.js"; +import { openSecret, type SecretContext, sealSecret } from "../../shared/secrets.js"; +import { + activateCustodyWallet, + type CustodyWalletRecord, + failCustodyWallet, + findCustodyWallet, + insertProvisioningWallet, +} from "./repository.js"; + +export function custodyCredential(wallet: CustodyWalletRecord) { + return openSecret( + { + ciphertext: wallet.credentialCiphertext, + keyId: wallet.credentialKeyId, + nonce: wallet.credentialNonce, + }, + custodyCredentialContext(wallet), + ); +} + +/** + * The credential is only valid for this exact tenant/agent/wallet row. `walletId` uses the + * immutable row id, not the provider wallet id: the provider id can be rewritten on re-register + * while the row stays the same. Binding the provider identity too means a relocated ciphertext + * fails authentication rather than decrypting into the wrong wallet context. + */ +function custodyCredentialContext(wallet: CustodyWalletRecord): SecretContext { + return { + schemaVersion: 1, + tenantId: wallet.tenantId, + agentId: wallet.agentId, + walletId: wallet.id, + providerId: wallet.providerWalletId || undefined, + purpose: "outlayer.custody_credential", + }; +} + +/** A resolved EVM address, or null. An unresolved address is not an address. */ +export function projectEvmAddress(value: string): string | null { + return /^0x[0-9a-f]{40}$/.test(value) ? value : null; +} + +export function toWallet(wallet: CustodyWalletRecord): Wallet { + return { + wallet_id: wallet.providerWalletId, + near_account_id: wallet.nearAccountId, + evm_address: projectEvmAddress(wallet.evmAddress), + }; +} + +export async function requireActiveWallet(tenantId: string, agentId: string) { + const wallet = await findCustodyWallet(tenantId, agentId); + if (!wallet) throw new ApiError("wallet_not_provisioned", 409); + if (wallet.status !== "active") throw new ApiError("wallet_not_active", 409); + return wallet; +} + +/** Registers one OutLayer custody wallet per agent. The owner wallet is never custody. */ +export async function provisionWallet(tenantId: string, agentId: string) { + const existing = await findCustodyWallet(tenantId, agentId); + if (existing?.status === "active") return existing; + + if (existing) throw new ApiError("wallet_provisioning_uncertain", 409); + const client = getOutlayer(); + const reservedId = newId(); + const reserved = await insertProvisioningWallet({ + id: reservedId, + tenantId, + agentId, + credential: sealSecret("pending", { + schemaVersion: 1, + tenantId, + agentId, + walletId: reservedId, + purpose: "outlayer.custody_credential", + }), + }); + if (!reserved) { + const winner = await findCustodyWallet(tenantId, agentId); + if (!winner) throw new ApiError("wallet_conflict", 409); + if (winner.status !== "active") throw new ApiError("wallet_provisioning_uncertain", 409); + return winner; + } + + try { + const registered = await client.registerWallet(); + if (!isSupportedNearPolicyAccountId(registered.near_account_id)) + throw new Error("unsupported_custody_near_account_id"); + // Persist the only copy of the credential before any optional provider call. + const evm = ""; + await activateCustodyWallet({ + id: reserved.id, + tenantId, + providerWalletId: registered.wallet_id, + nearAccountId: registered.near_account_id, + evmAddress: evm, + credential: sealSecret(registered.api_key, { + schemaVersion: 1, + tenantId, + agentId, + walletId: reserved.id, + providerId: registered.wallet_id, + purpose: "outlayer.custody_credential", + }), + }); + const evmAddress = await resolveEvmAddress(client, registered.api_key); + if (evmAddress) + await activateCustodyWallet({ + id: reserved.id, + tenantId, + providerWalletId: registered.wallet_id, + nearAccountId: registered.near_account_id, + evmAddress, + credential: sealSecret(registered.api_key, { + schemaVersion: 1, + tenantId, + agentId, + walletId: reserved.id, + providerId: registered.wallet_id, + purpose: "outlayer.custody_credential", + }), + }); + } catch { + await failCustodyWallet(reserved.id); + throw new ApiError("wallet_provisioning_failed", 502); + } + + const wallet = await findCustodyWallet(tenantId, agentId); + if (!wallet) throw new ApiError("wallet_conflict", 409); + return wallet; +} + +/** + * Resolves the wallet's EVM address. A provider outage must not permanently record an empty + * address, so this retries a bounded number of times and returns `""` only after exhausting them; + * `toWallet` projects an empty value as `null` and the address can be filled in by re-provisioning. + */ +async function resolveEvmAddress(client: OutlayerWalletClient, apiKey: string) { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const evm = await client.address(apiKey, "ethereum"); + return /^0x[0-9a-fA-F]{40}$/.test(evm.address) ? evm.address.toLowerCase() : ""; + } catch { + if (attempt < 2) await new Promise((done) => setTimeout(done, 500 * (attempt + 1))); + } + } + return ""; +} diff --git a/packages/agents-core/src/modules/wallet/storage-funding.ts b/packages/agents-core/src/modules/wallet/storage-funding.ts new file mode 100644 index 000000000..e9bf0022c --- /dev/null +++ b/packages/agents-core/src/modules/wallet/storage-funding.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +/** + * Policy storage funding: a plain sponsor transfer to the owner account that precedes the policy + * relay, as a separate transaction with its own outcome. + * + * It is journaled with its signed hash and nonce before it can be broadcast, so a transfer whose + * finality was never observed stays on record for reconciliation instead of disappearing with the + * error. `submitted` means "may have been paid", never "paid": only `succeeded` waives funding for + * a later relay of the same preparation, and an unresolved transfer blocks the policy phase from + * being closed as if nothing happened. + */ +export const storageFundingSchema = z.object({ + status: z.enum(["submitted", "succeeded", "failed"]), + transaction_hash: z.string().max(128), + sponsor_account_id: z.string().min(2).max(64), + sponsor_public_key: z.string().max(128).optional(), + sponsor_nonce: z + .string() + .regex(/^[0-9]{1,20}$/) + .optional(), + receiver_id: z.string().max(64), + amount_yocto: z.string().regex(/^[0-9]{1,40}$/), +}); + +export type StorageFunding = z.infer; + +/** The funding journaled in a stored policy operation result, if any. */ +export function storedFunding(result: unknown): StorageFunding | undefined { + const parsed = storageFundingSchema.safeParse( + (result as { storage_funding?: unknown } | null)?.storage_funding, + ); + return parsed.success ? parsed.data : undefined; +} diff --git a/packages/agents-core/src/shared/actor.ts b/packages/agents-core/src/shared/actor.ts new file mode 100644 index 000000000..54d1e4a25 --- /dev/null +++ b/packages/agents-core/src/shared/actor.ts @@ -0,0 +1,4 @@ +export type Actor = { + tenantId: string; + keyId: string; +}; diff --git a/packages/agents-core/src/shared/audit-retention.ts b/packages/agents-core/src/shared/audit-retention.ts new file mode 100644 index 000000000..36bb66ca6 --- /dev/null +++ b/packages/agents-core/src/shared/audit-retention.ts @@ -0,0 +1,38 @@ +import { sql } from "drizzle-orm"; +import { getDatabase } from "../lib/db.js"; + +const auditRetentionDays = 90; +const sweepLimit = 500; + +/** Preserve live authorization evidence, unsettled effects and explicit holds. */ +export async function sweepOldAuditEvents(): Promise { + const removed = await getDatabase().execute<{ id: number }>(sql` + WITH candidates AS ( + SELECT e.id + FROM audit_events e + WHERE e.created_at < now() - ${auditRetentionDays} * interval '1 day' + AND NOT EXISTS ( + SELECT 1 FROM audit_retention_holds h + WHERE h.tenant_id = e.tenant_id + AND h.released_at IS NULL + AND (h.agent_id IS NULL OR h.agent_id = e.agent_id) + ) + AND NOT EXISTS ( + SELECT 1 FROM operation_artifacts a + WHERE a.tenant_id = e.tenant_id AND a.agent_id = e.agent_id + AND (a.operation_id = e.resource_id OR a.grant_id = e.grant_id) + ) + AND NOT EXISTS ( + SELECT 1 FROM operations o + WHERE o.tenant_id = e.tenant_id AND o.agent_id = e.agent_id + AND o.id = e.resource_id + AND o.status IN ('pending', 'uncertain') + ) + ORDER BY e.id + LIMIT ${sweepLimit} + FOR UPDATE OF e SKIP LOCKED + ) + DELETE FROM audit_events e USING candidates c WHERE e.id = c.id RETURNING e.id + `); + return removed.rows.length; +} diff --git a/packages/agents-core/src/shared/commit-effect.ts b/packages/agents-core/src/shared/commit-effect.ts new file mode 100644 index 000000000..9604bf6e5 --- /dev/null +++ b/packages/agents-core/src/shared/commit-effect.ts @@ -0,0 +1,4 @@ +import type { Tx } from "@near-intents-agent-api/database"; + +/** Records a caller's outcome in the transaction that commits its domain effect. */ +export type CommitEffect = (tx: Tx, result: T) => Promise; diff --git a/packages/agents-core/src/shared/crypto.ts b/packages/agents-core/src/shared/crypto.ts new file mode 100644 index 000000000..6a6f6ee7b --- /dev/null +++ b/packages/agents-core/src/shared/crypto.ts @@ -0,0 +1,13 @@ +import { createHash, randomBytes } from "node:crypto"; + +export function hashSecret(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +export function newId() { + return randomBytes(32).toString("hex"); +} + +export function newApiToken() { + return `naa_${randomBytes(32).toString("base64url")}`; +} diff --git a/packages/agents-core/src/shared/errors.ts b/packages/agents-core/src/shared/errors.ts new file mode 100644 index 000000000..b77da46cc --- /dev/null +++ b/packages/agents-core/src/shared/errors.ts @@ -0,0 +1,8 @@ +export class ApiError extends Error { + constructor( + public code: string, + public status: 400 | 401 | 403 | 404 | 409 | 410 | 429 | 502 | 503 = 400, + ) { + super(code); + } +} diff --git a/packages/agents-core/src/shared/history-retention.ts b/packages/agents-core/src/shared/history-retention.ts new file mode 100644 index 000000000..c15b78a35 --- /dev/null +++ b/packages/agents-core/src/shared/history-retention.ts @@ -0,0 +1,64 @@ +import { sql } from "drizzle-orm"; +import { getDatabase } from "../lib/db.js"; + +const retentionDays = 90; +const batchSize = 500; + +/** Retire finished timelock metadata before its terminal operation detail. */ +export async function sweepTerminalDelayedExecutions(): Promise { + const removed = await getDatabase().execute<{ id: string }>(sql` + WITH candidates AS ( + SELECT d.tenant_id, d.agent_id, d.id + FROM delayed_executions d + JOIN operations o ON o.tenant_id = d.tenant_id AND o.agent_id = d.agent_id AND o.id = d.id + WHERE d.state IN ('finished', 'cancelled') + AND o.status IN ('completed', 'failed') + AND d.updated_at < now() - ${retentionDays} * interval '1 day' + AND NOT EXISTS ( + SELECT 1 FROM audit_retention_holds h + WHERE h.tenant_id = d.tenant_id AND h.released_at IS NULL + AND (h.agent_id IS NULL OR h.agent_id = d.agent_id) + ) + ORDER BY d.updated_at + LIMIT ${batchSize} + FOR UPDATE OF d SKIP LOCKED + ) + DELETE FROM delayed_executions d USING candidates c + WHERE d.tenant_id = c.tenant_id AND d.agent_id = c.agent_id AND d.id = c.id + RETURNING d.id + `); + return removed.rows.length; +} + +/** Expired/revoked grant detail can go once no live work needs it. */ +export async function sweepExpiredGrants(): Promise { + const removed = await getDatabase().execute<{ id: string }>(sql` + WITH candidates AS ( + SELECT g.tenant_id, g.agent_id, g.id + FROM agent_grants g + WHERE GREATEST(g.expires_at, COALESCE(g.revoked_at, g.expires_at)) + < now() - ${retentionDays} * interval '1 day' + AND NOT EXISTS ( + SELECT 1 FROM operation_artifacts a + WHERE a.tenant_id = g.tenant_id AND a.agent_id = g.agent_id AND a.grant_id = g.id + ) + AND NOT EXISTS ( + SELECT 1 FROM operations o + WHERE o.tenant_id = g.tenant_id AND o.agent_id = g.agent_id + AND o.authorized_grant_id = g.id + ) + AND NOT EXISTS ( + SELECT 1 FROM audit_retention_holds h + WHERE h.tenant_id = g.tenant_id AND h.released_at IS NULL + AND (h.agent_id IS NULL OR h.agent_id = g.agent_id) + ) + ORDER BY g.expires_at + LIMIT ${batchSize} + FOR UPDATE OF g SKIP LOCKED + ) + DELETE FROM agent_grants g USING candidates c + WHERE g.tenant_id = c.tenant_id AND g.agent_id = c.agent_id AND g.id = c.id + RETURNING g.id + `); + return removed.rows.length; +} diff --git a/packages/agents-core/src/shared/logger.ts b/packages/agents-core/src/shared/logger.ts new file mode 100644 index 000000000..e3ac34782 --- /dev/null +++ b/packages/agents-core/src/shared/logger.ts @@ -0,0 +1,64 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +const levelRank: Record = { debug: 10, info: 20, warn: 30, error: 40 }; +let minimumLevel: LogLevel = "debug"; + +export function configureLogger(level: LogLevel): void { + minimumLevel = level; +} + +export type LogFields = Record; + +/** One JSON line per event so a request, its errors and its provider calls share a request id. */ +export function log(level: LogLevel, event: string, fields: LogFields = {}): void { + if (levelRank[level] < levelRank[minimumLevel]) return; + const line = { level, time: new Date().toISOString(), event, ...fields }; + const message = JSON.stringify(line); + if (level === "error") { + console.error(message); + return; + } + if (level === "warn") { + console.warn(message); + return; + } + console.log(message); +} + +export const logger = { + debug: (event: string, fields?: LogFields) => log("debug", event, fields), + info: (event: string, fields?: LogFields) => log("info", event, fields), + warn: (event: string, fields?: LogFields) => log("warn", event, fields), + error: (event: string, fields?: LogFields) => log("error", event, fields), +}; + +/** Identifier-shaped values only: class names and typed codes, never free text. */ +const stableIdentifier = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; + +function stable(value: unknown): string | undefined { + return typeof value === "string" && stableIdentifier.test(value) ? value : undefined; +} + +/** + * Diagnosable without leaking: the error class name and any typed code (`ApiError.code`, + * `OutlayerError.code`, a Node system code) or RPC error type. Messages and provider bodies can + * carry secrets and are never logged. + */ +export function errorFields(error: unknown): LogFields { + if (!(error instanceof Error)) return { error_kind: "thrown_value" }; + const { code, type } = error as { code?: unknown; type?: unknown }; + const name = stable(error.name); + const errorCode = stable(code); + const errorType = stable(type); + return { + error_kind: "exception", + ...(name ? { error_name: name } : {}), + ...(errorCode ? { error_code: errorCode } : {}), + ...(errorType ? { error_type: errorType } : {}), + }; +} + +/** Stable per-request id so demo logs, API logs and provider calls can be correlated. */ +export function requestId(): string { + return crypto.randomUUID(); +} diff --git a/packages/agents-core/src/shared/metrics.ts b/packages/agents-core/src/shared/metrics.ts new file mode 100644 index 000000000..b176b34ee --- /dev/null +++ b/packages/agents-core/src/shared/metrics.ts @@ -0,0 +1,89 @@ +import { getOutlayer } from "../lib/outlayer.js"; +import { sponsorBalanceMetrics } from "../lib/sponsor-balance.js"; +import { sponsorPoolMetrics } from "../lib/sponsor-pool.js"; + +type RouteMetrics = { count: number; errors: number; totalMs: number; maxMs: number }; + +const routes = new Map(); +const counters = new Map(); +const MAX_ROUTE_METRICS = 128; +const MAX_COUNTER_METRICS = 128; +const OVERFLOW_ROUTE = "OTHER "; +const OVERFLOW_COUNTER = "other"; +const UNMATCHED_ROUTE = ""; +const MAX_ROUTE_TEMPLATE_LENGTH = 192; +const HTTP_METHODS = new Set([ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE", +]); + +/** Build a metric/log label from Hono's registered route template, never the request URL. */ +export function httpRouteLabel(method: string, routeTemplate?: string): string { + const normalizedMethod = HTTP_METHODS.has(method) ? method : "OTHER"; + const registeredPath = routeTemplate === "/*" ? undefined : routeTemplate; + const normalizedPath = + registeredPath?.startsWith("/") && registeredPath.length <= MAX_ROUTE_TEMPLATE_LENGTH + ? registeredPath + : UNMATCHED_ROUTE; + return `${normalizedMethod} ${normalizedPath}`; +} + +/** + * In-process counters exposed as JSON from `/metrics`. + * + * This is deliberately dependency-free: one replica's numbers, scraped by whatever the platform + * already runs. A process restart resets them, which is acceptable for latency/error visibility + * and avoids pretending these are durable business metrics. + */ +export function recordHttpRequest( + method: string, + routeTemplate: string | undefined, + status: number, + durationMs: number, +) { + const route = httpRouteLabel(method, routeTemplate); + const routeKey = + routes.has(route) || routes.size < MAX_ROUTE_METRICS - 1 ? route : OVERFLOW_ROUTE; + const entry = routes.get(routeKey) ?? { count: 0, errors: 0, totalMs: 0, maxMs: 0 }; + entry.count += 1; + entry.totalMs += durationMs; + entry.maxMs = Math.max(entry.maxMs, durationMs); + if (status >= 500 || status === 429) entry.errors += 1; + routes.set(routeKey, entry); +} + +export function recordCounter(name: string, amount = 1) { + const safeName = /^[a-zA-Z0-9_.-]{1,96}$/.test(name) ? name : OVERFLOW_COUNTER; + const counterKey = + counters.has(safeName) || counters.size < MAX_COUNTER_METRICS - 1 ? safeName : OVERFLOW_COUNTER; + const increment = Number.isFinite(amount) ? amount : 0; + counters.set(counterKey, (counters.get(counterKey) ?? 0) + increment); +} + +export function metricsSnapshot() { + const http = [...routes.entries()] + .map(([route, entry]) => ({ + route, + count: entry.count, + errors: entry.errors, + avg_ms: Math.round(entry.totalMs / entry.count), + max_ms: entry.maxMs, + })) + .sort((a, b) => b.count - a.count); + return { + sponsor_pool: sponsorPoolMetrics(), + sponsor_balance: sponsorBalanceMetrics(), + observed_at: new Date().toISOString(), + process: { uptime_ms: Math.round(process.uptime() * 1000) }, + provider: { contract_id: getOutlayer().contractId }, + counters: Object.fromEntries(counters), + http: http.slice(0, 50), + }; +} diff --git a/packages/agents-core/src/shared/near-rpc.ts b/packages/agents-core/src/shared/near-rpc.ts new file mode 100644 index 000000000..727b5da09 --- /dev/null +++ b/packages/agents-core/src/shared/near-rpc.ts @@ -0,0 +1,19 @@ +import { nearRpcRetryOptions } from "@near-intents-agent-api/relayer"; +import { FailoverRpcProvider, JsonRpcProvider } from "near-api-js"; +import { getRuntime } from "../config/runtime.js"; + +let cached: { urls: string; provider: FailoverRpcProvider } | undefined; + +/** One failover provider per configured RPC list; a reconfigured runtime gets a fresh one. */ +export function nearRpcProvider(): FailoverRpcProvider { + const urls = getRuntime().nearRpcUrls; + const key = urls.join(","); + if (cached?.urls !== key) + cached = { + urls: key, + provider: new FailoverRpcProvider( + urls.map((url) => new JsonRpcProvider({ url }, nearRpcRetryOptions)), + ), + }; + return cached.provider; +} diff --git a/packages/agents-core/src/shared/near.ts b/packages/agents-core/src/shared/near.ts new file mode 100644 index 000000000..4edb640ea --- /dev/null +++ b/packages/agents-core/src/shared/near.ts @@ -0,0 +1,292 @@ +import { JsonRpcProvider, PublicKey } from "near-api-js"; +import { verifyMessage } from "near-api-js/nep413"; +import { getRuntime } from "../config/runtime.js"; +import { defaultedSlot } from "../lib/slot.js"; +import { ApiError } from "./errors.js"; +import { nearRpcProvider } from "./near-rpc.js"; + +export type NearKeyVerifier = { + verify(input: { + accountId: string; + publicKey: string; + message: string; + nonceHex: string; + recipient: string; + signatureHex: string; + }): Promise; +}; + +const liveVerifier: NearKeyVerifier = { + async verify(input) { + const nonce = Buffer.from(input.nonceHex, "hex"); + if (nonce.length !== 32) throw new ApiError("invalid_nonce"); + let signerPublicKey: PublicKey; + try { + signerPublicKey = PublicKey.from(input.publicKey); + } catch { + throw new ApiError("invalid_public_key"); + } + let transportError: unknown; + for (const url of getRuntime().nearRpcUrls) { + try { + await verifyMessage({ + signerAccountId: input.accountId, + signerPublicKey, + payload: { message: input.message, nonce, recipient: input.recipient }, + signature: Buffer.from(input.signatureHex, "hex"), + provider: new JsonRpcProvider({ url }), + }); + return; + } catch (error) { + if (!isRpcTransportFailure(error)) throw error; + transportError = error; + } + } + throw transportError; + }, +}; + +const slot = defaultedSlot(liveVerifier); + +export function nearProvider() { + // Bounded retry budget per endpoint, then failover to the next configured RPC. + return nearRpcProvider(); +} + +type NearWalletPolicyView = { owner?: unknown }; + +export type NearPolicyOwnerVerifier = { + verify(input: { contractId: string; nearAccountId: string }): Promise; +}; + +export type PolicyOwnerFreshness = "fresh" | "cached"; + +export function isSupportedNearPolicyAccountId(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +const livePolicyOwnerVerifier: NearPolicyOwnerVerifier = { + async verify({ contractId, nearAccountId }) { + const policy = await nearProvider().callFunction({ + contractId, + method: "get_wallet_policy", + args: { wallet_pubkey: `ed25519:${nearAccountId}` }, + blockQuery: { finality: "final" }, + }); + return policy && typeof policy.owner === "string" ? policy.owner : null; + }, +}; + +const policyOwnerVerifierSlot = defaultedSlot(livePolicyOwnerVerifier); + +export type NearAccountReader = { exists(accountId: string): Promise }; + +/** + * One endpoint at a time, like the key verifier: the failover provider retries every error, + * including a definitive "account does not exist", and then reports only `RetriesExceeded`. + */ +const liveAccountReader: NearAccountReader = { + async exists(accountId) { + let transportError: unknown; + for (const url of getRuntime().nearRpcUrls) { + try { + await new JsonRpcProvider({ url }).viewAccount({ + accountId, + blockQuery: { finality: "final" }, + }); + return true; + } catch (error) { + if (error instanceof Error && error.name === "AccountDoesNotExistError") return false; + if (!isRpcTransportFailure(error)) throw error; + transportError = error; + } + } + throw transportError; + }, +}; + +const accountReaderSlot = defaultedSlot(liveAccountReader); + +/** Tests may replace the chain reader explicitly; production always uses finalized NEAR RPC. */ +export function configureNearAccountReader(next: NearAccountReader | undefined) { + accountReaderSlot.set(next); +} + +/** Whether a NEAR account exists at final finality. An unfunded implicit account does not. */ +export function nearAccountExists(accountId: string): Promise { + return accountReaderSlot.get().exists(accountId); +} + +/** Tests may replace the chain reader explicitly; production always uses finalized NEAR RPC. */ +export function configureNearPolicyOwnerVerifier(next: NearPolicyOwnerVerifier | undefined) { + policyOwnerVerifierSlot.set(next); + invalidatePolicyOwnerCache(); +} + +/** Short-lived cache used only by callers that explicitly tolerate stale read projections. */ +const policyOwnerTtlMs = 30_000; +const policyOwnerCache = new Map(); +let policyOwnerCacheRevision = 0; +let policyOwnerCacheSweep: ReturnType | undefined; + +function schedulePolicyOwnerCacheSweep() { + if (policyOwnerCacheSweep || policyOwnerCache.size === 0) return; + let nextExpiry = Number.POSITIVE_INFINITY; + for (const entry of policyOwnerCache.values()) { + nextExpiry = Math.min(nextExpiry, entry.expiresAt); + } + policyOwnerCacheSweep = setTimeout( + () => { + policyOwnerCacheSweep = undefined; + const now = Date.now(); + for (const [key, entry] of policyOwnerCache) { + if (entry.expiresAt <= now) policyOwnerCache.delete(key); + } + schedulePolicyOwnerCacheSweep(); + }, + Math.max(0, nextExpiry - Date.now()), + ); + policyOwnerCacheSweep.unref(); +} + +/** Drops cached ownership, e.g. after a policy write has been confirmed on chain. */ +export function invalidatePolicyOwnerCache(contractId?: string, nearAccountId?: string) { + policyOwnerCacheRevision += 1; + if (!contractId || !nearAccountId) { + policyOwnerCache.clear(); + } else { + policyOwnerCache.delete(`${contractId}\u0000${nearAccountId}`); + } + if (policyOwnerCache.size === 0 && policyOwnerCacheSweep) { + clearTimeout(policyOwnerCacheSweep); + policyOwnerCacheSweep = undefined; + } +} + +export async function nearPolicyOwner( + contractId: string, + nearAccountId: string, + freshness: PolicyOwnerFreshness = "fresh", +) { + if (!isSupportedNearPolicyAccountId(nearAccountId)) return null; + const key = `${contractId}\u0000${nearAccountId}`; + const cached = policyOwnerCache.get(key); + if (freshness === "cached" && cached && cached.expiresAt > Date.now()) return cached.value; + const cacheRevision = policyOwnerCacheRevision; + const owner = await policyOwnerVerifierSlot.get().verify({ contractId, nearAccountId }); + // A chain write may invalidate while this read is in flight. Do not repopulate stale evidence. + if (cacheRevision === policyOwnerCacheRevision) { + policyOwnerCache.set(key, { value: owner, expiresAt: Date.now() + policyOwnerTtlMs }); + schedulePolicyOwnerCacheSweep(); + } + return owner; +} + +/** Verifies custody ownership from the chain. Provider-reported controller is never authority. */ +export async function nearPolicyOwnerMatches(input: { + contractId: string; + nearAccountId: string; + expectedOwner: string | null; + freshness?: PolicyOwnerFreshness; +}) { + if (!isSupportedNearPolicyAccountId(input.nearAccountId) || !input.expectedOwner) return false; + const owner = await nearPolicyOwner(input.contractId, input.nearAccountId, input.freshness); + return owner !== null && owner === input.expectedOwner; +} + +export function configureNearVerifier(next: NearKeyVerifier | undefined) { + slot.set(next); +} + +export type NearAccessKeyVerifier = { + /** Resolves when `publicKey` is a live FullAccess key of `accountId` at final finality. */ + verifyFullAccess(input: { accountId: string; publicKey: string }): Promise; +}; + +const liveAccessKeyVerifier: NearAccessKeyVerifier = { + async verifyFullAccess({ accountId, publicKey }) { + let permission: unknown; + try { + permission = ( + await nearProvider().viewAccessKey({ + accountId, + publicKey, + finalityQuery: { finality: "final" }, + }) + ).permission; + } catch (error) { + if (isRpcTransportFailure(error)) throw new ApiError("owner_verification_unavailable", 502); + throw new ApiError("owner_key_not_full_access", 403); + } + if (permission !== "FullAccess") throw new ApiError("owner_key_not_full_access", 403); + }, +}; + +const accessKeyVerifierSlot = defaultedSlot(liveAccessKeyVerifier); + +/** Tests may replace the access-key reader; production reads finalized NEAR RPC. */ +export function configureNearAccessKeyVerifier(next: NearAccessKeyVerifier | undefined) { + accessKeyVerifierSlot.set(next); +} + +/** A named NEAR owner must sign with a live FullAccess key; checked before any sponsor spend. */ +export function verifyNearFullAccessKey(accountId: string, publicKey: string) { + return accessKeyVerifierSlot.get().verifyFullAccess({ accountId, publicKey }); +} + +/** + * Verifies a NEP-413 owner proof and requires the named full-access key on the named + * account. The account name alone never proves ownership. + * + * A signature that does not verify is `owner_proof_invalid` (401). An RPC that never answered is + * a dependency failure (502): conflating the two turns a transient outage into a permanent-looking + * "invalid signature" for every owner. Missing account/key on chain stays 401, because that is a + * real property of the proof, not a transport problem. + */ +export async function verifyNearOwnerProof(input: { + accountId: string; + publicKey: string; + message: string; + nonceHex: string; + signatureHex: string; + recipient: string; +}) { + try { + await slot.get().verify(input); + } catch (error) { + if (error instanceof ApiError) throw error; + const message = error instanceof Error ? error.message : ""; + if (message.includes("FullAccess")) throw new ApiError("owner_key_not_full_access", 403); + if (isRpcTransportFailure(error)) throw new ApiError("owner_verification_unavailable", 502); + throw new ApiError("owner_proof_invalid", 401); + } +} + +/** + * Distinguishes "the chain answered" from "the chain never answered". + * + * `near-api-js` raises `RpcError` subclasses for protocol-level answers (including + * `UNKNOWN_ACCOUNT`), `ProviderError` for HTTP failures and `TypedError` for exhausting its own + * retries. `ProviderError` is not publicly exported and does not survive a bundled `instanceof` + * intact, so this classifies structurally: a transport failure is an error with no RPC status, + * carrying a numeric HTTP code and no protocol `type` of its own. + */ +export function isRpcTransportFailure(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const value = error as { name?: unknown; type?: unknown; cause?: unknown }; + if (value.type === "RetriesExceeded") return true; + if (value.name === "ProviderError") return true; + // ProviderError sets `cause` to the HTTP status number. + if (typeof value.cause === "number") return true; + const message = (error as { message?: unknown }).message; + if (typeof message === "string") { + return ( + message.includes("Timeout error") || + message.includes("Bad Gateway") || + message.includes("unavailable") || + message.includes("Internal server error") || + /failed to fetch|fetch failed|network/i.test(message) + ); + } + return false; +} diff --git a/packages/agents-core/src/shared/nonces.ts b/packages/agents-core/src/shared/nonces.ts new file mode 100644 index 000000000..f0f229914 --- /dev/null +++ b/packages/agents-core/src/shared/nonces.ts @@ -0,0 +1,84 @@ +import { ownerNonces, type Tx } from "@near-intents-agent-api/database"; +import { and, eq, sql } from "drizzle-orm"; +import { getDatabase } from "../lib/db.js"; +import { newId } from "./crypto.js"; +import { ApiError } from "./errors.js"; + +const NONCE_TTL_MS = 5 * 60 * 1000; +const CLEANUP_BATCH = 500; + +export async function issueOwnerNonce(tenantId: string, agentId: string) { + const nonce = newId(); + await getDatabase() + .insert(ownerNonces) + .values({ + nonce, + tenantId, + agentId, + expiresAt: new Date(Date.now() + NONCE_TTL_MS), + }); + return nonce; +} + +/** Atomically consumes a single-use owner nonce. Replays and expired nonces fail closed. */ +export async function consumeOwnerNonce(tenantId: string, agentId: string, nonce: string) { + const consumed = await getDatabase() + .delete(ownerNonces) + .where( + and( + eq(ownerNonces.nonce, nonce), + eq(ownerNonces.tenantId, tenantId), + eq(ownerNonces.agentId, agentId), + sql`${ownerNonces.expiresAt} > now()`, + ), + ) + .returning({ nonce: ownerNonces.nonce }); + if (!consumed.length) throw new ApiError("owner_nonce_invalid", 409); +} + +/** Consumes a nonce inside the state transition transaction for owner admin commands. */ +export async function consumeOwnerNonceInTransaction( + tx: Tx, + tenantId: string, + agentId: string, + nonce: string, +) { + const consumed = await tx + .delete(ownerNonces) + .where( + and( + eq(ownerNonces.nonce, nonce), + eq(ownerNonces.tenantId, tenantId), + eq(ownerNonces.agentId, agentId), + sql`${ownerNonces.expiresAt} > now()`, + ), + ) + .returning({ nonce: ownerNonces.nonce }); + if (!consumed.length) throw new ApiError("owner_nonce_invalid", 409); +} + +/** + * Deletes expired nonces in bounded batches. + * + * This used to run on every challenge as an unbounded `DELETE ... WHERE expires_at < ...`, + * which scanned the table and could delete an unbounded number of rows inside the request that + * needed the nonce. Cleanup is now a scheduled sweep: correctness never depends on it, because + * `consumeOwnerNonce` already rejects expired rows. + */ +export async function sweepExpiredOwnerNonces(): Promise { + let deletedTotal = 0; + for (;;) { + const deleted = await getDatabase() + .delete(ownerNonces) + .where( + sql`${ownerNonces.nonce} in ( + select ${ownerNonces.nonce} from ${ownerNonces} + where ${ownerNonces.expiresAt} < now() - interval '10 minutes' + limit ${CLEANUP_BATCH} + )`, + ) + .returning({ nonce: ownerNonces.nonce }); + deletedTotal += deleted.length; + if (deleted.length < CLEANUP_BATCH) return deletedTotal; + } +} diff --git a/packages/agents-core/src/shared/owner-message.ts b/packages/agents-core/src/shared/owner-message.ts new file mode 100644 index 000000000..251d024e4 --- /dev/null +++ b/packages/agents-core/src/shared/owner-message.ts @@ -0,0 +1,74 @@ +import { getRuntime } from "../config/runtime.js"; + +/** + * Every owner-signed envelope repeats the same three bindings and the same freshness window. + * These are predicates, not throwers: each ceremony answers a stale or mismatched message with + * its own error code and status, and those codes are part of the wire contract. + */ + +/** Skew allowance for a message issued slightly ahead of this server's clock. */ +export const ownerMessageGraceMs = 60_000; +/** Longest window an owner challenge may authorize; a signed consent is not standing authority. */ +export const ownerMessageMaxLifetimeMs = 300_000; + +type OwnerEnvelope = { + tenant_id: string; + agent_id: string; + network: string; +}; + +type OwnerLifetime = { + issued_at_ms: number; + expires_at_ms: number; +}; + +type OwnerBinding = { + account_id?: string | null; + public_key?: string | null; +}; + +/** Tenant, agent and network must all match the resource the ceremony is about. */ +export function ownerEnvelopeMismatch( + message: OwnerEnvelope, + tenantId: string, + agentId: string, +): boolean { + return ( + message.tenant_id !== tenantId || + message.agent_id !== agentId || + message.network !== getRuntime().network + ); +} + +/** The signed message binds the bound owner's custody account and key. */ +export function ownerKeyMismatch( + message: OwnerBinding, + agent: { ownerAccountId: string | null; ownerPublicKey: string | null }, +): boolean { + return message.account_id !== agent.ownerAccountId || message.public_key !== agent.ownerPublicKey; +} + +export function ownerMessageExpired(message: OwnerLifetime, now: number): boolean { + return message.expires_at_ms <= now; +} + +export function ownerMessageIssuedInFuture(message: OwnerLifetime, now: number): boolean { + return message.issued_at_ms > now + ownerMessageGraceMs; +} + +/** A window that never closes, or one longer than a challenge may authorize. */ +export function ownerMessageLifetimeInvalid(message: OwnerLifetime): boolean { + return ( + message.expires_at_ms <= message.issued_at_ms || + message.expires_at_ms - message.issued_at_ms > ownerMessageMaxLifetimeMs + ); +} + +/** Expired, issued in the future, or a malformed window: the full freshness rule. */ +export function ownerMessageWindowInvalid(message: OwnerLifetime, now: number): boolean { + return ( + ownerMessageIssuedInFuture(message, now) || + ownerMessageExpired(message, now) || + ownerMessageLifetimeInvalid(message) + ); +} diff --git a/packages/agents-core/src/shared/owner-proof.ts b/packages/agents-core/src/shared/owner-proof.ts new file mode 100644 index 000000000..9719af1dd --- /dev/null +++ b/packages/agents-core/src/shared/owner-proof.ts @@ -0,0 +1,40 @@ +import type { OwnerWallet } from "@near-intents-agent-api/contracts"; +import { + createOwnerProofVerifier, + OwnerAuthError, + type OwnerMessage, + type OwnerProof, +} from "@near-intents-agent-api/owner-auth"; +import { ApiError } from "./errors.js"; +import { verifyNearOwnerProof } from "./near.js"; + +export { assertOwnerIdentity } from "@near-intents-agent-api/owner-auth"; + +const verifier = createOwnerProofVerifier({ + verifyNear: { + verify: async (input) => { + try { + await verifyNearOwnerProof(input); + } catch (error) { + if (error instanceof ApiError) + throw new OwnerAuthError(error.code, error.status as 400 | 401 | 403 | 409); + throw error; + } + }, + }, +}); + +/** Public credential metadata comes from the partner site; private keys never enter this API. */ +export async function verifyOwnerProof(input: { + owner: OwnerWallet; + message: OwnerMessage; + proof: OwnerProof; + counter?: number; +}): Promise { + try { + return await verifier.verify(input); + } catch (error) { + if (error instanceof OwnerAuthError) throw new ApiError(error.code, error.status); + throw error; + } +} diff --git a/packages/agents-core/src/shared/secret-rotation.ts b/packages/agents-core/src/shared/secret-rotation.ts new file mode 100644 index 000000000..50117d75c --- /dev/null +++ b/packages/agents-core/src/shared/secret-rotation.ts @@ -0,0 +1,126 @@ +import { + custodyWallets, + delayedExecutions, + operationArtifacts, +} from "@near-intents-agent-api/database"; +import { and, count, eq, isNotNull, isNull, ne, or } from "drizzle-orm"; +import { getRuntime } from "../config/runtime.js"; +import { getDatabase } from "../lib/db.js"; +import { openSecret, type SecretContext, sealSecret } from "./secrets.js"; + +const batchSize = 100; + +/** Rewrap one locked batch. A failed decrypt aborts the transaction without dropping ciphertext. */ +export async function rewrapSecretsBatch(): Promise<{ wallets: number; jobs: number }> { + const active = getRuntime().secretEncryptionActiveKeyId ?? "env:v1"; + return getDatabase().transaction(async (tx) => { + const wallets = await tx + .select() + .from(custodyWallets) + .where(ne(custodyWallets.credentialKeyId, active)) + .limit(batchSize) + .for("update", { skipLocked: true }); + for (const wallet of wallets) { + const context: SecretContext = { + schemaVersion: 1, + tenantId: wallet.tenantId, + agentId: wallet.agentId, + walletId: wallet.id, + providerId: wallet.providerWalletId || undefined, + purpose: "outlayer.custody_credential", + }; + const value = openSecret( + { + ciphertext: wallet.credentialCiphertext, + nonce: wallet.credentialNonce, + keyId: wallet.credentialKeyId, + }, + context, + ); + const sealed = sealSecret(value, context); + await tx + .update(custodyWallets) + .set({ + credentialCiphertext: sealed.ciphertext, + credentialNonce: sealed.nonce, + credentialKeyId: sealed.keyId, + }) + .where(eq(custodyWallets.id, wallet.id)); + } + + const jobs = await tx + .select() + .from(delayedExecutions) + .where( + and( + isNotNull(delayedExecutions.ciphertext), + or(isNull(delayedExecutions.keyId), ne(delayedExecutions.keyId, active)), + ), + ) + .limit(batchSize) + .for("update", { skipLocked: true }); + for (const job of jobs) { + if (!job.ciphertext || !job.nonce || !job.keyId) throw new Error("timelock_payload_missing"); + const context: SecretContext = { + schemaVersion: 1, + tenantId: job.tenantId, + agentId: job.agentId, + walletId: "", + purpose: "delayed_execution", + operationId: job.id, + grantId: job.grantId, + ownerEpoch: job.ownerEpoch, + }; + const sealed = sealSecret( + openSecret({ ciphertext: job.ciphertext, nonce: job.nonce, keyId: job.keyId }, context), + context, + ); + await tx + .update(delayedExecutions) + .set({ ciphertext: sealed.ciphertext, nonce: sealed.nonce, keyId: sealed.keyId }) + .where( + and( + eq(delayedExecutions.tenantId, job.tenantId), + eq(delayedExecutions.agentId, job.agentId), + eq(delayedExecutions.id, job.id), + ), + ); + } + + return { wallets: wallets.length, jobs: jobs.length }; + }); +} + +/** Old key material must remain available until every count reaches zero. */ +export async function staleSecretCounts() { + const active = getRuntime().secretEncryptionActiveKeyId ?? "env:v1"; + const [walletRows, jobRows, artifactRows] = await Promise.all([ + getDatabase() + .select({ total: count() }) + .from(custodyWallets) + .where(ne(custodyWallets.credentialKeyId, active)), + getDatabase() + .select({ total: count() }) + .from(delayedExecutions) + .where( + and( + isNotNull(delayedExecutions.ciphertext), + or(isNull(delayedExecutions.keyId), ne(delayedExecutions.keyId, active)), + ), + ), + getDatabase() + .select({ total: count() }) + .from(operationArtifacts) + .where( + and( + isNotNull(operationArtifacts.ciphertext), + or(isNull(operationArtifacts.keyId), ne(operationArtifacts.keyId, active)), + ), + ), + ]); + return { + wallets: walletRows[0]?.total ?? 0, + jobs: jobRows[0]?.total ?? 0, + artifacts: artifactRows[0]?.total ?? 0, + }; +} diff --git a/packages/agents-core/src/shared/secrets.ts b/packages/agents-core/src/shared/secrets.ts new file mode 100644 index 000000000..c23504caa --- /dev/null +++ b/packages/agents-core/src/shared/secrets.ts @@ -0,0 +1,96 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { getRuntime } from "../config/runtime.js"; + +const algorithm = "aes-256-gcm"; +const contextSeparator = "\u0000"; + +/** + * Ciphertext is only meaningful for the exact record it was written for. Without this, a valid + * ciphertext/nonce pair can be relocated between rows and the honest service will happily decrypt + * a credential that belongs to a different tenant, agent or wallet. The AAD is rebuilt from the + * row's own columns on every read, so a relocated tuple fails authentication instead of + * decrypting into the wrong context. + */ +export type SecretContext = { + schemaVersion: 1; + tenantId: string; + agentId: string; + /** Immutable row id. Empty while a wallet is still provisioning. */ + walletId: string; + /** Provider-visible wallet identity, when one is already known. */ + providerId?: string; + purpose: string; + /** Artifact binding fields; wallet credentials use wallet identity instead. */ + operationId?: string; + grantId?: string; + ownerEpoch?: number; + signingAction?: string; +}; + +export function canonicalSecretContext(context: SecretContext) { + const fields = [ + `v${context.schemaVersion}`, + context.tenantId, + context.agentId, + context.walletId, + context.providerId ?? "", + context.purpose, + ]; + if ( + context.operationId || + context.grantId || + context.ownerEpoch !== undefined || + context.signingAction + ) { + fields.push( + `operation:${context.operationId ?? ""}`, + `grant:${context.grantId ?? ""}`, + `owner_epoch:${context.ownerEpoch ?? ""}`, + `signing_action:${context.signingAction ?? ""}`, + ); + } + return fields.join(contextSeparator); +} + +function encryptionKey(keyId: string) { + const runtime = getRuntime(); + const secret = + runtime.secretEncryptionKeys?.[keyId] ?? + (keyId === "env:v1" ? runtime.secretEncryptionKey : undefined); + if (!secret) throw new Error(`Unknown secret key id: ${keyId}`); + return createHash("sha256").update(secret).digest(); +} + +export type SealedSecret = { + ciphertext: string; + keyId: string; + nonce: string; +}; + +export function sealSecret(plaintext: string, context: SecretContext): SealedSecret { + const keyId = getRuntime().secretEncryptionActiveKeyId ?? "env:v1"; + const nonce = randomBytes(12); + const cipher = createCipheriv(algorithm, encryptionKey(keyId), nonce); + cipher.setAAD(Buffer.from(canonicalSecretContext(context), "utf8")); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + ciphertext: Buffer.concat([ciphertext, tag]).toString("base64url"), + keyId, + nonce: nonce.toString("base64url"), + }; +} + +export function openSecret(secret: SealedSecret, context: SecretContext) { + const payload = Buffer.from(secret.ciphertext, "base64url"); + const tag = payload.subarray(payload.length - 16); + const encrypted = payload.subarray(0, payload.length - 16); + const decipher = createDecipheriv( + algorithm, + encryptionKey(secret.keyId), + Buffer.from(secret.nonce, "base64url"), + ); + decipher.setAAD(Buffer.from(canonicalSecretContext(context), "utf8")); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); +} diff --git a/packages/agents-core/src/shared/wallet-authorization.ts b/packages/agents-core/src/shared/wallet-authorization.ts new file mode 100644 index 000000000..721fad864 --- /dev/null +++ b/packages/agents-core/src/shared/wallet-authorization.ts @@ -0,0 +1,105 @@ +import { getRuntime } from "../config/runtime.js"; +import { ApiError } from "./errors.js"; +import { nearRpcProvider } from "./near-rpc.js"; + +const walletAccountPattern = /^0s[0-9a-f]{40}$/; + +type WalletAuthorizationInput = { + accountId: string; + authorization: string; + expectedPayload: string; + /** Finalized block selected by the owner-wallet code check. */ + blockId: string; +}; + +function validatePasskeyClaim( + message: Record | undefined, + input: WalletAuthorizationInput, +) { + if ( + !message || + message.chain_id !== getRuntime().network || + message.signer_id !== input.accountId || + message.payload !== input.expectedPayload || + (Array.isArray(message.path) && message.path.length !== 0) || + (message.path !== undefined && !Array.isArray(message.path)) || + typeof message.timestamp !== "string" + ) + throw new ApiError("wallet_authorization_invalid", 401); + const signedAt = Date.parse(message.timestamp); + if ( + !Number.isFinite(signedAt) || + signedAt > Date.now() + 30_000 || + signedAt < Date.now() - 300_000 + ) + throw new ApiError("wallet_authorization_expired", 409); +} + +function validateEvmClaim(authorization: unknown, recipient: string, expectedPayload: string) { + const claim = authorization as { recipient?: unknown; payload?: unknown; signature?: unknown }; + if ( + claim.recipient !== recipient || + claim.payload !== expectedPayload || + typeof claim.signature !== "string" + ) + throw new ApiError("wallet_authorization_invalid", 401); +} + +function validatedAuthorization(input: WalletAuthorizationInput) { + if (!walletAccountPattern.test(input.accountId) || input.authorization.length > 32_768) + throw new ApiError("wallet_authorization_invalid", 400); + let authorization: unknown; + try { + authorization = JSON.parse(input.authorization); + } catch { + throw new ApiError("wallet_authorization_invalid", 400); + } + const evm = + authorization && + typeof authorization === "object" && + (authorization as { purpose?: unknown }).purpose === "PROVE_OWNERSHIP"; + const signed = (authorization as { signature?: { msg?: Record } })?.signature; + const message = signed?.msg; + if (!evm) validatePasskeyClaim(message, input); + const payload = JSON.parse(input.expectedPayload) as { recipient?: unknown }; + if (typeof payload.recipient !== "string") + throw new ApiError("wallet_authorization_invalid", 401); + if (evm) validateEvmClaim(authorization, payload.recipient, input.expectedPayload); + return { evm, recipient: payload.recipient }; +} + +/** Resolve one NEP-641 proof against finalized wallet-contract state. */ +export async function verifyWalletAuthorization(input: WalletAuthorizationInput): Promise { + const { evm, recipient } = validatedAuthorization(input); + let result: number[]; + try { + const response = await nearRpcProvider().callFunctionRaw({ + contractId: input.accountId, + method: "w_resolve_auth", + args: evm + ? { purpose: "PROVE_OWNERSHIP", recipient, authorization: input.authorization } + : { path: [], authorization: input.authorization }, + blockQuery: { blockId: input.blockId }, + }); + if (response.block_hash !== input.blockId) + throw new ApiError("wallet_resolver_unavailable", 503); + result = response.result; + } catch { + throw new ApiError("wallet_resolver_unavailable", 503); + } + let resolution: unknown; + try { + resolution = JSON.parse(Buffer.from(result).toString("utf8")); + } catch { + throw new ApiError("wallet_authorization_unresolved", 401); + } + const resolved = resolution as { payload?: unknown; pending?: unknown } | null; + if ( + !resolved || + typeof resolved !== "object" || + resolved.payload !== input.expectedPayload || + (resolved.pending !== undefined && + (!Array.isArray(resolved.pending) || resolved.pending.length !== 0)) + ) + throw new ApiError("wallet_authorization_invalid", 401); +} diff --git a/packages/agents-core/tests/slots.test.ts b/packages/agents-core/tests/slots.test.ts new file mode 100644 index 000000000..97fde0169 --- /dev/null +++ b/packages/agents-core/tests/slots.test.ts @@ -0,0 +1,42 @@ +import { + configureDatabase, + configureRuntime, + getDatabase, + getRuntime, + requiredSlot, +} from "@near-intents-agent-api/agents-core"; +import { describe, expect, it } from "vitest"; + +describe("agents-core dependency slots", () => { + it("requiredSlot fails closed before configuration and returns the value after", () => { + const slot = requiredSlot<{ marker: string }>("Test slot"); + expect(() => slot.get()).toThrow("Test slot is not configured"); + slot.set({ marker: "configured" }); + expect(slot.get().marker).toBe("configured"); + slot.set(undefined); + expect(() => slot.get()).toThrow("Test slot is not configured"); + }); + + it("runtime slot round-trips a configured AppRuntime", () => { + configureRuntime({ + trustedOrigins: ["https://citynode.app"], + nearRpcUrls: ["https://near.drpc.org"], + network: "mainnet", + serviceUrl: "https://citynode.app", + sponsorDailyGlobalLimit: 100, + sponsorDailyTenantLimit: 20, + sponsorDailyAgentLimit: 10, + logLevel: "warn", + } as Parameters[0]); + const runtime = getRuntime(); + expect(runtime.trustedOrigins).toEqual(["https://citynode.app"]); + expect(runtime.network).toBe("mainnet"); + expect(runtime.sponsorDailyGlobalLimit).toBe(100); + }); + + it("database slot surfaces the configured handle", () => { + const handle = { db: { marker: "db" } } as unknown as Parameters[0]; + configureDatabase(handle); + expect(getDatabase()).toBeDefined(); + }); +}); diff --git a/packages/agents-core/tests/sponsor-keys.test.ts b/packages/agents-core/tests/sponsor-keys.test.ts new file mode 100644 index 000000000..09970e8e9 --- /dev/null +++ b/packages/agents-core/tests/sponsor-keys.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { sponsorKeysSchema } from "../src/config/sponsor-keys"; + +const validKey = + "ed25519:51wkXZuAj4mUpd8GskACyNj5omyifyUEKGECqiVRviBzT4gTFAFAVD5jYcmMdFEHRcDLt2iktJ6irQtzpa8PBmso"; + +describe("sponsor key provisioning schema", () => { + it("parses a single-key JSON array sharing one account", () => { + const parsed = sponsorKeysSchema.parse( + JSON.stringify([{ accountId: "sponsor.citynode.near", privateKey: validKey }]), + ); + expect(parsed).toHaveLength(1); + expect(parsed[0]?.accountId).toBe("sponsor.citynode.near"); + }); + + it("rejects keys spread across multiple accounts", () => { + const result = sponsorKeysSchema.safeParse( + JSON.stringify([ + { accountId: "a.near", privateKey: validKey }, + { accountId: "b.near", privateKey: validKey }, + ]), + ); + expect(result.success).toBe(false); + }); + + it("rejects a testnet sponsor account (account/network mismatch)", () => { + const result = sponsorKeysSchema.safeParse( + JSON.stringify([{ accountId: "sponsor.testnet", privateKey: validKey }]), + ); + expect(result.success).toBe(false); + }); + + it("rejects malformed JSON and duplicate keys", () => { + expect(sponsorKeysSchema.safeParse("not json").success).toBe(false); + expect( + sponsorKeysSchema.safeParse( + JSON.stringify([ + { accountId: "a.near", privateKey: validKey }, + { accountId: "a.near", privateKey: validKey }, + ]), + ).success, + ).toBe(false); + }); +}); diff --git a/packages/agents-core/tsconfig.json b/packages/agents-core/tsconfig.json new file mode 100644 index 000000000..c1d8fd273 --- /dev/null +++ b/packages/agents-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 000000000..6308da3f0 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,32 @@ +{ + "name": "@near-intents-agent-api/contracts", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./borsh": { + "types": "./src/borsh.ts", + "import": "./src/borsh.ts" + }, + "./api": { + "types": "./src/api/index.ts", + "import": "./src/api/index.ts" + } + }, + "dependencies": { + "@noble/hashes": "2.4.0", + "@scure/base": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/contracts/src/agents.ts b/packages/contracts/src/agents.ts new file mode 100644 index 000000000..75aba1296 --- /dev/null +++ b/packages/contracts/src/agents.ts @@ -0,0 +1,244 @@ +import { z } from "zod"; +import { + idSchema, + nearAccountSchema, + nearPublicKeySchema, + ownerNearSchema, + ownerProofSchema, + ownerWalletSchema, + signedMessageFields, + usdAmountSchema, +} from "./common.js"; +import { balanceEntrySchema } from "./tokens.js"; + +/** Owner-signed archive and restore; wallet recovery and rotation are not supported. */ +const agentControlMessageSchema = z.strictObject({ + domain: z.literal("near-intents-agent-api.agent-control.v3"), + ...signedMessageFields, + action: z.enum(["archive", "restore"]), + previous_public_key: nearPublicKeySchema, +}); + +const agentControlSchema = z.strictObject({ + message: agentControlMessageSchema, + proof: ownerProofSchema, +}); + +const ownerAdminCommandFields = { + domain: z.literal("near-intents-agent-api.owner-admin.v2"), + owner: ownerWalletSchema, + tenant_id: idSchema, + agent_id: idSchema, + network: z.literal("mainnet"), + owner_epoch: z.number().int().positive(), + /** Exact target id or canonical settings hash for this command. */ + target_id: idSchema, + recipient: z.string().min(1).max(253), + nonce: idSchema, + issued_at_ms: z.number().int().nonnegative(), + expires_at_ms: z.number().int().positive(), +}; + +const agentGrantRevocationMessageSchema = z.strictObject({ + ...ownerAdminCommandFields, + action: z.literal("revoke_grant"), +}); + +const timelockSettingsSchema = z.strictObject({ + delay_seconds: z + .number() + .int() + .min(0) + .max(30 * 24 * 60 * 60), +}); +const timelockMessageSchema = z.strictObject({ + ...ownerAdminCommandFields, + action: z.literal("set_timelock"), + delay_seconds: timelockSettingsSchema.shape.delay_seconds, + expected_revision: z.number().int().nonnegative(), +}); +const timelockWriteSchema = z.strictObject({ + message: timelockMessageSchema, + proof: ownerProofSchema, +}); +/** `null` leaves that window uncapped. */ +const budgetSettingsSchema = z.strictObject({ + daily_usd: usdAmountSchema.nullable(), + weekly_usd: usdAmountSchema.nullable(), + monthly_usd: usdAmountSchema.nullable(), +}); +const budgetMessageSchema = z.strictObject({ + ...ownerAdminCommandFields, + action: z.literal("set_budget"), + ...budgetSettingsSchema.shape, + expected_revision: z.number().int().nonnegative(), +}); +const budgetWriteSchema = z.strictObject({ + message: budgetMessageSchema, + proof: ownerProofSchema, +}); +const executionCancellationMessageSchema = z.strictObject({ + ...ownerAdminCommandFields, + action: z.literal("cancel_execution"), +}); +const executionCancellationSchema = z.strictObject({ + message: executionCancellationMessageSchema, + proof: ownerProofSchema, +}); +const budgetWindowUsageSchema = z.strictObject({ + limit_usd: usdAmountSchema.nullable(), + spent_usd: z.string(), + remaining_usd: z.string().nullable(), + /** When the oldest counted spend leaves the window; null when nothing is counted. */ + resets_at: z.iso.datetime().nullable(), +}); +const budgetViewSchema = z.strictObject({ + daily: budgetWindowUsageSchema, + weekly: budgetWindowUsageSchema, + monthly: budgetWindowUsageSchema, + revision: z.number().int().nonnegative(), + enforced_by: z.literal("agent_api"), +}); +const timelockViewSchema = z.strictObject({ + delay_seconds: z.number().int().nonnegative(), + revision: z.number().int().nonnegative(), + /** Executions waiting for release or being dispatched; list them page by page. */ + scheduled_count: z.number().int().nonnegative(), + enforced_by: z.literal("agent_api"), +}); +const scheduledExecutionSchema = z.strictObject({ + operation_id: idSchema, + execute_after: z.iso.datetime(), + state: z.enum(["waiting", "dispatching"]), + action: z.string().nullable(), +}); +export type TimelockSettings = z.infer; +export type TimelockMessage = z.infer; +export type TimelockWrite = z.infer; +export type TimelockView = z.infer; +export type ScheduledExecution = z.infer; +export type BudgetSettings = z.infer; +export type BudgetMessage = z.infer; +export type BudgetWrite = z.infer; +export type BudgetView = z.infer; +export type ExecutionCancellation = z.infer; + +const agentAdminCommandMessageSchema = z.discriminatedUnion("action", [ + agentGrantRevocationMessageSchema, + timelockMessageSchema, + budgetMessageSchema, + executionCancellationMessageSchema, +]); + +const agentGrantRevocationSchema = z.strictObject({ + message: agentGrantRevocationMessageSchema, + proof: ownerProofSchema, +}); + +/** + * The owner's one signature that deletes an agent. The server issues it through the delete + * challenge: `beneficiary` is always the service sponsor account, which receives the custody + * account's native NEAR. Public and confidential balances still in the wallet are lost. + */ +const agentDeleteMessageSchema = z.strictObject({ + domain: z.literal("near-intents-agent-api.agent-delete.v3"), + ...signedMessageFields, + beneficiary: nearAccountSchema, + chain: z.literal("near"), + confirm_asset_loss: z.literal(true), +}); + +const agentDeleteSchema = z.strictObject({ + message: agentDeleteMessageSchema, + proof: ownerProofSchema, + idempotencyKey: z.string().min(8).max(128), +}); + +export const walletSchema = z.strictObject({ + wallet_id: z.string().min(1).max(256), + near_account_id: nearAccountSchema, + evm_address: z + .string() + .regex(/^0x[0-9a-f]{40}$/) + .nullable(), +}); + +/** + * `pending`: created, waiting for the owner's single onboarding signature to land on chain. + * `abandoned`: the onboarding request expired or failed; the agent never becomes usable. + */ +const agentStatusSchema = z.enum(["pending", "active", "archived", "deleted", "abandoned"]); + +export const agentViewSchema = z.strictObject({ + id: idSchema, + name: z.string(), + externalUserId: z.string().nullable(), + status: agentStatusSchema, + archived: z.boolean().optional(), + deleted: z.boolean(), + ownerWallet: ownerWalletSchema.nullable(), + ownerNear: ownerNearSchema.nullable(), + wallet: walletSchema.nullable(), + createdAt: z.string(), +}); + +const balanceListSchema = z.strictObject({ + near_account_id: nearAccountSchema, + source: z.enum(["public", "confidential"]), + balances: z.array(balanceEntrySchema).max(4096), +}); + +export type Wallet = z.infer; +export type AgentView = z.infer; +/** + * What deleting the agent does to its funds, read when the challenge is issued. Balances can + * still change before the owner signs; the owner empties the wallet (for example by asking the + * agent to withdraw) before deleting. + */ +const agentDeletePreviewSchema = z.strictObject({ + near_account_id: nearAccountSchema, + /** Receives the custody account's native NEAR when the account is deleted. */ + beneficiary: nearAccountSchema, + native_balance: z.string().regex(/^[0-9]{1,78}$/), + /** Non-zero public balances that deletion destroys. */ + public: z.array(balanceEntrySchema).max(4096), + /** Non-zero confidential balances that deletion destroys. */ + confidential: z.array(balanceEntrySchema).max(4096), + /** True when any Intents or confidential balance would be lost. */ + assets_lost: z.boolean(), + /** + * `provider_delete`: the custody account exists on chain and OutLayer deletes it, sending its + * native NEAR to `beneficiary`. `credential_erase`: the account was never created on chain (an + * Intents-only wallet never holds native NEAR), so there is nothing to delete; the API retires + * the wallet by erasing its only custody credential. + */ + retirement: z.enum(["provider_delete", "credential_erase"]), + /** + * False only for `provider_delete` under a policy whose `transaction_types` lacks `delete`: + * OutLayer would refuse the deletion, so the owner adds the type with a policy update first. + */ + policy_allows_delete: z.boolean(), +}); + +export const agentDeletionChallengeSchema = z.strictObject({ + message: agentDeleteMessageSchema, + preview: agentDeletePreviewSchema, +}); + +export type BalanceEntry = z.infer; +export type AgentDeletePreview = z.infer; +export type AgentDeletionChallenge = z.infer; +export type BalanceList = z.infer; + +/** Lists hold every held asset, so no token is required. */ +const balanceListQuerySchema = z.strictObject({ + source: z.enum(["public", "confidential"]), +}); +export type BalanceListQuery = z.infer; + +export type AgentControl = z.infer; +export type AgentControlMessage = z.infer; +export type AgentAdminCommandMessage = z.infer; +export type AgentGrantRevocation = z.infer; +export type AgentDelete = z.infer; +export type AgentDeleteMessage = z.infer; diff --git a/packages/contracts/src/api/common.ts b/packages/contracts/src/api/common.ts new file mode 100644 index 000000000..381de6504 --- /dev/null +++ b/packages/contracts/src/api/common.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +/** + * Wire primitives shared by every `/v1` endpoint. + * + * The public API follows the NEAR Intents 1Click conventions: camelCase fields, UPPER_SNAKE + * status values, a `correlationId` on every long-running request, and JSON:API error documents + * whose stable `code` is kebab-case. Signed protocol objects (policies, owner messages, wallet + * requests, typed data) keep their own field names because their bytes are signed. + */ + +/** Atomic token amount: a non-negative integer in the token's smallest unit. */ +export const atomicAmountSchema = z + .string() + .regex(/^[0-9]{1,78}$/) + .describe("Atomic amount in the token's smallest unit, as a decimal integer string."); + +export const agentIdSchema = z + .string() + .regex(/^[0-9a-f]{64}$/) + .describe("Agent id."); + +/** + * Every intent or execution is tracked by one correlation id. Owner intents use a UUID; + * executions, signatures and relays use their 64-hex operation id. + */ +export const correlationIdSchema = z + .string() + .regex(/^(?:[0-9a-f]{64}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/) + .describe("Correlation id returned by generate-intent or an execution endpoint."); + +/** Value of the `Idempotency-Key` request header. */ +export const idempotencyKeySchema = z + .string() + .min(8) + .max(128) + .regex(/^[A-Za-z0-9._:-]+$/); + +/** + * Lifecycle of every intent and execution. + * + * - `PENDING_SIGNATURE`: generated, waiting for the owner's wallet signature. + * - `QUEUED`: accepted and waiting for the owner's execution timelock. + * - `PENDING_APPROVAL`: waiting for approvers configured in the owner's policy. + * - `PENDING_DEPOSIT`: a deposit address was issued; waiting for funds. + * - `PROCESSING`: submitted; settlement not yet observed. + * - `SUCCESS`: settled with evidence. + * - `REFUNDED`: the route refunded the input. + * - `FAILED`: provably never executed, or failed with a recorded reason. + * - `UNCERTAIN`: the outcome is unknown. Keep polling; never resubmit under a new key. + */ +export const statusSchema = z.enum([ + "PENDING_SIGNATURE", + "QUEUED", + "PENDING_APPROVAL", + "PENDING_DEPOSIT", + "PROCESSING", + "SUCCESS", + "REFUNDED", + "FAILED", + "UNCERTAIN", +]); +export type Status = z.infer; + +export const terminalStatuses: readonly Status[] = ["SUCCESS", "REFUNDED", "FAILED"]; + +/** Stable machine-readable error codes are kebab-case. Branch on `code`, never on `title`. */ +export const errorCodeSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + +export const apiErrorSchema = z.strictObject({ + status: z.string().regex(/^[1-5][0-9]{2}$/), + code: errorCodeSchema, + title: z.string(), + detail: z.string().optional(), + source: z.strictObject({ pointer: z.string() }).optional(), + meta: z.strictObject({ retryable: z.boolean() }).optional(), +}); + +/** JSON:API error document, as returned by 1Click order endpoints. */ +export const errorDocumentSchema = z.strictObject({ + errors: z.array(apiErrorSchema).min(1), + meta: z.strictObject({ requestId: z.string().optional() }), +}); +export type ApiErrorObject = z.infer; +export type ErrorDocument = z.infer; + +/** Converts an internal snake_case failure code to its public kebab-case form. */ +export function publicCode(code: string): string { + return code.replaceAll("_", "-").toLowerCase(); +} + +/** Cursor page shape used by every list endpoint. */ +export function pageSchema(item: T) { + return z.strictObject({ + data: z.array(item), + nextCursor: z.string().nullable(), + }); +} diff --git a/packages/contracts/src/api/endpoints.ts b/packages/contracts/src/api/endpoints.ts new file mode 100644 index 000000000..06ac4e89c --- /dev/null +++ b/packages/contracts/src/api/endpoints.ts @@ -0,0 +1,740 @@ +import { z } from "zod"; +import { partnerQuotaViewSchema } from "../quotas.js"; +import { agentIdSchema, correlationIdSchema } from "./common.js"; +import { + balanceMoveRequestSchema, + depositRequestSchema, + historyPageSchema, + historyQuerySchema, + quoteResponseSchema, + recoverRequestSchema, + relayRequestSchema, + signMessageRequestSchema, + statusQuerySchema, + statusResponseSchema, + swapRequestSchema, + transferRequestSchema, + withdrawRequestSchema, +} from "./executions.js"; +import { + generateIntentRequestSchema, + generateIntentResponseSchema, + submitIntentRequestSchema, +} from "./intents.js"; +import { + acknowledgementSchema, + agentListQuerySchema, + agentPageSchema, + agentViewSchema, + approvalViewSchema, + balanceQuerySchema, + balancesViewSchema, + budgetViewSchema, + containmentQuerySchema, + containmentViewSchema, + createDeveloperKeySchema, + createdDeveloperKeySchema, + developerDashboardSchema, + developerKeyListSchema, + grantListViewSchema, + limitsViewSchema, + managedAgentPageSchema, + managedAgentQuerySchema, + networkViewSchema, + policyHistoryQuerySchema, + policyHistoryViewSchema, + policyViewSchema, + revocationViewSchema, + scheduledPageSchema, + scheduledQuerySchema, + signatureDeliverySchema, + timelockViewSchema, + tokenListViewSchema, + walletViewSchema, + whoamiViewSchema, +} from "./views.js"; + +/** + * The single definition of every `/v1` endpoint. The server mounts routes from it, OpenAPI is + * generated from it and the SDK derives its method types from it, so none of them can drift. + */ + +export type EndpointAuth = "apiKey" | "session" | "none"; +export type EndpointMethod = "get" | "post" | "put" | "delete"; + +export type EndpointDefinition = { + operationId: string; + method: EndpointMethod; + /** OpenAPI path template, e.g. `/v1/agents/{agentId}/swap`. */ + path: string; + tag: string; + summary: string; + description?: string; + auth: EndpointAuth; + /** + * Delegated work: the request must also carry `X-Grant-Token`, the token of the owner grant it + * runs under. The API key alone never authorizes it. + */ + grant?: true; + params?: z.ZodObject; + query?: z.ZodType; + body?: z.ZodType; + /** `Idempotency-Key` header: required for writes that create work, optional otherwise. */ + idempotency?: "required" | "optional"; + status: 200 | 201 | 202; + response: z.ZodType; + /** Kebab-case error codes this endpoint documents beyond generic validation and auth. */ + errors?: readonly string[]; +}; + +function defineEndpoint(endpoint: E): E { + return endpoint; +} + +const agentParams = z.strictObject({ agentId: agentIdSchema }); +const agentCorrelationParams = z.strictObject({ + agentId: agentIdSchema, + correlationId: correlationIdSchema, +}); +const executionResponse = z.union([statusResponseSchema, quoteResponseSchema]); +const executionErrors = [ + "agent-grant-required", + "grant-action-denied", + "grant-recipient-denied", + "grant-recipient-required", + "policy-not-ready", + "policy-denied", + "insufficient-balance", + "amount-too-low", + "route-unavailable", + "wallet-busy", + "wallet-frozen", + "idempotency-conflict", + "authorization-stale", +] as const; + +export const endpoints = { + getPartnerQuota: defineEndpoint({ + operationId: "getPartnerQuota", + method: "get", + path: "/v1/quotas", + tag: "Partner", + summary: "Read effective partner resource quotas and usage", + auth: "apiKey", + status: 200, + response: partnerQuotaViewSchema, + }), + getDeveloperQuota: defineEndpoint({ + operationId: "getDeveloperQuota", + method: "get", + path: "/v1/developer/quotas", + tag: "Developer", + summary: "Read partner resource quotas and usage", + auth: "session", + status: 200, + response: partnerQuotaViewSchema, + }), + + // --------------------------------------------------------------------------------- intents + generateIntent: defineEndpoint({ + operationId: "generateIntent", + method: "post", + path: "/v1/generate-intent", + tag: "Owner intents", + summary: "Generate an owner intent for signing", + description: + "Builds the exact payload the owner's wallet must sign for one owner action and returns it as `intent: {standard, payload}`. Sign `intent.payload` unchanged with the wallet named by `standard`, then call submit-intent with the wallet's output. `agent_create` creates a pending agent and returns its id.", + auth: "apiKey", + body: generateIntentRequestSchema, + idempotency: "optional", + status: 201, + response: generateIntentResponseSchema, + errors: [ + "agent-not-bound", + "agent-not-found", + "policy-revision-conflict", + "grant-action-unknown", + "grant-action-forbidden", + "approval-closed", + "owner-type-unsupported", + "agent-quota-exceeded", + ], + }), + submitIntent: defineEndpoint({ + operationId: "submitIntent", + method: "post", + path: "/v1/submit-intent", + tag: "Owner intents", + summary: "Submit a signed owner intent", + description: + "Submits the wallet's signature over a generated intent. `signedData.payload` must equal the generated payload. Resubmitting the same signature is idempotent. Returns the current status; poll GET /v1/status for settlement.", + auth: "apiKey", + body: submitIntentRequestSchema, + status: 202, + response: statusResponseSchema, + errors: [ + "intent-not-found", + "intent-expired", + "intent-payload-mismatch", + "intent-type-mismatch", + "signature-invalid", + "policy-revision-conflict", + "sponsor-balance-insufficient", + "sponsor-busy", + ], + }), + getStatus: defineEndpoint({ + operationId: "getStatus", + method: "get", + path: "/v1/status", + tag: "Status", + summary: "Get the status of an intent or execution", + description: + "Reads the status of any correlation id and reconciles it with chain and provider state. `waitMs` long-polls until the status is terminal, needs a signature, or the wait ends.", + auth: "apiKey", + query: statusQuerySchema, + status: 200, + response: statusResponseSchema, + errors: ["status-not-found"], + }), + getHistory: defineEndpoint({ + operationId: "getHistory", + method: "get", + path: "/v1/agents/{agentId}/history", + tag: "Status", + summary: "List an agent's intents and executions, newest first", + auth: "apiKey", + params: agentParams, + query: historyQuerySchema, + status: 200, + response: historyPageSchema, + }), + + // ---------------------------------------------------------------------------------- agents + listAgents: defineEndpoint({ + operationId: "listAgents", + method: "get", + path: "/v1/agents", + tag: "Agents", + summary: "List agents", + auth: "apiKey", + query: agentListQuerySchema, + status: 200, + response: agentPageSchema, + }), + getAgent: defineEndpoint({ + operationId: "getAgent", + method: "get", + path: "/v1/agents/{agentId}", + tag: "Agents", + summary: "Get an agent", + auth: "apiKey", + params: agentParams, + status: 200, + response: agentViewSchema, + }), + getWallet: defineEndpoint({ + operationId: "getWallet", + method: "get", + path: "/v1/agents/{agentId}/wallet", + tag: "Agents", + summary: "Get the agent's custody wallet identity", + auth: "apiKey", + params: agentParams, + status: 200, + response: walletViewSchema, + }), + getBalances: defineEndpoint({ + operationId: "getBalances", + method: "get", + path: "/v1/agents/{agentId}/balances", + tag: "Agents", + summary: "Get the agent's balances", + description: + "`source=public` (default) and `confidential` list every non-zero asset. Amounts are atomic.", + auth: "apiKey", + params: agentParams, + query: balanceQuerySchema, + status: 200, + response: balancesViewSchema, + }), + getPolicy: defineEndpoint({ + operationId: "getPolicy", + method: "get", + path: "/v1/agents/{agentId}/policy", + tag: "Policy", + summary: "Get the current policy revision", + auth: "apiKey", + params: agentParams, + status: 200, + response: policyViewSchema, + }), + getPolicyHistory: defineEndpoint({ + operationId: "getPolicyHistory", + method: "get", + path: "/v1/agents/{agentId}/policy/history", + tag: "Policy", + summary: "List signed policy revisions, newest first", + auth: "apiKey", + params: agentParams, + query: policyHistoryQuerySchema, + status: 200, + response: policyHistoryViewSchema, + }), + getLimits: defineEndpoint({ + operationId: "getLimits", + method: "get", + path: "/v1/agents/{agentId}/limits", + tag: "Policy", + summary: "Get hard limits OutLayer enforces for the agent", + auth: "apiKey", + params: agentParams, + status: 200, + response: limitsViewSchema, + }), + listGrants: defineEndpoint({ + operationId: "listGrants", + method: "get", + path: "/v1/agents/{agentId}/grants", + tag: "Grants", + summary: "List every grant for the agent, including revoked ones", + auth: "apiKey", + params: agentParams, + status: 200, + response: grantListViewSchema, + }), + getTimelock: defineEndpoint({ + operationId: "getTimelock", + method: "get", + path: "/v1/agents/{agentId}/timelock", + tag: "Grants", + summary: "Get the execution timelock and how many executions it holds", + auth: "apiKey", + params: agentParams, + status: 200, + response: timelockViewSchema, + }), + listScheduledExecutions: defineEndpoint({ + operationId: "listScheduledExecutions", + method: "get", + path: "/v1/agents/{agentId}/timelock/scheduled", + tag: "Grants", + summary: "List executions the timelock holds, earliest release first", + auth: "apiKey", + params: agentParams, + query: scheduledQuerySchema, + status: 200, + response: scheduledPageSchema, + }), + getBudget: defineEndpoint({ + operationId: "getBudget", + method: "get", + path: "/v1/agents/{agentId}/budget", + tag: "Grants", + summary: "Get the USD spend caps and how much of each is used", + auth: "apiKey", + params: agentParams, + status: 200, + response: budgetViewSchema, + }), + getContainment: defineEndpoint({ + operationId: "getContainment", + method: "get", + path: "/v1/agents/{agentId}/containment", + tag: "Grants", + summary: "Get the agent's API enforcement state and open work", + auth: "apiKey", + params: agentParams, + query: containmentQuerySchema, + status: 200, + response: containmentViewSchema, + }), + listApprovals: defineEndpoint({ + operationId: "listApprovals", + method: "get", + path: "/v1/agents/{agentId}/approvals", + tag: "Approvals", + summary: "List requests waiting for approval", + auth: "apiKey", + params: agentParams, + status: 200, + response: z.strictObject({ data: z.array(approvalViewSchema) }), + }), + getApproval: defineEndpoint({ + operationId: "getApproval", + method: "get", + path: "/v1/agents/{agentId}/approvals/{approvalId}", + tag: "Approvals", + summary: "Get one approval request", + auth: "apiKey", + params: z.strictObject({ agentId: agentIdSchema, approvalId: z.uuid() }), + status: 200, + response: approvalViewSchema, + }), + getAddress: defineEndpoint({ + operationId: "getAddress", + method: "get", + path: "/v1/agents/{agentId}/addresses/{chain}", + tag: "Agents", + summary: "Get the custody wallet's address on a chain", + auth: "apiKey", + params: z.strictObject({ agentId: agentIdSchema, chain: z.string().min(1).max(64) }), + status: 200, + response: z.strictObject({ chain: z.string(), address: z.string() }).passthrough(), + }), + listProviderRecords: defineEndpoint({ + operationId: "listProviderRecords", + method: "get", + path: "/v1/agents/{agentId}/provider/{kind}", + tag: "Agents", + summary: "Read OutLayer request, audit or deposit records for reconciliation", + auth: "apiKey", + params: z.strictObject({ + agentId: agentIdSchema, + kind: z.enum(["requests", "audit", "deposits", "deposit-history"]), + }), + query: z.strictObject({ + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().nonnegative().max(10_000).optional(), + type: z.string().min(1).max(256).optional(), + }), + status: 200, + response: z.strictObject({ data: z.unknown() }), + }), + + // ------------------------------------------------------------------------------ executions + swap: defineEndpoint({ + operationId: "swap", + method: "post", + path: "/v1/agents/{agentId}/swap", + tag: "Executions", + summary: "Swap one asset for another", + description: + "Requires `X-Grant-Token` for a grant that allows `swap`. With `dry: true` returns a quote and needs no `Idempotency-Key`.", + auth: "apiKey", + grant: true, + params: agentParams, + body: swapRequestSchema, + idempotency: "required", + status: 202, + response: executionResponse, + errors: executionErrors, + }), + withdraw: defineEndpoint({ + operationId: "withdraw", + method: "post", + path: "/v1/agents/{agentId}/withdraw", + tag: "Executions", + summary: "Withdraw an asset to an address on any supported chain", + description: + "Requires `X-Grant-Token` for a grant that allows `withdraw`. With `dry: true` previews fees and needs no `Idempotency-Key`.", + auth: "apiKey", + grant: true, + params: agentParams, + body: withdrawRequestSchema, + idempotency: "required", + status: 202, + response: executionResponse, + errors: executionErrors, + }), + transfer: defineEndpoint({ + operationId: "transfer", + method: "post", + path: "/v1/agents/{agentId}/transfer", + tag: "Executions", + summary: "Transfer an asset to another NEAR Intents account", + description: + "Requires `X-Grant-Token` for a grant that allows `intents_transfer`, or `confidential_transfer` when `confidential` is true.", + auth: "apiKey", + grant: true, + params: agentParams, + body: transferRequestSchema, + idempotency: "required", + status: 202, + response: statusResponseSchema, + errors: executionErrors, + }), + shield: defineEndpoint({ + operationId: "shield", + method: "post", + path: "/v1/agents/{agentId}/shield", + tag: "Executions", + summary: "Move a public balance to the confidential shard", + auth: "apiKey", + grant: true, + params: agentParams, + body: balanceMoveRequestSchema, + idempotency: "required", + status: 202, + response: statusResponseSchema, + errors: executionErrors, + }), + unshield: defineEndpoint({ + operationId: "unshield", + method: "post", + path: "/v1/agents/{agentId}/unshield", + tag: "Executions", + summary: "Move a confidential balance back to public", + auth: "apiKey", + grant: true, + params: agentParams, + body: balanceMoveRequestSchema, + idempotency: "required", + status: 202, + response: statusResponseSchema, + errors: executionErrors, + }), + confidentialDeposit: defineEndpoint({ + operationId: "confidentialDeposit", + method: "post", + path: "/v1/agents/{agentId}/confidential-deposit", + tag: "Executions", + summary: "Deposit a public balance into the confidential shard", + auth: "apiKey", + grant: true, + params: agentParams, + body: balanceMoveRequestSchema, + idempotency: "required", + status: 202, + response: statusResponseSchema, + errors: executionErrors, + }), + deposit: defineEndpoint({ + operationId: "deposit", + method: "post", + path: "/v1/agents/{agentId}/deposit", + tag: "Executions", + summary: "Get a deposit address that funds the agent from another chain", + description: + "Requires `X-Grant-Token` for a grant that allows `cross_chain_deposit`. The status reads `PENDING_DEPOSIT` with `details.depositAddress` until funds arrive.", + auth: "apiKey", + grant: true, + params: agentParams, + body: depositRequestSchema, + idempotency: "required", + status: 202, + response: statusResponseSchema, + errors: executionErrors, + }), + recover: defineEndpoint({ + operationId: "recover", + method: "post", + path: "/v1/agents/{agentId}/recover", + tag: "Executions", + summary: "Dispatch an UNCERTAIN execution that provably never reached the provider", + description: + "Only for executions with no provider reference and no dispatch commitment. `request` must equal the original body, `Idempotency-Key` the original key, and `X-Grant-Token` the token of the grant that admitted it.", + auth: "apiKey", + grant: true, + params: agentParams, + body: recoverRequestSchema, + idempotency: "required", + status: 202, + response: statusResponseSchema, + errors: ["operation-not-recoverable", "operation-recovery-unavailable", "idempotency-conflict"], + }), + signMessage: defineEndpoint({ + operationId: "signMessage", + method: "post", + path: "/v1/agents/{agentId}/sign-message", + tag: "Signing", + summary: "Sign an identity challenge with the agent's custody key", + description: + "NEP-413 (`chain: near`) or EIP-191 (EVM chains) over a canonical identity challenge. Requires `X-Grant-Token` for a grant that allows `sign:near_message` or `sign:evm_message`. Read the signature with GET /signatures/{correlationId}, then acknowledge it.", + auth: "apiKey", + grant: true, + params: agentParams, + body: signMessageRequestSchema, + idempotency: "required", + status: 202, + response: statusResponseSchema, + }), + getSignature: defineEndpoint({ + operationId: "getSignature", + method: "get", + path: "/v1/agents/{agentId}/signatures/{correlationId}", + tag: "Signing", + summary: "Read a completed signature", + auth: "apiKey", + grant: true, + params: agentCorrelationParams, + status: 200, + response: signatureDeliverySchema, + errors: ["signing-artifact-not-found", "signing-artifact-expired"], + }), + acknowledgeSignature: defineEndpoint({ + operationId: "acknowledgeSignature", + method: "post", + path: "/v1/agents/{agentId}/signatures/{correlationId}/ack", + tag: "Signing", + summary: "Erase a delivered signature from the server", + auth: "apiKey", + grant: true, + params: agentCorrelationParams, + status: 200, + response: acknowledgementSchema, + }), + relay: defineEndpoint({ + operationId: "relay", + method: "post", + path: "/v1/agents/{agentId}/relay", + tag: "Signing", + summary: "Relay an owner-signed NEP-366 delegate to an allowlisted receiver", + auth: "apiKey", + params: agentParams, + body: relayRequestSchema, + idempotency: "required", + status: 202, + response: statusResponseSchema, + errors: ["sponsor-balance-insufficient", "transaction-unconfirmed"], + }), + + // ------------------------------------------------------------------------------------ meta + getTokens: defineEndpoint({ + operationId: "getTokens", + method: "get", + path: "/v1/tokens", + tag: "Meta", + summary: "List the assets agents can hold and trade, with USD prices", + description: + "Exactly the custody provider's (OutLayer) supported assets, with chain and price from the public 1Click token list. `assetId` is the value executions take. Public; the provider list is cached for 30 minutes and prices for one.", + auth: "none", + status: 200, + response: tokenListViewSchema, + }), + getNetwork: defineEndpoint({ + operationId: "getNetwork", + method: "get", + path: "/v1/network", + tag: "Meta", + summary: "Get the network and supported owner types", + auth: "none", + status: 200, + response: networkViewSchema, + }), + whoami: defineEndpoint({ + operationId: "whoami", + method: "get", + path: "/v1/whoami", + tag: "Meta", + summary: "Identify the calling API key", + auth: "apiKey", + status: 200, + response: whoamiViewSchema, + }), + + // ------------------------------------------------------------------------------- developer + listDeveloperKeys: defineEndpoint({ + operationId: "listDeveloperKeys", + method: "get", + path: "/v1/developer/keys", + tag: "Developer", + summary: "List API keys", + auth: "session", + status: 200, + response: developerKeyListSchema, + }), + createDeveloperKey: defineEndpoint({ + operationId: "createDeveloperKey", + method: "post", + path: "/v1/developer/keys", + tag: "Developer", + summary: "Create an API key; the token is returned once", + auth: "session", + body: createDeveloperKeySchema, + status: 201, + response: createdDeveloperKeySchema, + errors: ["api-key-quota-exceeded", "fresh-login-required", "invalid-origin"], + }), + revokeDeveloperKey: defineEndpoint({ + operationId: "revokeDeveloperKey", + method: "delete", + path: "/v1/developer/keys/{keyId}", + tag: "Developer", + summary: "Revoke an API key", + auth: "session", + params: z.strictObject({ keyId: z.string().min(1).max(64) }), + status: 200, + response: revocationViewSchema, + }), + getDeveloperDashboard: defineEndpoint({ + operationId: "getDeveloperDashboard", + method: "get", + path: "/v1/developer/dashboard", + tag: "Developer", + summary: "Get all-time tenant counts", + auth: "session", + status: 200, + response: developerDashboardSchema, + }), + listManagedAgents: defineEndpoint({ + operationId: "listManagedAgents", + method: "get", + path: "/v1/developer/agents", + tag: "Developer", + summary: "List the tenant's agents", + auth: "session", + query: managedAgentQuerySchema, + status: 200, + response: managedAgentPageSchema, + }), + getManagedAgentPolicy: defineEndpoint({ + operationId: "getManagedAgentPolicy", + method: "get", + path: "/v1/developer/agents/{agentId}/policy", + tag: "Developer", + summary: "Get an agent's current policy", + auth: "session", + params: agentParams, + status: 200, + response: policyViewSchema, + }), + getManagedAgentLimits: defineEndpoint({ + operationId: "getManagedAgentLimits", + method: "get", + path: "/v1/developer/agents/{agentId}/limits", + tag: "Developer", + summary: "Get an agent's enforced limits", + auth: "session", + params: agentParams, + status: 200, + response: limitsViewSchema, + }), + getManagedAgentBudget: defineEndpoint({ + operationId: "getManagedAgentBudget", + method: "get", + path: "/v1/developer/agents/{agentId}/budget", + tag: "Developer", + summary: "Get an agent's USD spend caps and usage", + auth: "session", + params: agentParams, + status: 200, + response: budgetViewSchema, + }), + getManagedAgentTimelock: defineEndpoint({ + operationId: "getManagedAgentTimelock", + method: "get", + path: "/v1/developer/agents/{agentId}/timelock", + tag: "Developer", + summary: "Get an agent's execution timelock", + auth: "session", + params: agentParams, + status: 200, + response: timelockViewSchema, + }), + listManagedAgentScheduledExecutions: defineEndpoint({ + operationId: "listManagedAgentScheduledExecutions", + method: "get", + path: "/v1/developer/agents/{agentId}/timelock/scheduled", + tag: "Developer", + summary: "List an agent's timelocked executions, earliest release first", + auth: "session", + params: agentParams, + query: scheduledQuerySchema, + status: 200, + response: scheduledPageSchema, + }), +} as const; + +export type Endpoints = typeof endpoints; +export type EndpointName = keyof Endpoints; diff --git a/packages/contracts/src/api/executions.ts b/packages/contracts/src/api/executions.ts new file mode 100644 index 000000000..645adedaf --- /dev/null +++ b/packages/contracts/src/api/executions.ts @@ -0,0 +1,304 @@ +import { z } from "zod"; +import { nearAccountSchema } from "../common.js"; +import { evmChainSchema } from "../operations.js"; +import { agentIdSchema, atomicAmountSchema, correlationIdSchema, statusSchema } from "./common.js"; +import { intentTypeSchema } from "./intents.js"; +import { + budgetViewSchema, + grantViewSchema, + signatureDeliverySchema, + timelockViewSchema, +} from "./views.js"; + +/** + * Agent executions. The agent's own custody wallet signs inside OutLayer under the owner's + * policy, so no owner signature is needed; the calling API key needs a live owner grant for the + * action. Field names follow the 1Click quote request. `dry: true` previews without executing. + */ + +const asset = z + .string() + .min(1) + .max(256) + .describe("Asset id from GET /agents/{agentId}/tokens (`assetId`)."); + +export const swapRequestSchema = z.strictObject({ + originAsset: asset, + destinationAsset: asset, + /** Exact input amount, atomic units of `originAsset`. */ + amount: atomicAmountSchema, + /** Refuse a fill below this output, atomic units of `destinationAsset`. */ + minAmountOut: atomicAmountSchema.optional(), + /** Settle on the confidential shard. Never falls back to a public route. */ + confidential: z.boolean().default(false), + /** Quote only: returns the expected output without executing. */ + dry: z.boolean().default(false), +}); + +export const withdrawRequestSchema = z.strictObject({ + asset, + amount: atomicAmountSchema, + /** Destination chain, e.g. `near`, `eth`, `base`, `sol`. */ + chain: z.string().min(1).max(64), + /** Destination address on `chain`. */ + recipient: z.string().min(1).max(128), + memo: z.string().max(256).optional(), + confidential: z.boolean().default(false), + /** Return as soon as the bridge accepts the request; poll status for settlement. */ + async: z.boolean().default(false), + /** Preview fees and the received amount without executing. */ + dry: z.boolean().default(false), +}); + +export const transferRequestSchema = z.strictObject({ + asset, + amount: atomicAmountSchema, + /** NEAR Intents account that receives the asset. */ + recipient: nearAccountSchema, + /** Transfer on the confidential shard instead of public NEAR Intents. */ + confidential: z.boolean().default(false), +}); + +/** Moves an amount between the agent's own public and confidential balances. */ +export const balanceMoveRequestSchema = z.strictObject({ + asset, + amount: atomicAmountSchema, +}); + +/** Issues a deposit address that credits the agent's Intents (or confidential) balance. */ +export const depositRequestSchema = z + .strictObject({ + /** Asset sent on the origin chain. Either this or `chain` is required. */ + originAsset: z.string().min(1).max(256).optional(), + /** Asset credited on NEAR Intents, when it differs from the origin asset. */ + destinationAsset: z.string().min(1).max(256).optional(), + amount: atomicAmountSchema, + /** Origin chain, when `originAsset` is not given. */ + chain: z.string().min(1).max(32).optional(), + asset: z.string().min(1).max(128).optional(), + /** Origin-chain address that receives a refund. */ + refundTo: z.string().min(1).max(256).optional(), + confidential: z.boolean().default(false), + }) + .refine((input) => Boolean(input.originAsset) || Boolean(input.chain), { + message: "originAsset or chain is required", + path: ["originAsset"], + }); + +export const executionTypeSchema = z.enum([ + "swap", + "withdraw", + "transfer", + "shield", + "unshield", + "confidential_deposit", + "deposit", +]); +export type ExecutionType = z.infer; + +export const recoverRequestSchema = z.strictObject({ + correlationId: correlationIdSchema, + request: z.discriminatedUnion("type", [ + swapRequestSchema.extend({ type: z.literal("swap") }), + withdrawRequestSchema.extend({ type: z.literal("withdraw") }), + transferRequestSchema.extend({ type: z.literal("transfer") }), + balanceMoveRequestSchema.extend({ type: z.literal("shield") }), + balanceMoveRequestSchema.extend({ type: z.literal("unshield") }), + balanceMoveRequestSchema.extend({ type: z.literal("confidential_deposit") }), + ]), +}); + +export const signMessageRequestSchema = z.strictObject({ + /** `near` for NEP-413; an EVM chain for EIP-191. */ + chain: z.union([z.literal("near"), evmChainSchema]), + /** Canonical `near-intents-agent-api.identity.v1` challenge JSON. */ + message: z.string().max(32768), + encoding: z.enum(["utf8", "hex"]).default("utf8"), + /** NEP-413 recipient; must equal the challenge audience. Required for `near`. */ + recipient: nearAccountSchema.optional(), +}); + +export const relayRequestSchema = z.strictObject({ + senderId: z.string().min(2).max(64), + receiverId: z.string().min(2).max(64), + publicKey: z.string().startsWith("ed25519:").max(80), + nonce: z.string().regex(/^(0|[1-9][0-9]{0,19})$/), + maxBlockHeight: z.string().regex(/^(0|[1-9][0-9]{0,19})$/), + signatureHex: z.string().regex(/^[0-9a-f]{128}$/), + actions: z + .array( + z.strictObject({ + methodName: z.string().min(1).max(128), + argsBase64: z + .string() + .max(32768) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/), + gas: z.string().regex(/^(0|[1-9][0-9]{0,19})$/), + depositYocto: z.literal("0"), + }), + ) + .min(1) + .max(8), +}); + +// --------------------------------------------------------------------------------------------- +// Dry-run responses +// --------------------------------------------------------------------------------------------- + +export const quoteResponseSchema = z.strictObject({ + dry: z.literal(true), + type: z.enum(["swap", "withdraw"]), + /** Provider quote or preview, camelCased. Amounts are atomic strings. */ + quote: z.record(z.string(), z.unknown()), +}); +export type QuoteResponse = z.infer; + +// --------------------------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------------------------- + +/** Evidence recorded for an execution. Unknown provider fields pass through camelCased. */ +export const executionDetailsSchema = z + .object({ + action: z.string(), + nearAccountId: z.string().optional(), + providerRequestId: z.string().nullable().optional(), + providerStatus: z.string().optional(), + confidential: z.boolean().optional(), + chain: z.string().optional(), + executeAfter: z.string().optional(), + delaySeconds: z.number().optional(), + approvalId: z.string().nullable().optional(), + intentHash: z.string().nullable().optional(), + txHash: z.string().nullable().optional(), + destinationTxHash: z.string().nullable().optional(), + amountOut: z.string().nullable().optional(), + depositAddress: z.string().nullable().optional(), + intentId: z.string().nullable().optional(), + refundTxHash: z.string().nullable().optional(), + failureReason: z.string().nullable().optional(), + }) + .passthrough(); + +export const statusTypeSchema = z.union([ + intentTypeSchema, + executionTypeSchema, + z.enum(["sign_message", "relay"]), +]); +export type StatusType = z.infer; + +const policyDetails = z.strictObject({ + revision: z.number().int().positive().nullable(), + policyHash: z.string().nullable(), + transactionHash: z.string().nullable(), +}); + +export const statusDetailsSchemas = { + agent_create: policyDetails.extend({ agentId: agentIdSchema }), + policy_update: policyDetails, + agent_freeze: policyDetails, + agent_unfreeze: policyDetails, + grant_issue: z.strictObject({ grant: grantViewSchema.nullable() }), + grant_revoke: z.strictObject({ + grantId: z.string().nullable(), + committedCorrelationIds: z.array(correlationIdSchema).nullable(), + committedTruncated: z.boolean().nullable(), + }), + timelock_set: z.strictObject({ timelock: timelockViewSchema.nullable() }), + budget_set: z.strictObject({ budget: budgetViewSchema.nullable() }), + execution_cancel: z.strictObject({ cancelledCorrelationId: correlationIdSchema.nullable() }), + agent_archive: z.strictObject({ archived: z.boolean().nullable() }), + agent_restore: z.strictObject({ archived: z.boolean().nullable() }), + agent_delete: executionDetailsSchema, + approval_vote: z.strictObject({ + approvalId: z.string().nullable(), + verdict: z.enum(["approve", "reject"]).nullable(), + }), + signing_artifact_read: z.strictObject({ + /** Returned only in the submit-intent response, never by status reads. */ + delivery: signatureDeliverySchema.optional(), + }), + signing_artifact_ack: z.strictObject({ acknowledged: z.boolean().nullable() }), + swap: executionDetailsSchema, + withdraw: executionDetailsSchema, + transfer: executionDetailsSchema, + shield: executionDetailsSchema, + unshield: executionDetailsSchema, + confidential_deposit: executionDetailsSchema, + deposit: executionDetailsSchema, + sign_message: z.strictObject({ chain: z.string().optional() }).passthrough(), + relay: z + .strictObject({ + transactionHash: z.string().nullable().optional(), + }) + .passthrough(), +} as const satisfies Record; + +const statusBase = { + correlationId: correlationIdSchema, + agentId: agentIdSchema.nullable(), + status: statusSchema, + /** Kebab-case reason when `status` is `FAILED`, `REFUNDED` or `UNCERTAIN`. */ + failureCode: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + dispatchCommittedAt: z + .string() + .nullable() + .describe( + "When the API committed to the provider write, null before that and for owner-signed intents. After it, revoking a key or grant cannot retract the operation.", + ), + /** The owner grant that authorized this operation; null for owner-signed work. */ + grant: z.strictObject({ id: z.string(), label: z.string() }).nullable(), +}; + +export const statusResponseSchema = z.discriminatedUnion( + "type", + Object.entries(statusDetailsSchemas).map(([type, details]) => + z.strictObject({ ...statusBase, type: z.literal(type), details }), + ) as unknown as [ + z.ZodObject<{ type: z.ZodLiteral }>, + ...z.ZodObject<{ type: z.ZodLiteral }>[], + ], +); + +type DetailsOf = z.infer<(typeof statusDetailsSchemas)[T]>; +export type StatusResponseOf = { + correlationId: string; + agentId: string | null; + type: T; + status: z.infer; + failureCode: string | null; + createdAt: string; + updatedAt: string; + dispatchCommittedAt: string | null; + grant: { id: string; label: string } | null; + details: DetailsOf; +}; +export type StatusResponse = { [T in StatusType]: StatusResponseOf }[StatusType]; +export type ExecutionDetails = z.infer; + +export const statusQuerySchema = z.strictObject({ + correlationId: correlationIdSchema, + /** Long-poll up to this many milliseconds for a terminal or signature-needed status. */ + waitMs: z.coerce.number().int().min(0).max(30_000).default(0), +}); + +export const historyQuerySchema = z.strictObject({ + cursor: z.string().max(128).optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); +export const historyPageSchema = z.strictObject({ + data: z.array(statusResponseSchema), + nextCursor: z.string().nullable(), +}); +export type HistoryPage = { data: StatusResponse[]; nextCursor: string | null }; + +export type SwapRequest = z.input; +export type WithdrawRequest = z.input; +export type TransferRequest = z.input; +export type BalanceMoveRequest = z.input; +export type DepositRequest = z.input; +export type RecoverRequest = z.input; +export type SignMessageRequest = z.input; +export type RelayRequest = z.input; diff --git a/packages/contracts/src/api/index.ts b/packages/contracts/src/api/index.ts new file mode 100644 index 000000000..35a9060a2 --- /dev/null +++ b/packages/contracts/src/api/index.ts @@ -0,0 +1,18 @@ +/** + * Public wire contract of the `/v1` API. Everything a client sends or receives is defined here; + * `@near-intents-agent-api/contracts` (the package root) holds server-internal domain schemas. + */ +export type { OwnerType, OwnerWallet } from "../common.js"; +export { ownerWalletSchema, usdAmountSchema } from "../common.js"; +export * from "../destinations.js"; +export { grantTokenPattern } from "../grants.js"; +export type { Policy } from "../policy.js"; +export { policySchema } from "../policy.js"; +export * from "../quotas.js"; +export * from "./common.js"; +export * from "./endpoints.js"; +export * from "./executions.js"; +export * from "./intents.js"; +export { buildLlmsText } from "./llms.js"; +export { buildOpenApiDocument } from "./openapi.js"; +export * from "./views.js"; diff --git a/packages/contracts/src/api/intents.ts b/packages/contracts/src/api/intents.ts new file mode 100644 index 000000000..f8facb298 --- /dev/null +++ b/packages/contracts/src/api/intents.ts @@ -0,0 +1,312 @@ +import { z } from "zod"; +import { + authenticationResponseSchema, + nearAccountSchema, + ownerWalletSchema, + usdAmountSchema, +} from "../common.js"; +import { grantDestinationSchema } from "../destinations.js"; +import { grantCommitmentSchema, grantLabelSchema } from "../grants.js"; +import { policySchema } from "../policy.js"; +import { agentIdSchema, correlationIdSchema } from "./common.js"; +import { deletionPreviewSchema } from "./views.js"; + +/** + * Owner intents: every action that needs the owner's wallet. + * + * Mirrors the 1Click `generate-intent` / `submit-intent` pair. The server builds every byte the + * owner signs and returns it as `intent: { standard, payload }`; the wallet signs `payload` + * exactly as returned; the partner backend submits the wallet's output unchanged as + * `signedData`. The server verifies it against the stored intent and normalizes the encoding. + */ + +export const intentTypeSchema = z.enum([ + "agent_create", + "policy_update", + "agent_freeze", + "agent_unfreeze", + "grant_issue", + "grant_revoke", + "timelock_set", + "budget_set", + "execution_cancel", + "agent_archive", + "agent_restore", + "agent_delete", + "approval_vote", + "signing_artifact_read", + "signing_artifact_ack", +]); +export type IntentType = z.infer; + +export const signingStandardSchema = z.enum(["nep413", "nep366", "eip712", "webauthn"]); +export type SigningStandard = z.infer; + +// --------------------------------------------------------------------------------------------- +// Payloads, one per signing standard. Each is exactly what the wallet API takes. +// --------------------------------------------------------------------------------------------- + +/** NEAR NEP-413 message. near-connect: `wallet.signMessage({ message, recipient, nonce })`. */ +export const nep413PayloadSchema = z.strictObject({ + message: z.string(), + recipient: z.string(), + /** Base64 of the 32 nonce bytes. Decode to a `Uint8Array` before calling the wallet. */ + nonce: z.string().regex(/^[A-Za-z0-9+/]{43}=$/), +}); + +/** + * NEAR NEP-366 delegate action, in near-connect's `ConnectorAction` shape. + * near-connect: `wallet.signDelegateActions({ delegateActions: [payload] })`. The wallet picks + * its own access-key nonce and block height. Function-call `args` are serialized with + * `JSON.stringify`. + */ +export const nep366PayloadSchema = z.strictObject({ + receiverId: nearAccountSchema, + actions: z + .array( + z.strictObject({ + type: z.literal("FunctionCall"), + params: z.strictObject({ + methodName: z.string().min(1).max(128), + args: z.record(z.string(), z.unknown()), + gas: z.string().regex(/^[0-9]+$/), + deposit: z.string().regex(/^[0-9]+$/), + }), + }), + ) + .min(1) + .max(2), +}); + +/** EIP-712 typed data. viem: `walletClient.signTypedData({ account, ...payload })`. */ +export const eip712PayloadSchema = z.strictObject({ + domain: z.record(z.string(), z.unknown()), + types: z.record(z.string(), z.array(z.strictObject({ name: z.string(), type: z.string() }))), + primaryType: z.string(), + message: z.record(z.string(), z.unknown()), +}); + +/** + * WebAuthn `PublicKeyCredentialRequestOptionsJSON`. + * `startAuthentication({ optionsJSON: payload })` or `navigator.credentials.get`. + */ +export const webauthnPayloadSchema = z.strictObject({ + challenge: z.string().regex(/^[A-Za-z0-9_-]+$/), + rpId: z.string().min(1).max(253), + allowCredentials: z + .array(z.strictObject({ id: z.string(), type: z.literal("public-key") })) + .length(1), + userVerification: z.literal("required"), + timeout: z.number().int().positive(), +}); + +export const intentSchema = z.discriminatedUnion("standard", [ + z.strictObject({ standard: z.literal("nep413"), payload: nep413PayloadSchema }), + z.strictObject({ standard: z.literal("nep366"), payload: nep366PayloadSchema }), + z.strictObject({ standard: z.literal("eip712"), payload: eip712PayloadSchema }), + z.strictObject({ standard: z.literal("webauthn"), payload: webauthnPayloadSchema }), +]); +export type Intent = z.infer; +export type IntentOf = Extract; + +/** + * The wallet's output, sent back unchanged with the payload it signed. The server compares the + * payload with the stored intent byte for byte and accepts any common signature encoding. + */ +export const signedDataSchema = z.discriminatedUnion("standard", [ + z.strictObject({ + standard: z.literal("nep413"), + payload: nep413PayloadSchema, + /** `ed25519:` public key the wallet signed with. */ + public_key: z.string().startsWith("ed25519:").max(80), + /** Signature as the wallet returned it: base64, base58, `ed25519:` or hex. */ + signature: z.string().min(64).max(256), + }), + z.strictObject({ + standard: z.literal("nep366"), + payload: nep366PayloadSchema, + /** Base64 borsh `SignedDelegate`, e.g. `signedDelegateActions[0]` from near-connect. */ + signedDelegate: z.string().min(32).max(50_000), + }), + z.strictObject({ + standard: z.literal("eip712"), + payload: eip712PayloadSchema, + signature: z.string().regex(/^0x[0-9a-fA-F]{130}$/), + }), + z.strictObject({ + standard: z.literal("webauthn"), + payload: webauthnPayloadSchema, + /** The `AuthenticationResponseJSON` returned by the authenticator. */ + credential: authenticationResponseSchema, + }), +]); +export type SignedData = z.infer; + +// --------------------------------------------------------------------------------------------- +// generate-intent requests +// --------------------------------------------------------------------------------------------- + +const agentScoped = { agentId: agentIdSchema }; + +export const generateIntentRequestSchema = z.discriminatedUnion("type", [ + z.strictObject({ + type: z.literal("agent_create"), + name: z.string().trim().min(1).max(100), + externalUserId: z.string().min(1).max(200).optional(), + owner: ownerWalletSchema, + policy: policySchema, + }), + z.strictObject({ + type: z.literal("policy_update"), + ...agentScoped, + policy: policySchema, + /** Current applied revision from `GET /agents/{agentId}/policy`. */ + expectedRevision: z.number().int().nonnegative(), + }), + z.strictObject({ type: z.literal("agent_freeze"), ...agentScoped }), + z.strictObject({ type: z.literal("agent_unfreeze"), ...agentScoped }), + z.strictObject({ + type: z.literal("grant_issue"), + ...agentScoped, + /** Shown to the owner when signing and on every operation this grant authorizes. */ + label: grantLabelSchema, + /** + * SHA-256 hex of the grant token that will use this grant (`createGrantCredential()` in the + * SDK). Keep the token itself on your backend; it is never sent here. + */ + credential: grantCommitmentSchema, + /** Execution actions (`swap`, `withdraw`, …), `*`, or `sign:near_message` / `sign:evm_message`. */ + actions: z.array(z.string().min(1).max(64)).min(1).max(64), + /** Allowed destinations, refund addresses included. Empty keeps the grant to the agent's own balances. */ + recipients: z.array(grantDestinationSchema).max(256).default([]), + signingAudiences: z.array(z.string().min(1).max(256)).max(256).default([]), + /** When the delegation ends. At most 90 days ahead. */ + expiresAt: z.iso.datetime(), + }), + z.strictObject({ + type: z.literal("grant_revoke"), + ...agentScoped, + grantId: z.string().regex(/^[0-9a-f]{64}$/), + }), + z.strictObject({ + type: z.literal("timelock_set"), + ...agentScoped, + /** Delay applied to every execution; 0 disables. At most 30 days. */ + delaySeconds: z + .number() + .int() + .min(0) + .max(30 * 24 * 60 * 60), + }), + z.strictObject({ + type: z.literal("budget_set"), + ...agentScoped, + /** + * USD spend caps over rolling 24 hours, 7 days and 30 days, counted across every asset. Send + * any subset; an omitted or null window is uncapped. This replaces the previous caps. + */ + dailyUsd: usdAmountSchema.nullable().default(null), + weeklyUsd: usdAmountSchema.nullable().default(null), + monthlyUsd: usdAmountSchema.nullable().default(null), + }), + z.strictObject({ + type: z.literal("execution_cancel"), + ...agentScoped, + /** Correlation id of a `QUEUED` execution. */ + correlationId: correlationIdSchema, + }), + z.strictObject({ type: z.literal("agent_archive"), ...agentScoped }), + z.strictObject({ type: z.literal("agent_restore"), ...agentScoped }), + z.strictObject({ type: z.literal("agent_delete"), ...agentScoped }), + z.strictObject({ + type: z.literal("approval_vote"), + ...agentScoped, + approvalId: z.uuid(), + verdict: z.enum(["approve", "reject"]), + /** + * NEAR approver listed in the applied policy. Defaults to the agent's owner, which must then + * be a NEAR account. + */ + signer: z + .strictObject({ + accountId: nearAccountSchema, + publicKey: z.string().startsWith("ed25519:").max(80), + }) + .optional(), + }), + z.strictObject({ + type: z.literal("signing_artifact_read"), + ...agentScoped, + correlationId: correlationIdSchema, + }), + z.strictObject({ + type: z.literal("signing_artifact_ack"), + ...agentScoped, + correlationId: correlationIdSchema, + }), +]); +export type GenerateIntentRequest = z.input; +export type GenerateIntentRequestOf = Extract< + GenerateIntentRequest, + { type: T } +>; + +/** What the owner is about to authorize, for display next to the wallet prompt. */ +export const intentPreviewSchema = z.strictObject({ + summary: z.string(), + revision: z.number().int().nonnegative().optional(), + previousRevision: z.number().int().nonnegative().optional(), + policyHash: z.string().optional(), + deletion: deletionPreviewSchema.optional(), + approval: z + .strictObject({ + approvalId: z.uuid(), + requestType: z.string(), + requestHash: z.string(), + verdict: z.enum(["approve", "reject"]), + }) + .optional(), +}); +export type IntentPreview = z.infer; + +export const generateIntentResponseSchema = z.strictObject({ + correlationId: correlationIdSchema, + type: intentTypeSchema, + agentId: agentIdSchema, + status: z.literal("PENDING_SIGNATURE"), + /** Submit the signature before this instant. */ + expiresAt: z.iso.datetime(), + /** The identity whose wallet must sign. */ + signer: ownerWalletSchema, + intent: intentSchema, + preview: intentPreviewSchema, +}); +export type GenerateIntentResponse = z.infer; +/** Policy requests use NEP-366 for NEAR; consent requests use NEP-413. */ +export type GenerateIntentResponseOf = T extends IntentType + ? Omit & { + type: T; + intent: Extract< + Intent, + { + standard: + | "eip712" + | "webauthn" + | (T extends "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" + ? "nep366" + : "nep413"); + } + >; + } + : never; + +// --------------------------------------------------------------------------------------------- +// submit-intent +// --------------------------------------------------------------------------------------------- + +export const submitIntentRequestSchema = z.strictObject({ + type: intentTypeSchema, + correlationId: correlationIdSchema, + signedData: signedDataSchema, +}); +export type SubmitIntentRequest = z.infer; diff --git a/packages/contracts/src/api/llms.ts b/packages/contracts/src/api/llms.ts new file mode 100644 index 000000000..bbb07a654 --- /dev/null +++ b/packages/contracts/src/api/llms.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import { statusSchema, terminalStatuses } from "./common.js"; +import { type EndpointDefinition, endpoints } from "./endpoints.js"; +import { generateIntentRequestSchema } from "./intents.js"; + +/** + * Plain-text guide at `/llms.txt`, generated from the endpoint registry like the OpenAPI + * document, so an LLM calling the API over raw HTTP reads the same contract the server enforces. + */ + +/** `name`, or `name?` when the field may be omitted from the request. */ +function fields(schema: z.ZodType | undefined): string | undefined { + if (!(schema instanceof z.ZodObject)) return undefined; + return Object.entries(schema.shape as Record) + .map(([name, field]) => (field.safeParse(undefined).success ? `${name}?` : name)) + .join(", "); +} + +function endpointLine(endpoint: EndpointDefinition) { + const notes = [ + endpoint.idempotency === "required" ? "Idempotency-Key required" : undefined, + endpoint.idempotency === "optional" ? "Idempotency-Key optional" : undefined, + ].filter(Boolean); + const query = fields(endpoint.query); + const body = fields(endpoint.body); + return [ + `- ${endpoint.method.toUpperCase()} ${endpoint.path}: ${endpoint.summary}.`, + notes.length ? ` (${notes.join("; ")})` : "", + query ? `\n query: ${query}` : "", + body ? `\n body: ${body}` : "", + ].join(""); +} + +function intentTypeLines() { + return generateIntentRequestSchema.options.map((option) => { + const { type, ...rest } = option.shape; + const names = fields(z.strictObject(rest)); + return `- ${(type as z.ZodLiteral).value}${names ? `: ${names}` : ""}`; + }); +} + +function endpointSections() { + const groups = new Map(); + for (const endpoint of Object.values(endpoints) as EndpointDefinition[]) { + if (endpoint.auth === "session") continue; + groups.set(endpoint.tag, [...(groups.get(endpoint.tag) ?? []), endpoint]); + } + return [...groups].flatMap(([tag, list]) => ["", `### ${tag}`, ...list.map(endpointLine)]); +} + +export function buildLlmsText(input: { serverUrl?: string } = {}) { + const base = input.serverUrl ?? ""; + return [ + "# NEAR Intents Agent API", + "", + "> Backend HTTP API for AI agents that trade on NEAR Intents under an owner-signed policy. Shaped like the NEAR Intents 1Click API. No SDK is needed: every call is plain JSON over HTTPS.", + "", + `Full schemas: ${base}/openapi.json (OpenAPI 3.1, generated from the same registry as this file).`, + "", + "## Conventions", + "", + "- Authenticate with the `X-API-Key: naa_…` header. The key belongs to a partner backend; never send it to a browser.", + "- JSON bodies with camelCase fields. Signed objects (policies, `intent.payload`) keep their own field names; send them back byte-for-byte.", + "- Execution amounts and balanceRaw are atomic integer strings. Balance entries use assetId and token metadata; balance is an exact token-unit decimal string, or null if decimals are unknown.", + "- Lists return `{ data, nextCursor }`; pass `cursor=` for the next page.", + "- Writes that create work take an `Idempotency-Key` header (8–128 chars of `A-Za-z0-9._:-`). Retry with the same key and body; never reuse a key for new work.", + "- Errors are JSON:API documents: `{ errors: [{ status, code, title, detail?, source?: { pointer }, meta?: { retryable } }], meta: { requestId } }`. Branch on `errors[0].code` (kebab-case), never on `title`. Retry only when `meta.retryable` is true, honoring `Retry-After`.", + "", + "## Owner actions: generate → sign → submit → status", + "", + "Anything that needs the owner's wallet (creating an agent, changing its policy, grants, freeze, delete, approval votes, …) is one owner intent:", + "", + "1. `POST /v1/generate-intent` with `{ type, ...fields }`. The response has `correlationId`, `signer` (the wallet that must sign), `intent: { standard, payload }`, a human-readable `preview` and `expiresAt`. Store `correlationId`.", + "2. The owner's wallet signs `intent.payload` unchanged, according to `intent.standard`:", + " - `nep413` (NEAR message): `wallet.signMessage({ message, recipient, nonce })` with `nonce` base64-decoded to 32 bytes. signedData: `{ standard, payload, public_key, signature }`.", + " - `nep366` (NEAR delegate action): `wallet.signDelegateActions({ delegateActions: [payload] })`. signedData: `{ standard, payload, signedDelegate }` (base64 borsh SignedDelegate).", + " - `eip712` (EVM typed data): `signTypedData({ account, ...payload })`. signedData: `{ standard, payload, signature }` (0x, 65 bytes).", + " - `webauthn` (passkey): `startAuthentication({ optionsJSON: payload })`. signedData: `{ standard, payload, credential }` (AuthenticationResponseJSON).", + "3. `POST /v1/submit-intent` with `{ type, correlationId, signedData }`. Resubmitting the same signature is safe.", + "4. `GET /v1/status?correlationId=…&waitMs=30000` until the status is terminal.", + "", + "A policy update is one signature. Read `GET /v1/agents/{agentId}/policy` first and pass its `revision` as `expectedRevision`; on `policy-revision-conflict`, read again and generate a new intent.", + "", + "Intent types and their fields (`?` = optional):", + "", + ...intentTypeLines(), + "", + "## Agent actions", + "", + "Swaps, withdrawals, transfers and signing need no owner signature at call time, only `X-Grant-Token` for an active owner grant. Create the token on your backend (`createGrantCredential()` in the SDK), put its `commitment` and a `label` in `grant_issue`, and keep the token: one grant per user session, assistant or bot, each revocable on its own. The API key only identifies you. A grant with no `recipients` covers only the agent's own balances (swaps, shield, unshield, deposits without a refund address); withdrawals, transfers and deposit refund addresses need each destination listed in the signed grant, and signing needs an explicit audience. Each call returns a status with a `correlationId`; track it with `GET /v1/status`. `dry: true` on swap and withdraw returns a quote and needs no Idempotency-Key.", + "", + "Grant v4 recipients contain action, kind, chain, network, address, memo (none or exact value), and purpose. signingAudiences are separate. Legacy address-only grants require new consent; they cannot authorize execution after migration.", + "", + "## One account, shared rules", + "", + "An agent is one financial account. Access belongs to a grant; limits and counted usage belong to the account. Every operation must pass the grant (action, exact destination, expiry), the account's OutLayer policy (tokens, capabilities, per-token limits, approvals, freeze), its USD budget and its execution delay; none overrides or adds allowance to another. A larger USD budget never lifts a per-token limit (`policy-denied`), and a looser policy never widens a grant (`grant-action-denied`, `grant-recipient-denied`): ask the owner to change the layer that refused. Account rule changes apply to every connection; there are no per-grant budgets. A signed policy revision is in force only once `GET /v1/agents/{agentId}/policy` shows `APPLIED` with `providerPolicySynced: true`; until then executions fail with `policy-not-ready`. `/limits` shows configured provider rules, not remaining per-token allowance. `GET /v1/agents/{agentId}/timelock` reports `scheduledCount`; list the executions it holds with `GET /v1/agents/{agentId}/timelock/scheduled`, following `nextCursor`.", + "", + "## Spending budget", + "", + "One account budget is shared by humans, agents, scripts and all grants. Signed limit changes never reset usage. Once configured, usage remains tracked even while caps are cleared. The owner can cap what an account spends in USD across all assets, on top of the per-asset limits in its policy: `budget_set` with any of `dailyUsd`, `weeklyUsd`, `monthlyUsd` (rolling 24 hours, 7 days, 30 days; an omitted window is uncapped, and each call replaces the previous caps). Swaps, withdrawals and transfers count at their USD value when they dispatch, priced from a 1Click quote under 10 minutes old whose decimals match OutLayer's (the same rule `GET /v1/tokens` applies: `price` is null otherwise, and unusable after `priceExpiresAt`); shield, unshield and deposits do not. A cap the owner sets before an execution dispatches applies to it, including one queued earlier; a dispatch that already happened is never retracted. `GET /v1/agents/{agentId}/budget` shows each cap, what is charged (including work whose outcome is still uncertain) and what remains. An execution over the remaining budget fails with `spend-budget-exceeded` (403); one for an asset with no current price fails with `spend-price-unavailable` (503). Neither reaches the provider, and the operation records that refusal: replaying its Idempotency-Key returns it, so once budget frees up send a new key for a new attempt. Never send a new key for an operation that is uncertain or already dispatched; read its status instead.", + "", + "## Status values", + "", + `${statusSchema.options.map((status) => `\`${status}\``).join(", ")}. Terminal: ${terminalStatuses.map((status) => `\`${status}\``).join(", ")}.`, + "", + "`UNCERTAIN` means the outcome is unknown: keep polling the same correlationId and never resubmit under a new Idempotency-Key. `PENDING_SIGNATURE` means the intent still waits for the owner's wallet.", + "", + "## Endpoints", + ...endpointSections(), + "", + ].join("\n"); +} diff --git a/packages/contracts/src/api/openapi.ts b/packages/contracts/src/api/openapi.ts new file mode 100644 index 000000000..0fda519f1 --- /dev/null +++ b/packages/contracts/src/api/openapi.ts @@ -0,0 +1,218 @@ +import { z } from "zod"; +import { ownerWalletSchema } from "../common.js"; +import { policySchema } from "../policy.js"; +import { errorDocumentSchema } from "./common.js"; +import { type EndpointDefinition, endpoints } from "./endpoints.js"; +import { executionDetailsSchema, statusResponseSchema } from "./executions.js"; +import { + generateIntentResponseSchema, + intentPreviewSchema, + intentSchema, + signedDataSchema, +} from "./intents.js"; +import { + agentViewSchema, + budgetViewSchema, + deletionPreviewSchema, + grantViewSchema, + limitsViewSchema, + policyViewSchema, + timelockViewSchema, +} from "./views.js"; + +/** + * OpenAPI 3.1 document generated from the endpoint registry. Request schemas are rendered as + * inputs (defaults make fields optional), response schemas as outputs. + */ + +const errorStatuses = ["400", "401", "403", "404", "409", "410", "429", "502", "503"] as const; + +/** Shared schemas rendered once under `components/schemas` and referenced everywhere else. */ +const components = z.registry<{ id: string }>(); +for (const [id, schema] of Object.entries({ + Policy: policySchema, + OwnerWallet: ownerWalletSchema, + Intent: intentSchema, + SignedData: signedDataSchema, + IntentPreview: intentPreviewSchema, + GenerateIntentResponse: generateIntentResponseSchema, + StatusResponse: statusResponseSchema, + ExecutionDetails: executionDetailsSchema, + AgentView: agentViewSchema, + GrantView: grantViewSchema, + TimelockView: timelockViewSchema, + BudgetView: budgetViewSchema, + PolicyView: policyViewSchema, + LimitsView: limitsViewSchema, + DeletionPreview: deletionPreviewSchema, + ErrorDocument: errorDocumentSchema, +})) + components.add(schema, { id }); + +type JsonObject = Record; + +/** Converts one schema and moves its shared definitions into `definitions`. */ +function jsonSchema( + schema: z.ZodType, + io: "input" | "output", + definitions: Record, +): JsonObject { + const rendered = z.toJSONSchema(schema, { + io, + unrepresentable: "any", + metadata: components, + cycles: "ref", + reused: "inline", + }) as JsonObject; + const text = JSON.stringify(rendered).replaceAll('"#/$defs/', '"#/components/schemas/'); + const { + $defs, + $schema: _schema, + ...root + } = JSON.parse(text) as JsonObject & { + $defs?: Record; + }; + for (const [id, definition] of Object.entries($defs ?? {})) definitions[id] ??= definition; + const id = components.get(schema)?.id; + if (!id) return root; + definitions[id] ??= root; + return { $ref: `#/components/schemas/${id}` }; +} + +function parameters(endpoint: EndpointDefinition, definitions: Record) { + const result: Record[] = []; + if (endpoint.params) { + const schema = jsonSchema(endpoint.params, "input", definitions) as { + properties?: Record; + }; + for (const [name, property] of Object.entries(schema.properties ?? {})) + result.push({ name, in: "path", required: true, schema: property }); + } + if (endpoint.query) { + const schema = jsonSchema(endpoint.query, "input", definitions) as { + properties?: Record; + required?: string[]; + }; + for (const [name, property] of Object.entries(schema.properties ?? {})) + result.push({ + name, + in: "query", + required: schema.required?.includes(name) ?? false, + schema: property, + }); + } + if (endpoint.idempotency) + result.push({ + name: "Idempotency-Key", + in: "header", + required: endpoint.idempotency === "required", + description: + "Stable key for one logical request. Replays with the same key and body return the original result; a different body is refused with `idempotency-conflict`.", + schema: { type: "string", minLength: 8, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" }, + }); + return result; +} + +function security(endpoint: EndpointDefinition) { + if (endpoint.auth === "none") return []; + if (endpoint.grant) return [{ ApiKey: [], GrantToken: [] }]; + return [{ [endpoint.auth === "apiKey" ? "ApiKey" : "DeveloperSession"]: [] }]; +} + +function operation(endpoint: EndpointDefinition, definitions: Record) { + const errorDescription = endpoint.errors?.length + ? `Error document. Documented codes: ${endpoint.errors.map((code) => `\`${code}\``).join(", ")}.` + : "Error document."; + return { + operationId: endpoint.operationId, + tags: [endpoint.tag], + summary: endpoint.summary, + ...(endpoint.description ? { description: endpoint.description } : {}), + security: security(endpoint), + parameters: parameters(endpoint, definitions), + ...(endpoint.body + ? { + requestBody: { + required: true, + content: { + "application/json": { schema: jsonSchema(endpoint.body, "input", definitions) }, + }, + }, + } + : {}), + responses: { + [endpoint.status]: { + description: "Success", + content: { + "application/json": { schema: jsonSchema(endpoint.response, "output", definitions) }, + }, + }, + ...Object.fromEntries( + errorStatuses.map((status) => [ + status, + { + description: errorDescription, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorDocument" }, + }, + }, + }, + ]), + ), + }, + }; +} + +export function buildOpenApiDocument(input: { serverUrl?: string } = {}) { + const paths: Record> = {}; + const definitions: Record = {}; + jsonSchema(errorDocumentSchema, "output", definitions); + for (const endpoint of Object.values(endpoints) as EndpointDefinition[]) { + paths[endpoint.path] ??= {}; + (paths[endpoint.path] as Record)[endpoint.method] = operation( + endpoint, + definitions, + ); + } + const tags = [...new Set(Object.values(endpoints).map((endpoint) => endpoint.tag))]; + return { + openapi: "3.1.0", + info: { + title: "NEAR Intents Agent API", + version: "1.0.0", + description: [ + "Backend API for AI agents that trade on NEAR Intents under an owner-signed policy.", + "", + "Authenticate with `X-API-Key`. Keep the key on your backend.", + "", + "Owner actions use generate-intent → owner wallet signs `intent.payload` unchanged → submit-intent → GET /v1/status. Agent actions (swap, withdraw, transfer, …) need no owner signature, only `X-Grant-Token` for an owner grant that allows them, and return a `correlationId` for GET /v1/status.", + "", + "Errors are JSON:API documents. Branch on `errors[].code` (kebab-case), never on `title`. `UNCERTAIN` means the outcome is unknown: keep polling and never resubmit under a new `Idempotency-Key`.", + ].join("\n"), + }, + ...(input.serverUrl ? { servers: [{ url: input.serverUrl }] } : {}), + tags: tags.map((name) => ({ name })), + paths, + components: { + schemas: definitions, + securitySchemes: { + ApiKey: { type: "apiKey", in: "header", name: "X-API-Key" }, + GrantToken: { + type: "apiKey", + in: "header", + name: "X-Grant-Token", + description: + "Token of the owner grant a delegated request runs under. The partner creates it and the owner signs its commitment; keep it on your backend.", + }, + DeveloperSession: { + type: "apiKey", + in: "cookie", + name: "better-auth.session_token", + description: + "Partner dashboard session. Key mutations also require a trusted Origin and a login within five minutes.", + }, + }, + }, + }; +} diff --git a/packages/contracts/src/api/views.ts b/packages/contracts/src/api/views.ts new file mode 100644 index 000000000..76839e73f --- /dev/null +++ b/packages/contracts/src/api/views.ts @@ -0,0 +1,320 @@ +import { z } from "zod"; +import { grantDestinationSchema } from "../destinations.js"; +import { balanceEntrySchema, tokenViewSchema } from "../tokens.js"; + +export { balanceEntrySchema, tokenViewSchema } from "../tokens.js"; + +import { + idSchema, + nearAccountSchema, + ownerNearSchema, + ownerWalletSchema, + usdAmountSchema, +} from "../common.js"; +import { historicalGrantMessageSchema } from "../grants.js"; +import { policySchema, policyTransactionTypeSchema } from "../policy.js"; +import { atomicAmountSchema, correlationIdSchema, pageSchema } from "./common.js"; + +/** + * Read models of the public API. Resource and lifecycle states use UPPER_SNAKE values. + * Policy rules are the owner-signed provider policy and keep its field names. + */ + +export const walletViewSchema = z.strictObject({ + walletId: z.string().min(1).max(256), + nearAccountId: nearAccountSchema, + evmAddress: z + .string() + .regex(/^0x[0-9a-f]{40}$/) + .nullable(), +}); + +export const agentViewSchema = z.strictObject({ + id: idSchema, + name: z.string(), + externalUserId: z.string().nullable(), + status: z.enum(["PENDING", "ACTIVE", "ARCHIVED", "DELETED", "ABANDONED"]), + archived: z.boolean(), + deleted: z.boolean(), + /** Public owner identity; null until onboarding lands. */ + owner: ownerWalletSchema.nullable(), + /** The owner's NEAR authority account: the named account, or its deterministic `0s` wallet. */ + ownerAccount: ownerNearSchema.nullable(), + wallet: walletViewSchema.nullable(), + createdAt: z.string(), +}); + +export const agentListQuerySchema = z.strictObject({ + externalUserId: z.string().min(1).max(200).optional(), + cursor: idSchema.optional(), +}); +export const agentPageSchema = pageSchema(agentViewSchema); + +export const balanceSourceSchema = z.enum(["public", "confidential"]); +export const balanceQuerySchema = z.strictObject({ + source: balanceSourceSchema.default("public"), + asset: z.string().min(1).max(256).optional(), +}); +export const balancesViewSchema = z.strictObject({ + nearAccountId: nearAccountSchema, + source: balanceSourceSchema, + balances: z.array(balanceEntrySchema).max(4096), +}); + +export const tokenListViewSchema = z.strictObject({ data: z.array(tokenViewSchema).max(4096) }); + +export const policyStateSchema = z.enum(["NONE", "SIGNED", "APPLIED", "FAILED"]); +export const policyViewSchema = z.strictObject({ + walletId: z.string().min(1).max(256), + revision: z.number().int().nonnegative().nullable(), + policyHash: idSchema.nullable(), + status: policyStateSchema, + appliedAt: z.string().nullable(), + transactionHash: z.string().max(128).nullable(), + /** True when OutLayer's readback matches the applied revision. */ + providerPolicySynced: z.boolean(), + policy: policySchema.nullable(), +}); +export const policyRevisionViewSchema = policyViewSchema.omit({ providerPolicySynced: true }); +export const policyHistoryQuerySchema = z.strictObject({ + cursor: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); +export const policyHistoryViewSchema = z.strictObject({ + data: z.array(policyRevisionViewSchema), + /** Pass as `cursor` to read older revisions. */ + nextCursor: z.number().int().positive().nullable(), +}); + +const policyShape = policySchema.shape; +const rulesShape = policyShape.rules.shape; +export const limitsViewSchema = z.strictObject({ + walletId: z.string().min(1).max(256), + frozen: z.boolean().nullable(), + capabilities: policyShape.capabilities.nullable(), + limits: rulesShape.limits.unwrap().nullable(), + rateLimit: rulesShape.rate_limit.unwrap().nullable(), + addresses: rulesShape.addresses.unwrap().nullable(), + allowedTokens: rulesShape.allowed_tokens.nullable(), + transactionTypes: z.array(policyTransactionTypeSchema).nullable(), + approval: policyShape.approval.unwrap().nullable(), + policySynced: z.boolean(), +}); + +export const grantViewSchema = z.strictObject({ + grantId: idSchema, + agentId: idSchema, + walletId: z.string(), + /** The owner-signed name of this grant. */ + label: z.string(), + actions: z.array(z.string()), + recipients: z.array(grantDestinationSchema), + signingAudiences: z.array(z.string()), + issuedAt: z.string(), + expiresAt: z.string(), + revokedAt: z.string().nullable(), + revokedReason: z.string().nullable(), + ownerEpoch: z.number().int().positive(), + /** The canonical owner-signed grant message; the fields above restate it exactly. */ + ownerMessage: historicalGrantMessageSchema, +}); +export const grantListViewSchema = z.strictObject({ data: z.array(grantViewSchema) }); + +export const timelockViewSchema = z.strictObject({ + delaySeconds: z.number().int().nonnegative(), + revision: z.number().int().nonnegative(), + /** Executions waiting for release or being dispatched. List them with `listScheduledExecutions`. */ + scheduledCount: z.number().int().nonnegative(), + enforcedBy: z.literal("agent_api"), +}); +export const scheduledExecutionSchema = z.strictObject({ + correlationId: correlationIdSchema, + executeAfter: z.iso.datetime(), + state: z.enum(["waiting", "dispatching"]), + action: z.string().nullable(), +}); +export const scheduledQuerySchema = z.strictObject({ + cursor: z.string().max(128).optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); +/** Earliest release first. `nextCursor` is null on the last page. */ +export const scheduledPageSchema = z.strictObject({ + data: z.array(scheduledExecutionSchema), + nextCursor: z.string().nullable(), +}); + +const budgetWindowViewSchema = z.strictObject({ + /** The owner's cap for this window in USD; null when the window is uncapped. */ + limitUsd: usdAmountSchema.nullable(), + /** USD counted in the window, six decimals. */ + spentUsd: z.string(), + remainingUsd: z.string().nullable(), + /** When the oldest counted spend leaves the window; null when nothing is counted. */ + resetsAt: z.iso.datetime().nullable(), +}); + +/** Owner-signed USD spend caps enforced by this API on top of the provider's per-asset limits. */ +export const budgetViewSchema = z.strictObject({ + daily: budgetWindowViewSchema, + weekly: budgetWindowViewSchema, + monthly: budgetWindowViewSchema, + revision: z.number().int().nonnegative(), + enforcedBy: z.literal("agent_api"), +}); + +export const approvalViewSchema = z.strictObject({ + approvalId: z.uuid(), + walletId: z.string().min(1), + walletPubkey: z.string().min(1), + requestHash: z.string().min(1), + status: z.enum(["PENDING", "APPROVED", "REJECTED", "EXPIRED"]), + requestType: z.string(), + requestData: z.record(z.string(), z.unknown()), + requiredApprovals: z.number().int().positive(), + expiresAt: z.string(), +}); + +export const networkViewSchema = z.strictObject({ + network: z.literal("mainnet"), + contractId: nearAccountSchema, + supportedOwnerTypes: z.array(z.enum(["near", "evm", "passkey"])), + intents: z.enum(["available", "provider_limited"]), +}); + +export const whoamiViewSchema = z.strictObject({ + apiKeyId: z.string(), + tenantId: z.string(), +}); + +export const containmentQuerySchema = z.strictObject({ + grantsCursor: idSchema.optional(), + operationsCursor: idSchema.optional(), +}); +const inventorySchema = z.strictObject({ + hasMore: z.boolean(), + nextCursor: z.string().nullable(), + data: z.array(z.record(z.string(), z.unknown())), +}); +export const containmentViewSchema = z.strictObject({ + generatedAt: z.string(), + wallet: z + .strictObject({ + walletId: z.string().nullable(), + nearAccountId: z.string().nullable(), + evmAddress: z.string().nullable(), + status: z.string(), + }) + .nullable(), + grants: inventorySchema, + pendingOperations: inventorySchema, + providerBoundary: z.strictObject({ + enforcement: z.literal("unverified"), + directProviderKeyBypass: z.literal("unverified"), + }), +}); + +export const deletionPreviewSchema = z.strictObject({ + nearAccountId: nearAccountSchema, + /** Receives the custody account's native NEAR when the account is deleted. */ + beneficiary: nearAccountSchema, + nativeBalance: atomicAmountSchema, + /** Non-zero public balances that deletion destroys. */ + public: z.array(balanceEntrySchema), + /** Non-zero confidential balances that deletion destroys. */ + confidential: z.array(balanceEntrySchema), + assetsLost: z.boolean(), + retirement: z.enum(["provider_delete", "credential_erase"]), + policyAllowsDelete: z.boolean(), +}); + +export const nearSignatureArtifactSchema = z.strictObject({ + chain: z.literal("near"), + nearAccountId: nearAccountSchema, + publicKey: z.string(), + recipient: z.string(), + nonce: z.string(), + signature: z.string().regex(/^[0-9a-f]{128}$/), +}); +export const evmSignatureArtifactSchema = z.strictObject({ + chain: z.string(), + evmAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/), + signature: z.string().regex(/^0x[0-9a-fA-F]{130}$/), +}); +export const signatureDeliverySchema = z.strictObject({ + correlationId: correlationIdSchema, + artifact: z.union([nearSignatureArtifactSchema, evmSignatureArtifactSchema]), + expiresAt: z.string(), +}); +export const acknowledgementSchema = z.strictObject({ acknowledged: z.literal(true) }); + +export const developerKeySchema = z.strictObject({ + id: z.string(), + name: z.string(), + prefix: z.string(), + expiresAt: z.string(), + revokedAt: z.string().nullable(), +}); +export const developerKeyListSchema = z.strictObject({ data: z.array(developerKeySchema) }); +export const createdDeveloperKeySchema = developerKeySchema.extend({ + /** Plaintext key. Shown once; store it in a secret manager. */ + token: z.string(), +}); +export const createDeveloperKeySchema = z.strictObject({ + name: z.string().trim().min(1).max(100), + expiresAt: z.iso.datetime(), +}); +export const revocationViewSchema = z.strictObject({ + revoked: z.literal(true), + /** Delegated work that reached its dispatch commitment before the revocation. */ + committedCorrelationIds: z.array(correlationIdSchema), + /** True when more work than `committedCorrelationIds` lists had committed; the list holds the oldest. */ + committedTruncated: z.boolean(), +}); +export const developerDashboardSchema = z.strictObject({ + agents: z.number().int().nonnegative(), + operations: z.number().int().nonnegative(), + pending: z.number().int().nonnegative(), + uncertain: z.number().int().nonnegative(), + settled: z.number().int().nonnegative(), + observedAt: z.string(), + period: z.literal("all_time"), +}); +export const managedAgentSchema = z.strictObject({ + id: z.string(), + name: z.string(), + externalUserId: z.string().nullable(), + ownerAccountId: z.string().nullable(), + ownerWalletBound: z.boolean(), + walletProvisioned: z.boolean(), + policyStatus: policyStateSchema, +}); +export const managedAgentPageSchema = pageSchema(managedAgentSchema); +export const managedAgentQuerySchema = z.strictObject({ cursor: idSchema.optional() }); + +export type WalletView = z.infer; +export type AgentView = z.infer; +export type AgentPage = z.infer; +export type BalanceSource = z.infer; +export type BalanceEntry = z.infer; +export type BalancesView = z.infer; +export type TokenView = z.infer; +export type PolicyView = z.infer; +export type PolicyRevisionView = z.infer; +export type PolicyHistoryView = z.infer; +export type LimitsView = z.infer; +export type GrantView = z.infer; +export type TimelockView = z.infer; +export type ScheduledExecutionView = z.infer; +export type ScheduledPage = z.infer; +export type BudgetView = z.infer; +export type ApprovalView = z.infer; +export type NetworkView = z.infer; +export type WhoamiView = z.infer; +export type ContainmentView = z.infer; +export type DeletionPreview = z.infer; +export type SignatureDelivery = z.infer; +export type DeveloperKey = z.infer; +export type CreatedDeveloperKey = z.infer; +export type RevocationView = z.infer; +export type DeveloperDashboard = z.infer; +export type ManagedAgent = z.infer; diff --git a/packages/contracts/src/approvals.ts b/packages/contracts/src/approvals.ts new file mode 100644 index 000000000..c29284391 --- /dev/null +++ b/packages/contracts/src/approvals.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { idSchema, nearAccountSchema, nearPublicKeySchema } from "./common.js"; + +const approvalVerdictSchema = z.strictObject({ verdict: z.enum(["approve", "reject"]) }); +const approvalVoteSchema = approvalVerdictSchema.extend({ + account_id: nearAccountSchema, + public_key: nearPublicKeySchema, + signature: z.string().regex(/^[0-9a-f]{128}$/), + nonce: idSchema, + request_hash: z.string().min(1).max(128), +}); +export const approvalDetailSchema = z.object({ + id: z.uuid(), + wallet_id: z.string().min(1), + wallet_pubkey: z.string().min(1), + request_hash: z.string().min(1), + status: z.enum(["pending", "approved", "rejected", "expired"]), + request_type: z.string(), + request_data: z.record(z.string(), z.unknown()), + op: z.record(z.string(), z.unknown()).nullable().optional(), + required_approvals: z.number().int().positive(), + // Public provider receipts. Older responses may omit them; absence proves nothing. + approvers: z + .array( + z.object({ + approver_id: z.string(), + approver_role: z.string(), + signature: z.string(), + created_at: z.string(), + }), + ) + .optional(), + expires_at: z.string(), +}); +export type ApprovalVote = z.infer; +export type ApprovalDetail = z.infer; diff --git a/packages/contracts/src/borsh.ts b/packages/contracts/src/borsh.ts new file mode 100644 index 000000000..125b31606 --- /dev/null +++ b/packages/contracts/src/borsh.ts @@ -0,0 +1,72 @@ +/** + * Little-endian Borsh writers shared by every wallet-state and offchain-message encoder. + * + * These bytes are hashed into signed wire formats (NEP-616/EIP-712 wallet ids, NEP-641 + * challenges), so a change here changes what an owner's signature commits to. The outputs are + * pinned against near/intents Rust contract vectors in `tests/unit/owner`. + */ + +export function concat(...parts: Uint8Array[]): Uint8Array { + const output = new Uint8Array(parts.reduce((length, part) => length + part.length, 0)); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.length; + } + return output; +} + +/** `u32`: 4-byte little-endian unsigned. */ +export function borshU32(value: number): Uint8Array { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value, true); + return bytes; +} + +/** `u64`: 8-byte little-endian unsigned. */ +export function borshU64(value: bigint): Uint8Array { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigUint64(0, value, true); + return bytes; +} + +/** `u128`: 16-byte little-endian unsigned. */ +export function borshU128(value: bigint): Uint8Array { + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + view.setBigUint64(0, value & ((1n << 64n) - 1n), true); + view.setBigUint64(8, value >> 64n, true); + return bytes; +} + +/** Width-checked little-endian integer, for the field sizes the wallet contract declares. */ +export function borshInteger(value: bigint, size: 8 | 16): Uint8Array { + if (value < 0n || value >= 1n << BigInt(size * 8)) throw new Error("wallet_integer_out_of_range"); + const bytes = new Uint8Array(size); + for (let index = 0; index < size; index++) + bytes[index] = Number((value >> BigInt(8 * index)) & 255n); + return bytes; +} + +/** `Vec`: 4-byte length prefix followed by the raw bytes. */ +export function borshBytes(value: Uint8Array): Uint8Array { + return concat(borshU32(value.length), value); +} + +/** `String`: length-prefixed UTF-8. */ +export function borshString(value: string): Uint8Array { + return borshBytes(new TextEncoder().encode(value)); +} + +/** + * `u64` nanoseconds from an RFC 3339 UTC timestamp, preserving up to 9 fractional digits. + * `requireWholeSeconds` rejects a sub-second fraction when the consumer does (NEP-641). + */ +export function borshTimestampNanos(timestamp: string, requireWholeSeconds = false): bigint { + const parts = /^(.*?)(?:\.(\d{1,9}))?Z$/.exec(timestamp); + if (!parts) throw new Error("invalid_timestamp"); + const milliseconds = Date.parse(`${parts[1]}Z`); + if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new Error("invalid_timestamp"); + if (requireWholeSeconds && milliseconds % 1000 !== 0) throw new Error("invalid_timestamp"); + return BigInt(milliseconds) * 1_000_000n + BigInt((parts[2] ?? "").padEnd(9, "0") || "0"); +} diff --git a/packages/contracts/src/common.ts b/packages/contracts/src/common.ts new file mode 100644 index 000000000..1a3753eb6 --- /dev/null +++ b/packages/contracts/src/common.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; + +export const idSchema = z.string().regex(/^[0-9a-f]{64}$/); + +export const nearAccountSchema = z.string().min(2).max(64); + +export const nearPublicKeySchema = z + .string() + .regex( + /^(?:ed25519:[1-9A-HJ-NP-Za-km-z]{32,44}|p256:[1-9A-HJ-NP-Za-km-z]{43,46}|secp256k1:[1-9A-HJ-NP-Za-km-z]{80,90})$/, + ); + +/** Owner authentication is separate from the derived NEAR identity and agent custody. */ +const ownerTypeSchema = z.enum(["near", "evm", "passkey"]); +export const supportedOwnerTypes = ownerTypeSchema.options; +const base64url = z.string().regex(/^[A-Za-z0-9_-]+$/); +const nearOwnerWalletSchema = z.strictObject({ + type: z.literal("near"), + accountId: nearAccountSchema, + publicKey: z.string().regex(/^ed25519:[1-9A-HJ-NP-Za-km-z]{32,44}$/), +}); +export const ownerWalletSchema = z.discriminatedUnion("type", [ + nearOwnerWalletSchema, + z.strictObject({ + type: z.literal("evm"), + address: z.string().regex(/^0x[0-9a-f]{40}$/), + chainId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + publicKey: z.string().regex(/^0x[0-9a-f]{128}$/), + }), + z.strictObject({ + type: z.literal("passkey"), + credentialId: base64url, + publicKey: base64url, + rpId: z.string().min(1).max(253), + origin: z.string().url(), + }), +]); +export const authenticationResponseSchema = z.object({ + id: base64url, + rawId: base64url, + type: z.literal("public-key"), + response: z.object({ + clientDataJSON: base64url, + authenticatorData: base64url, + signature: base64url, + userHandle: base64url.optional(), + }), + clientExtensionResults: z.record(z.string(), z.unknown()), + authenticatorAttachment: z.enum(["platform", "cross-platform"]).optional(), +}); +export const ownerProofSchema = z.union([ + z.strictObject({ + signature: z + .string() + .regex(/^(?:[0-9a-f]{128}|0x[0-9a-fA-F]{130}|[1-9A-HJ-NP-Za-km-z]{64,88})$/), + }), + z.strictObject({ authentication: authenticationResponseSchema }), + z.strictObject({ walletAuthorization: z.string().min(1).max(32_768) }), +]); +export const ownerNearSchema = z.strictObject({ + accountId: nearAccountSchema, + publicKey: nearPublicKeySchema, + authority: z.literal("wallet"), +}); +export const ownerSigningRequestSchema = z.strictObject({ + owner: ownerWalletSchema, + message: z.string(), + nonce: idSchema, + recipient: z.string(), + challenge: base64url, +}); + +/** + * The header every owner-signed consent envelope carries. + * + * An owner signs canonical JSON, and the verifier re-derives the same set from server state, so + * these ten bindings must mean the same thing in every ceremony: which owner consented, to which + * tenant/agent, on which network, over which custody account and key, delivered to which + * recipient, with which single-use nonce, inside which freshness window. Declaring them once keeps + * a rule change (a wider network set, a longer window) from silently applying to only some + * ceremonies. + * + * `agentGrantMessageSchema` deliberately does not use this: a grant is a longer-lived delegation + * with its own nonce and recipient constraints. + */ +export const signedMessageFields = { + owner: ownerWalletSchema, + tenant_id: idSchema, + agent_id: idSchema, + network: z.literal("mainnet"), + account_id: nearAccountSchema, + public_key: nearPublicKeySchema, + recipient: z.string().min(1).max(253), + nonce: idSchema, + issued_at_ms: z.number().int().nonnegative(), + expires_at_ms: z.number().int().positive(), +}; +export type OwnerSigningRequest = z.infer; +export type OwnerNear = z.infer; +export type NearOwnerWallet = z.infer; +export type OwnerWallet = z.infer; +export type OwnerType = z.infer; +export type OwnerProof = z.infer; + +export function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value !== null && typeof value === "object") + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical((value as Record)[key])}`) + .join(",")}}`; + const result = JSON.stringify(value); + if (result === undefined) throw new Error("Value is not JSON"); + return result; +} + +/** A USD amount with at most two decimals, e.g. `200` or `1000.50`. */ +export const usdAmountSchema = z.string().regex(/^(0|[1-9][0-9]{0,8})(\.[0-9]{1,2})?$/); diff --git a/packages/contracts/src/destinations.ts b/packages/contracts/src/destinations.ts new file mode 100644 index 000000000..564dffbb3 --- /dev/null +++ b/packages/contracts/src/destinations.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +const chainAliases: Record = { + ethereum: "eth", + arbitrum: "arb", + polygon: "pol", + matic: "pol", + optimism: "op", + avalanche: "avax", + solana: "sol", + bitcoin: "btc", +}; +export const canonicalChain = (chain: string) => chainAliases[chain] ?? chain; +const evmChains = new Set(["eth", "base", "arb", "bsc", "pol", "op", "avax"]); + +/** Complete external effect identity. No wildcard chains, tags or purposes. */ +export const grantDestinationSchema = z + .strictObject({ + action: z.enum([ + "withdraw", + "intents_transfer", + "confidential_transfer", + "cross_chain_deposit", + ]), + kind: z.enum(["chain-address", "intents-account", "confidential-account"]), + chain: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/), + network: z.literal("mainnet"), + address: z + .string() + .min(1) + .max(256) + .refine((value) => value === value.trim(), "Address whitespace is not allowed"), + memo: z.union([ + z.strictObject({ kind: z.literal("none") }), + z.strictObject({ kind: z.literal("exact"), value: z.string().max(256) }), + ]), + purpose: z.enum(["payout", "refund"]), + }) + .superRefine((value, ctx) => { + if (canonicalChain(value.chain) !== value.chain) + ctx.addIssue({ code: "custom", message: "Use the canonical chain identifier" }); + const expected = + value.action === "intents_transfer" + ? "intents-account" + : value.action === "confidential_transfer" + ? "confidential-account" + : "chain-address"; + if ( + value.kind !== expected || + value.purpose !== (value.action === "cross_chain_deposit" ? "refund" : "payout") + ) + ctx.addIssue({ + code: "custom", + message: "Destination kind and purpose must match its action", + }); + if ( + value.kind !== "chain-address" && + (value.chain !== "near" || value.memo.kind !== "none" || value.purpose !== "payout") + ) + ctx.addIssue({ + code: "custom", + message: "Intents accounts require near, no memo and payout purpose", + }); + if ( + value.kind === "chain-address" && + evmChains.has(value.chain) && + !/^0x[0-9a-f]{40}$/.test(value.address) + ) + ctx.addIssue({ + code: "custom", + message: "EVM destinations must use canonical lowercase hex addresses", + }); + }); +export type GrantDestination = z.infer; + +/** Exact identity for non-EVM formats; never lowercase a case-sensitive address or alter a memo. */ +export function canonicalDestination(input: GrantDestination): GrantDestination { + const address = + input.kind === "chain-address" && evmChains.has(canonicalChain(input.chain)) + ? input.address.toLowerCase() + : input.address; + return grantDestinationSchema.parse({ ...input, chain: canonicalChain(input.chain), address }); +} +export function destinationLabel(value: GrantDestination): string { + return `${value.purpose}: ${value.kind} ${value.chain} ${value.address}${value.memo.kind === "exact" ? ` (memo: ${JSON.stringify(value.memo.value)})` : " (no memo)"}`; +} diff --git a/packages/contracts/src/evm-wallet.ts b/packages/contracts/src/evm-wallet.ts new file mode 100644 index 000000000..4f34b2d45 --- /dev/null +++ b/packages/contracts/src/evm-wallet.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { nearAccountSchema } from "./common.js"; + +const decimal = z.string().regex(/^(0|[1-9][0-9]{0,77})$/); +const evmWalletActionSchema = z.strictObject({ + action: z.literal("function_call"), + function_name: z.string().min(1).max(128), + args: z.string().max(32768), + deposit: decimal, + min_gas: decimal.optional(), +}); +export const evmWalletRequestMessageSchema = z.strictObject({ + chain_id: z.literal("mainnet"), + signer_id: nearAccountSchema, + nonce: z.number().int().nonnegative().max(0xffffffff), + created_at: z.string().datetime({ offset: false }), + timeout_secs: z.number().int().positive().max(3600), + request: z.strictObject({ + ops: z.tuple([]), + out: z.strictObject({ + after: z.tuple([]), + // biome-ignore lint/suspicious/noThenProperty: Required wallet-contract request field. + then: z + .array( + z.strictObject({ + receiver_id: nearAccountSchema, + actions: z.array(evmWalletActionSchema).min(1).max(8), + }), + ) + .min(1) + .max(4), + }), + }), +}); +export type EvmWalletRequestMessage = z.infer; + +export function evmAuthorizationTypedData(message: { + purpose: string; + recipient: string; + payload: string; +}) { + return { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + ], + Authorization: [ + { name: "purpose", type: "string" }, + { name: "recipient", type: "string" }, + { name: "payload", type: "string" }, + ], + }, + primaryType: "Authorization", + domain: { name: "NEAR Wallet Contract", version: "1" }, + message, + }; +} + +export function evmWalletTypedData(msg: EvmWalletRequestMessage) { + return { + 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: msg.chain_id, + signerId: msg.signer_id, + nonce: msg.nonce, + createdAt: msg.created_at, + timeoutSecs: msg.timeout_secs, + ops: JSON.stringify(msg.request.ops), + out: JSON.stringify(msg.request.out), + }, + }; +} diff --git a/packages/contracts/src/grants.ts b/packages/contracts/src/grants.ts new file mode 100644 index 000000000..983907b1c --- /dev/null +++ b/packages/contracts/src/grants.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { idSchema, nearAccountSchema, ownerProofSchema, ownerWalletSchema } from "./common.js"; +import { grantDestinationSchema } from "./destinations.js"; + +/** + * An agent grant is the owner's delegation of bounded execution authority to one grant token. A + * tenant API key only proves which partner is calling; it never selects or carries a grant. Each + * grant is its own permission: an agent can hold many live grants at once (a dashboard session, + * Claude, a scheduled bot), and issuing or revoking one never touches another. + * + * The partner creates the token and sends only its commitment, so the owner signs the exact + * credential that will use the grant and this service never sees the token at issuance. + */ +export const agentGrantDomain = "near-intents-agent-api.agent-grant.v4"; + +/** Grant token: `ngt_` and 32 random bytes in base64url. Keep it on the partner's backend. */ +export const grantTokenPattern = /^ngt_[A-Za-z0-9_-]{43}$/; +/** SHA-256 hex of the grant token's UTF-8 bytes. */ +export const grantCommitmentSchema = z.string().regex(/^[0-9a-f]{64}$/); +/** The owner-visible name of a grant, shown when signing and on every operation it authorizes. */ +export const grantLabelSchema = z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[^\p{Cc}]+$/u); + +export const agentGrantMessageSchema = z.strictObject({ + domain: z.literal(agentGrantDomain), + owner: ownerWalletSchema, + tenant_id: z.string().min(1).max(64), + agent_id: idSchema, + /** Immutable provider wallet this grant is scoped to. */ + wallet_id: z.string().min(1).max(256), + label: grantLabelSchema, + /** Commitment to the grant token that may use this grant. */ + credential: grantCommitmentSchema, + /** Empty means "no actions"; use `*` only when the owner truly delegates everything. */ + actions: z.array(z.string().min(1).max(64)).min(1).max(64), + /** Empty permits internal agent balances only; destinations and signing audiences are explicit. */ + recipients: z.array(grantDestinationSchema).max(256), + signing_audiences: z.array(z.string().min(1).max(256)).max(256), + network: z.literal("mainnet"), + issued_at_ms: z.number().int().nonnegative(), + expires_at_ms: z.number().int().positive(), + owner_epoch: z.number().int().positive(), + nonce: z.string().min(16).max(64), + recipient: nearAccountSchema, +}); + +export const legacyAgentGrantMessageSchema = agentGrantMessageSchema + .omit({ signing_audiences: true, network: true }) + .extend({ + domain: z.literal("near-intents-agent-api.agent-grant.v3"), + recipients: z.array(z.string()), + }); +export const historicalGrantMessageSchema = z.union([ + agentGrantMessageSchema, + legacyAgentGrantMessageSchema, +]); + +const agentGrantSchema = z.strictObject({ + message: agentGrantMessageSchema, + proof: ownerProofSchema, + idempotencyKey: z.string().min(8).max(128), +}); + +const agentGrantViewSchema = z.strictObject({ + grant_id: idSchema, + agent_id: idSchema, + wallet_id: z.string(), + label: z.string(), + actions: z.array(z.string()), + recipients: z.array(grantDestinationSchema), + signing_audiences: z.array(z.string()), + issued_at: z.string(), + expires_at: z.string(), + revoked_at: z.string().nullable(), + revoked_reason: z.string().nullable(), + owner_epoch: z.number().int().positive(), + /** The canonical owner-signed message. The grant's columns must restate it exactly. */ + owner_message: historicalGrantMessageSchema, +}); + +export type AgentGrantMessage = z.infer; +export type AgentGrantWrite = z.infer; +export type AgentGrantView = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 000000000..16c029b36 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,126 @@ +export type { + AgentAdminCommandMessage, + AgentControl, + AgentControlMessage, + AgentDelete, + AgentDeleteMessage, + AgentDeletePreview, + AgentDeletionChallenge, + AgentGrantRevocation, + AgentView, + BalanceEntry, + BalanceList, + BalanceListQuery, + BudgetMessage, + BudgetSettings, + BudgetView, + BudgetWrite, + ExecutionCancellation, + ScheduledExecution, + TimelockMessage, + TimelockSettings, + TimelockView, + TimelockWrite, + Wallet, +} from "./agents.js"; +export { + agentDeletionChallengeSchema, + walletSchema, +} from "./agents.js"; +export type { ApprovalDetail, ApprovalVote } from "./approvals.js"; +export { approvalDetailSchema } from "./approvals.js"; +export type { + NearOwnerWallet, + OwnerNear, + OwnerProof, + OwnerSigningRequest, + OwnerType, + OwnerWallet, +} from "./common.js"; +export { + canonical, + idSchema, + nearAccountSchema, + ownerNearSchema, + ownerWalletSchema, + supportedOwnerTypes, +} from "./common.js"; +export * from "./destinations.js"; +export type { EvmWalletRequestMessage } from "./evm-wallet.js"; +export { + evmAuthorizationTypedData, + evmWalletRequestMessageSchema, + evmWalletTypedData, +} from "./evm-wallet.js"; +export type { AgentGrantMessage, AgentGrantView, AgentGrantWrite } from "./grants.js"; +export { + agentGrantDomain, + agentGrantMessageSchema, + grantCommitmentSchema, + grantLabelSchema, + grantTokenPattern, + historicalGrantMessageSchema, +} from "./grants.js"; +export type { ManagedAgent, ManagementSummary } from "./management.js"; +export type { OffchainMessage } from "./nep641.js"; +export { offchainMessageHash } from "./nep641.js"; +export type { + AgentOnboarding, + CreateAgent, + OnboardingSignature, + OnboardingView, +} from "./onboarding.js"; +export type { + EvmSignMessage, + EvmSignTransaction, + EvmSignTypedData, + ExecutionRequest, + IdentitySigningChallenge, + Operation, + OperationKind, + OperationStatus, + Signature, + SigningArtifactAction, + SigningArtifactDelivery, + SigningArtifactOwnerAccess, + SigningArtifactOwnerAction, + SigningArtifactOwnerChallenge, + SigningArtifactOwnerMessage, + SignMessage, +} from "./operations.js"; +export { + evmChainSchema, + evmSignatureSchema, + executeSchema, + executionActionSchema, + executionRequestSchema, + executionResultSchema, + identitySigningChallengeSchema, + operationStatusSchema, + signatureSchema, + signingArtifactOwnerMessageSchema, +} from "./operations.js"; +export type { + LimitsView, + Policy, + PolicyDelegate, + PolicyHistory, + PolicyOperationResult, + PolicyView, + SignedNearPolicy, +} from "./policy.js"; +export { + nearPolicyRequestSchema, + policyDelegateSchema, + policyOperationResultSchema, + policySchema, + policyTransactionTypeSchema, +} from "./policy.js"; +export * from "./quotas.js"; +export type { WalletQuery, WalletQueryResult } from "./wallet-queries.js"; +export type { SignedWalletRequest, WalletRequestMessage } from "./wallet-request.js"; +export { + serializeWalletRequestMessage, + walletRequestHash, + walletRequestMessageSchema, +} from "./wallet-request.js"; diff --git a/packages/contracts/src/management.ts b/packages/contracts/src/management.ts new file mode 100644 index 000000000..a644cbede --- /dev/null +++ b/packages/contracts/src/management.ts @@ -0,0 +1,18 @@ +export type ManagedAgent = { + id: string; + name: string; + externalUserId: string | null; + ownerAccountId: string | null; + ownerWalletBound: boolean; + walletProvisioned: boolean; + policyStatus: "none" | "signed" | "applied" | "failed"; +}; +export type ManagementSummary = { + agents: number; + operations: number; + pending: number; + uncertain: number; + settled: number; + observedAt: string; + period: "all_time"; +}; diff --git a/packages/contracts/src/nep641.ts b/packages/contracts/src/nep641.ts new file mode 100644 index 000000000..3d454d193 --- /dev/null +++ b/packages/contracts/src/nep641.ts @@ -0,0 +1,25 @@ +import { sha3_256 } from "@noble/hashes/sha3.js"; +import { borshInteger, borshString, borshTimestampNanos, borshU32, concat } from "./borsh.js"; + +export type OffchainMessage = { + chain_id: "mainnet"; + signer_id: string; + timestamp: string; + payload: string; + path?: string[]; +}; + +/** SHA3-256 domain hash of NEP-641 OffchainMessage Borsh, per near/intents wallet. */ +export function offchainMessageHash(message: OffchainMessage): Uint8Array { + const nanos = borshTimestampNanos(message.timestamp, true); + const path = message.path ?? []; + const serialized = concat( + borshString(message.chain_id), + borshString(message.signer_id), + borshU32(path.length), + ...path.map(borshString), + borshInteger(nanos, 8), + borshString(message.payload), + ); + return sha3_256(concat(new TextEncoder().encode("NEAR_NEP641_OFFCHAIN_MESSAGE/V1"), serialized)); +} diff --git a/packages/contracts/src/onboarding.ts b/packages/contracts/src/onboarding.ts new file mode 100644 index 000000000..7f55b1074 --- /dev/null +++ b/packages/contracts/src/onboarding.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { agentViewSchema } from "./agents.js"; +import { idSchema, nearAccountSchema, ownerWalletSchema } from "./common.js"; +import { evmWalletRequestMessageSchema } from "./evm-wallet.js"; +import { nearPolicyRequestSchema, policySchema, signedNearPolicySchema } from "./policy.js"; +import { walletRequestMessageSchema } from "./wallet-request.js"; + +/** + * An agent never exists without an owner and a policy. Creation names both; the owner then signs + * one exact on-chain request that installs the policy under the owner's wallet. That signature is + * the binding proof, the policy consent and the transaction authorization at once. + */ +const createAgentSchema = z.strictObject({ + name: z.string().trim().min(1).max(100), + externalUserId: z.string().min(1).max(200).optional(), + owner: ownerWalletSchema, + policy: policySchema, +}); + +const onboardingStatusSchema = z.enum([ + "pending_wallet_signature", + "dispatching", + "submitted", + "applied", + "failed", +]); + +const onboardingViewSchema = z.strictObject({ + operation_id: idSchema, + status: onboardingStatusSchema, + owner: ownerWalletSchema, + /** NEAR account that becomes the provider policy controller: named account or `0s` wallet. */ + controller_id: nearAccountSchema, + wallet_id: z.string().min(1).max(256), + policy_hash: idSchema, + policy: policySchema, + /** The request must be signed and submitted before this instant. */ + expires_at: z.iso.datetime(), + transaction_hash: z.string().max(128).nullable(), + failure_code: z.string().max(128).nullable(), + /** NEAR owners sign this as one NEP-366 delegate. */ + near_policy_request: nearPolicyRequestSchema.optional(), + /** Passkey and EVM owners sign this exact wallet-contract request. */ + wallet_request: z.union([walletRequestMessageSchema, evmWalletRequestMessageSchema]).optional(), +}); + +const agentOnboardingSchema = z.strictObject({ + agent: agentViewSchema, + onboarding: onboardingViewSchema, +}); + +/** The owner's single signature over the prepared request, exactly as returned. */ +const onboardingSignatureSchema = z.union([ + z.strictObject({ + msg: z.union([walletRequestMessageSchema, evmWalletRequestMessageSchema]), + proof: z.string().min(1).max(16_384), + }), + signedNearPolicySchema, +]); + +export type CreateAgent = z.infer; +export type OnboardingView = z.infer; +export type AgentOnboarding = z.infer; +export type OnboardingSignature = z.infer; diff --git a/packages/contracts/src/operations.ts b/packages/contracts/src/operations.ts new file mode 100644 index 000000000..c87386039 --- /dev/null +++ b/packages/contracts/src/operations.ts @@ -0,0 +1,300 @@ +import { z } from "zod"; +import { + idSchema, + nearAccountSchema, + ownerProofSchema, + ownerSigningRequestSchema, + signedMessageFields, +} from "./common.js"; + +const operationKindSchema = z.enum(["sign", "execute", "relay", "policy"]); +/** Command completion is not proof of transaction broadcast or settlement. */ +export const operationStatusSchema = z.enum(["pending", "completed", "uncertain", "failed"]); + +const operationSchema = z.strictObject({ + id: idSchema, + kind: operationKindSchema, + status: operationStatusSchema, + result: z.unknown(), +}); + +export type OperationKind = z.infer; +export type OperationStatus = z.infer; +export type Operation = Omit, "result"> & { + result: T; +}; + +const idempotencyKeySchema = z.string().min(8).max(128); + +export const evmChainSchema = z.enum([ + "ethereum", + "base", + "arbitrum", + "bsc", + "polygon", + "optimism", + "avalanche", +]); + +/** The only detached payload admitted to signing APIs: a short-lived, audience-bound identity challenge. */ +export const identitySigningChallengeSchema = z.strictObject({ + domain: z.literal("near-intents-agent-api.identity.v1"), + purpose: z.literal("identity"), + chain: z.union([z.literal("near"), evmChainSchema]), + audience: z.string().min(1).max(256), + /** Relying-party generated 32-byte random challenge; relying-party verifier enforces one-time use. */ + challenge: z.string().regex(/^[0-9a-f]{64}$/), + issued_at_ms: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + expires_at_ms: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +}); + +const signMessageSchema = z.strictObject({ + message: z.string().max(32768), + encoding: z.enum(["utf8", "hex"]).optional(), + recipient: nearAccountSchema.optional(), + idempotencyKey: idempotencyKeySchema, +}); + +const evmSignMessageSchema = z.strictObject({ + message: z.string().max(32768), + encoding: z.enum(["utf8", "hex"]).optional(), + chain: evmChainSchema, + idempotencyKey: idempotencyKeySchema, +}); + +const evmSignTypedDataSchema = z.strictObject({ + chain: evmChainSchema, + typed_data: z.strictObject({ + domain: z.record(z.string(), z.unknown()), + types: z.record(z.string(), z.unknown()), + primaryType: z.string().min(1).max(128), + message: z.record(z.string(), z.unknown()), + }), + idempotencyKey: idempotencyKeySchema, +}); + +const evmSignTransactionSchema = z.strictObject({ + chain: evmChainSchema, + unsigned_tx: z + .string() + .regex(/^0x[0-9a-fA-F]+$/) + .max(100_000), + idempotencyKey: idempotencyKeySchema, +}); + +const swapSchema = z.strictObject({ + token_in: z.string().min(1).max(128), + token_out: z.string().min(1).max(128), + amount_in: z.string().regex(/^[0-9]{1,78}$/), + min_amount_out: z + .string() + .regex(/^[0-9]{1,78}$/) + .optional(), + confidential: z.boolean().optional(), + idempotencyKey: idempotencyKeySchema, +}); + +const withdrawSchema = z.strictObject({ + token: z.string().min(1).max(128), + amount: z.string().regex(/^[0-9]{1,78}$/), + chain: z.string().min(1).max(64), + to: z.string().min(1).max(128), + memo: z.string().max(256).optional(), + confidential: z.boolean().optional(), + /** Cross-chain 1Click bridges outlive the synchronous window; async returns a poll_url. */ + async: z.boolean().optional(), + idempotencyKey: idempotencyKeySchema, +}); + +/** Moves an amount of one token between the agent's own public and confidential balances. */ +const intentsBalanceMoveSchema = z.strictObject({ + token: z.string().min(1).max(128), + amount: z.string().regex(/^[0-9]{1,78}$/), + idempotencyKey: idempotencyKeySchema, +}); + +const tokenTransferSchema = z.strictObject({ + token: z.string().min(1).max(256), + amount: z.string().regex(/^[0-9]{1,78}$/), + to: nearAccountSchema, + idempotencyKey: idempotencyKeySchema, +}); +const crossChainDepositSchema = z + .strictObject({ + source_asset: z.string().min(1).max(256).optional(), + destination_asset: z.string().min(1).max(256).optional(), + amount: z.string().regex(/^[0-9]{1,78}$/), + chain: z.string().min(1).max(32).optional(), + token: z.string().min(1).max(128).optional(), + refund_address: z.string().min(1).max(256).optional(), + confidential: z.boolean().optional(), + idempotencyKey: idempotencyKeySchema, + }) + .refine((input) => Boolean(input.source_asset) || Boolean(input.chain), { + message: "source_asset or chain is required", + }); +export const executionActionSchema = z.enum([ + "swap", + "withdraw", + "shield", + "unshield", + "intents_transfer", + "confidential_transfer", + "confidential_deposit", + "cross_chain_deposit", + "delete", +]); + +export const executionRequestSchema = z.discriminatedUnion("action", [ + z.strictObject({ action: z.literal("swap"), request: swapSchema }), + z.strictObject({ action: z.literal("withdraw"), request: withdrawSchema }), + z.strictObject({ + action: z.literal("shield"), + request: intentsBalanceMoveSchema, + }), + z.strictObject({ + action: z.literal("unshield"), + request: intentsBalanceMoveSchema, + }), + z.strictObject({ action: z.literal("intents_transfer"), request: tokenTransferSchema }), + z.strictObject({ action: z.literal("confidential_transfer"), request: tokenTransferSchema }), + z.strictObject({ + action: z.literal("confidential_deposit"), + request: intentsBalanceMoveSchema, + }), + z.strictObject({ action: z.literal("cross_chain_deposit"), request: crossChainDepositSchema }), +]); + +export const executeSchema = executionRequestSchema; + +export const signatureSchema = z.strictObject({ + near_account_id: nearAccountSchema, + nonce: z.string().min(1).max(64), + recipient: z.string().min(1).max(253), + signature: z.string().regex(/^[0-9a-f]{128}$/), + public_key: z.string().startsWith("ed25519:").max(80), +}); + +export const evmSignatureSchema = z.strictObject({ + evm_address: z.string().regex(/^0x[0-9a-fA-F]{40}$/), + signature: z.string().regex(/^0x[0-9a-fA-F]{130}$/), +}); + +const signingArtifactDeliverySchema = z.strictObject({ + operation_id: idSchema, + artifact: z.union([signatureSchema, evmSignatureSchema]), + expires_at: z.string().datetime(), +}); + +const signingArtifactActionSchema = z.enum([ + "near_message", + "evm_message", + "evm_typed_data", + "evm_transaction", +]); + +/** Owner proof permits one-time retrieval or acknowledgement of a protected signature artifact. */ +const signingArtifactOwnerActionSchema = z.enum(["read", "ack"]); +export const signingArtifactOwnerMessageSchema = z.strictObject({ + ...signedMessageFields, + domain: z.literal("near-intents-agent-api.signing-artifact-owner.v2"), + operation_id: idSchema, + request_hash: idSchema, + requesting_key_id: z.string().min(1).max(253), + grant_id: z.string().min(1).max(253), + owner_epoch: z.number().int().nonnegative(), + signing_action: signingArtifactActionSchema, + action: signingArtifactOwnerActionSchema, +}); +const signingArtifactOwnerChallengeSchema = z.strictObject({ + message: signingArtifactOwnerMessageSchema, + signing: ownerSigningRequestSchema, + nep413: z.strictObject({ message: z.string(), nonce: idSchema, recipient: z.string() }), +}); +const signingArtifactOwnerAccessSchema = z.strictObject({ + message: signingArtifactOwnerMessageSchema, + proof: ownerProofSchema, +}); + +export const executionResultSchema = z + .object({ + action: executionActionSchema, + near_account_id: nearAccountSchema, + provider_request_id: z.string().max(128).nullable(), + /** Set when the request used a `/confidential/*` route, which settles on the private shard. */ + confidential: z.literal(true).optional(), + /** Destination chain of a withdrawal, as requested. */ + chain: z.string().min(1).max(64).optional(), + execute_after: z.iso.datetime().optional(), + delay_seconds: z.number().int().nonnegative().optional(), + status: z.enum([ + "timelocked", + "pending", + "pending_approval", + "pending_deposit", + "processing", + "approved", + "success", + "partially_failed", + "failed", + "refunded", + "rejected", + "expired", + "cancelled", + "needs_review", + "unknown", + ]), + approval_id: z.string().nullable().optional(), + request_hash: z.string().nullable().optional(), + tx_hash: z.string().nullable().optional(), + /** + * Deletion of a custody wallet with no on-chain account (never natively funded): there is + * nothing for the provider to delete, so the API erased its only copy of the credential. + */ + credential_erased: z.literal(true).optional(), + intent_hash: z.string().nullable().optional(), + transfer_intent_hash: z.string().nullable().optional(), + destination_tx_hash: z.string().nullable().optional(), + receipt_id: z.string().max(256).nullable().optional(), + receipt_hash: z.string().max(256).nullable().optional(), + settlement_status: z.string().max(64).nullable().optional(), + settlement_tx_hash: z.string().max(128).nullable().optional(), + settled_at: z.string().max(128).nullable().optional(), + refund_tx_hash: z.string().max(128).nullable().optional(), + refund_status: z.string().max(64).nullable().optional(), + failure_code: z.string().max(128).nullable().optional(), + failure_reason: z.string().max(1024).nullable().optional(), + fee: z.string().max(256).nullable().optional(), + fee_amount: z.string().max(256).nullable().optional(), + fee_token: z.string().max(128).nullable().optional(), + provider_fee: z.string().max(256).nullable().optional(), + provider_fee_amount: z.string().max(256).nullable().optional(), + provider_fee_token: z.string().max(128).nullable().optional(), + solver_fee: z.string().max(256).nullable().optional(), + solver_fee_amount: z.string().max(256).nullable().optional(), + solver_fee_token: z.string().max(128).nullable().optional(), + protocol_fee: z.string().max(256).nullable().optional(), + protocol_fee_amount: z.string().max(256).nullable().optional(), + protocol_fee_token: z.string().max(128).nullable().optional(), + amount_out: z.string().nullable().optional(), + deposit_address: z.string().nullable().optional(), + intent_id: z.string().nullable().optional(), + expires_at: z.string().nullable().optional(), + estimated_time_secs: z.number().int().nullable().optional(), + poll_url: z.string().nullable().optional(), + }) + .passthrough(); + +export type SignMessage = z.infer; +export type EvmSignMessage = z.infer; +export type EvmSignTypedData = z.infer; +export type EvmSignTransaction = z.infer; +export type IdentitySigningChallenge = z.infer; +export type ExecutionRequest = z.infer; +export type Signature = z.infer; +export type SigningArtifactDelivery = z.infer; +export type SigningArtifactAction = z.infer; +export type SigningArtifactOwnerAction = z.infer; +export type SigningArtifactOwnerMessage = z.infer; +export type SigningArtifactOwnerChallenge = z.infer; +export type SigningArtifactOwnerAccess = z.infer; diff --git a/packages/contracts/src/policy.ts b/packages/contracts/src/policy.ts new file mode 100644 index 000000000..f87248793 --- /dev/null +++ b/packages/contracts/src/policy.ts @@ -0,0 +1,196 @@ +import { z } from "zod"; +import { idSchema, nearAccountSchema } from "./common.js"; +import { evmWalletRequestMessageSchema } from "./evm-wallet.js"; +import { walletRequestMessageSchema } from "./wallet-request.js"; + +const nonEmptyPolicyString = z.string().min(1).max(256); +const amountRawSchema = z.string().regex(/^[0-9]{1,78}$/); + +export const policyTransactionTypeSchema = z.enum([ + "call", + "confidential", + "cross_chain_withdraw", + "delete", + "intents_transfer", + "nft_transfer", + "swap", + "transfer", + "withdraw", +]); + +const deniedCapabilitySchema = z.strictObject({ + allowed: z.boolean(), + requires_approval: z.boolean(), +}); + +const signingCapabilitySchema = z.strictObject({ + allowed: z.boolean(), + raw_tx: z.boolean(), +}); + +const messageCapabilitySchema = z.strictObject({ + allowed: z.boolean(), + requires_approval: z.boolean(), + allowed_recipients: z.array(nearAccountSchema).max(256), +}); + +const rawSigningCapabilitySchema = z.strictObject({ + allowed: z.boolean(), + chains: z.array(nonEmptyPolicyString).max(64), + requires_approval: z.boolean(), +}); + +const policyCapabilitiesSchema = z.strictObject({ + confidential: deniedCapabilitySchema, + cross_chain_withdraw: deniedCapabilitySchema, + evm_sign: signingCapabilitySchema, + raw_sign: rawSigningCapabilitySchema, + sign_message: messageCapabilitySchema, + swap: deniedCapabilitySchema, +}); + +const policyLimitBucketSchema = z.record(z.string().min(1).max(256), amountRawSchema); + +const policyLimitsSchema = z.strictObject({ + per_transaction: policyLimitBucketSchema.optional(), + hourly: policyLimitBucketSchema.optional(), + daily: policyLimitBucketSchema.optional(), + monthly: policyLimitBucketSchema.optional(), +}); + +const policyAddressesSchema = z.strictObject({ + mode: z.enum(["whitelist", "blacklist"]), + list: z.array(nearAccountSchema).max(256), +}); + +const policyRulesSchema = z.strictObject({ + allowed_tokens: z.array(nonEmptyPolicyString).max(128), + transaction_types: z.array(policyTransactionTypeSchema).max(16), + limits: policyLimitsSchema.optional(), + rate_limit: z + .strictObject({ max_per_hour: z.number().int().positive().max(1_000_000) }) + .optional(), + addresses: policyAddressesSchema.optional(), + items: z.record(nearAccountSchema, z.array(nonEmptyPolicyString).max(128)).optional(), +}); + +const policyApprovalSchema = z.strictObject({ + approvers: z + .array( + z.strictObject({ + id: nearAccountSchema, + pubkey: z.string().startsWith("ed25519:").max(80), + role: z.enum(["admin", "signer"]), + }), + ) + .max(32), + excluded_types: z.array(policyTransactionTypeSchema).max(16), + threshold: z.strictObject({ required: z.number().int().positive().max(32) }), +}); + +/** Versioned provider-policy envelope. Canonical JSON of this value is hashed and signed. */ +export const policySchema = z.strictObject({ + version: z.literal(1), + capabilities: policyCapabilitiesSchema, + frozen: z.boolean(), + rules: policyRulesSchema, + approval: policyApprovalSchema.optional(), +}); + +const revisionSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); + +/** Owner-signed NEP-366 delegate used to submit a provider policy update. */ +const policyDelegateDecimal = z.string().regex(/^(0|[1-9][0-9]{0,38})$/); + +export const policyDelegateSchema = z.strictObject({ + senderId: nearAccountSchema, + publicKey: z.string().startsWith("ed25519:").max(80), + receiverId: nearAccountSchema, + nonce: policyDelegateDecimal, + maxBlockHeight: policyDelegateDecimal, + actions: z + .array( + z.strictObject({ + methodName: z.enum(["store_wallet_policy", "freeze_wallet", "unfreeze_wallet"]), + argsBase64: z.string().max(30000), + gas: policyDelegateDecimal, + depositYocto: policyDelegateDecimal, + }), + ) + .min(1) + .max(2), +}); +export const nearPolicyRequestSchema = z.strictObject({ + receiver_id: nearAccountSchema, + actions: policyDelegateSchema.shape.actions, +}); +export const signedNearPolicySchema = z.strictObject({ + signedDelegate: z.string().min(32).max(50_000), +}); + +/** Exact provider rules read back for a bound agent. Raw amounts are decimal strings. */ +const policyViewSchema = z.strictObject({ + wallet_id: z.string().min(1).max(256), + policy_hash: idSchema.nullable(), + revision: revisionSchema.nullable(), + status: z.enum(["none", "signed", "applied", "failed"]), + applied_at: z.string().nullable(), + transaction_hash: z.string().max(128).nullable(), + provider_policy_synced: z.boolean(), + policy: policySchema.nullable(), +}); + +const limitsViewSchema = z.strictObject({ + wallet_id: z.string().min(1).max(256), + frozen: z.boolean().nullable(), + capabilities: policyCapabilitiesSchema.nullable(), + limits: policyLimitsSchema.nullable(), + rate_limit: z + .strictObject({ max_per_hour: z.number().int().positive().max(1_000_000) }) + .nullable(), + addresses: policyAddressesSchema.nullable(), + allowed_tokens: z.array(nonEmptyPolicyString).nullable(), + transaction_types: z.array(policyTransactionTypeSchema).nullable(), + approval: policyApprovalSchema.nullable(), + policy_synced: z.boolean(), +}); + +export const policyOperationResultSchema = z + .object({ + operation_id: idSchema, + status: z.enum([ + "pending", + "pending_wallet_signature", + "dispatching", + "submitted", + "applied", + "failed", + ]), + wallet_id: z.string().min(1).max(256), + policy_hash: idSchema, + expected_revision: revisionSchema, + transaction_hash: z.string().max(128).nullable(), + submitted: z.boolean(), + provider_policy_readback: z.boolean(), + feePayer: z.literal("backend").nullable(), + controller_id: nearAccountSchema.nullable(), + receiver_id: nearAccountSchema.nullable(), + gas: amountRawSchema.nullable(), + storage_deposit_yocto: amountRawSchema.nullable(), + delegate: policyDelegateSchema.optional(), + near_policy_request: nearPolicyRequestSchema.optional(), + wallet_request: z.union([walletRequestMessageSchema, evmWalletRequestMessageSchema]).optional(), + }) + .passthrough(); + +export type Policy = z.infer; +export type PolicyDelegate = z.infer; +export type SignedNearPolicy = z.infer; +export type PolicyView = z.infer; +export type LimitsView = z.infer; +export type PolicyOperationResult = z.infer; +const policyHistorySchema = z.strictObject({ + policies: z.array(policyViewSchema.omit({ provider_policy_synced: true })), + next_before_revision: revisionSchema.nullable(), +}); +export type PolicyHistory = z.infer; diff --git a/packages/contracts/src/quotas.ts b/packages/contracts/src/quotas.ts new file mode 100644 index 000000000..15109d669 --- /dev/null +++ b/packages/contracts/src/quotas.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +const limit = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +/** Operator-managed resource limits, independent of the owner's spending budget. */ +export const partnerQuotaProfileSchema = z.strictObject({ + liveAgents: limit.optional(), + lifetimeAgents: limit.optional(), + requestsPerMinute: limit.optional(), + keyRequestsPerMinute: limit.optional(), + sponsorDaily: limit.optional(), + sponsorDailyPerAgent: limit.optional(), +}); +export type PartnerQuotaProfile = z.infer; +export const partnerQuotaViewSchema = z.strictObject({ + revision: limit, + overrides: partnerQuotaProfileSchema, + limits: z.strictObject({ + liveAgents: limit, + lifetimeAgents: limit.nullable(), + requestsPerMinute: limit, + keyRequestsPerMinute: limit, + sponsorDaily: limit, + sponsorDailyPerAgent: limit, + sponsorDailyGlobal: limit, + }), + usage: z.strictObject({ + liveAgents: limit, + lifetimeAgents: limit, + requestsThisMinute: limit, + sponsorLast24Hours: limit, + }), + observedAt: z.iso.datetime(), +}); +export type PartnerQuotaView = z.infer; diff --git a/packages/contracts/src/tokens.ts b/packages/contracts/src/tokens.ts new file mode 100644 index 000000000..a6ad882fa --- /dev/null +++ b/packages/contracts/src/tokens.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { atomicAmountSchema } from "./api/common.js"; + +/** One asset agents can use; the shape follows the 1Click token list. */ +export const tokenViewSchema = z.strictObject({ + /** NEAR Intents asset id. Pass it as `asset`, `originAsset` or `destinationAsset`. */ + assetId: z.string().min(1).max(256), + symbol: z.string().min(1).max(64), + decimals: z.number().int().nonnegative().max(36), + /** Chain the asset originates on (`near`, `eth`, `sol`, …). */ + blockchain: z.string().min(1).max(64), + /** + * USD per whole token from the 1Click feed; null when it has no usable price (none, too old, or + * in different decimals than custody). Null is unknown, never zero. + */ + price: z.number().nonnegative().nullable(), + /** When 1Click last updated the quote, even one too old to use. */ + priceUpdatedAt: z.string().nullable(), + /** When `price` stops being usable; null with no price. Do not show `price` after it. */ + priceExpiresAt: z.string().nullable(), +}); + +/** Held assets retain their identity even when catalog metadata is unavailable. */ +export const balanceEntrySchema = tokenViewSchema.extend({ + symbol: tokenViewSchema.shape.symbol.nullable(), + decimals: tokenViewSchema.shape.decimals.nullable(), + blockchain: tokenViewSchema.shape.blockchain.nullable(), + balanceRaw: atomicAmountSchema, + balance: z + .string() + .regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/) + .nullable() + .describe("Exact token units, or null when decimals are unknown."), +}); diff --git a/packages/contracts/src/wallet-queries.ts b/packages/contracts/src/wallet-queries.ts new file mode 100644 index 000000000..1a1b9b397 --- /dev/null +++ b/packages/contracts/src/wallet-queries.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +const text = z.string().min(1).max(256); +const amount = z.string().regex(/^[0-9]{1,78}$/); +const swap = z.strictObject({ + token_in: text, + token_out: text, + amount_in: amount, + min_amount_out: amount.optional(), + confidential: z.boolean().default(false), +}); +const withdrawal = z.strictObject({ + token: text, + amount, + chain: text, + to: text, + memo: z.string().max(256).optional(), + confidential: z.boolean().default(false), +}); +const walletQuerySchema = z.discriminatedUnion("query", [ + z.strictObject({ query: z.literal("address"), chain: text }), + z.strictObject({ query: z.literal("confidential_balances") }), + z.strictObject({ query: z.literal("pending_approvals") }), + z.strictObject({ + query: z.literal("requests"), + limit: z.number().int().min(1).max(100).optional(), + type: text.optional(), + }), + z.strictObject({ query: z.literal("audit") }), + z.strictObject({ + query: z.literal("deposits"), + limit: z.number().int().min(1).max(100).optional(), + }), + z.strictObject({ query: z.literal("deposit_status"), id: text }), + z.strictObject({ query: z.literal("deposit_intent_status"), id: text }), + z.strictObject({ + query: z.literal("deposit_history"), + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().nonnegative().max(10_000).optional(), + }), + z.strictObject({ query: z.literal("swap_quote"), request: swap }), + z.strictObject({ query: z.literal("withdraw_preview"), request: withdrawal }), +]); +export type WalletQuery = z.input; +export type WalletQueryResult = { + query: WalletQuery["query"]; + data: Record | unknown[]; +}; diff --git a/packages/contracts/src/wallet-request.ts b/packages/contracts/src/wallet-request.ts new file mode 100644 index 000000000..09771ab8d --- /dev/null +++ b/packages/contracts/src/wallet-request.ts @@ -0,0 +1,107 @@ +import { sha3_256 } from "@noble/hashes/sha3.js"; +import { base64 } from "@scure/base"; +import { z } from "zod"; +import { + borshBytes, + borshInteger, + borshString, + borshTimestampNanos, + borshU32, + concat, +} from "./borsh.js"; +import { nearAccountSchema } from "./common.js"; +import { evmWalletRequestMessageSchema } from "./evm-wallet.js"; + +const decimal = z.string().regex(/^(0|[1-9][0-9]{0,77})$/); +const transfer = z.strictObject({ + action: z.literal("transfer"), + payload: z.strictObject({ amount: decimal }), +}); +const functionCall = z.strictObject({ + action: z.literal("function_call"), + payload: z.strictObject({ + function_name: z.string().min(1).max(128), + args: z.string().max(32768).optional(), + deposit: decimal.optional(), + gas: decimal.optional(), + gas_weight: decimal.optional(), + }), +}); +const walletActionSchema = z.discriminatedUnion("action", [transfer, functionCall]); +const walletRequestSchema = z.strictObject({ + external: z + .array( + z.strictObject({ + receiver_id: nearAccountSchema, + refund_to: nearAccountSchema.optional(), + actions: z.array(walletActionSchema).min(1).max(8), + }), + ) + .min(1) + .max(4), +}); +export const walletRequestMessageSchema = z.strictObject({ + pay_for_gas: z.literal(false).optional(), + chain_id: z.literal("mainnet"), + signer_id: nearAccountSchema, + nonce: z.number().int().nonnegative().max(0xffffffff), + created_at: z.string().datetime({ offset: false }), + timeout_secs: z.number().int().positive().max(3600), + request: walletRequestSchema, +}); +const signedWalletRequestSchema = z.strictObject({ + msg: z.union([walletRequestMessageSchema, evmWalletRequestMessageSchema]), + proof: z.string().min(1).max(16_384), + idempotencyKey: z.string().min(8).max(128), +}); +export type WalletRequestMessage = z.infer; +export type SignedWalletRequest = z.infer; + +/** Canonical RequestMessage bytes used by the NEP-616 wallet contract. */ +export function serializeWalletRequestMessage(input: WalletRequestMessage): Uint8Array { + const msg = walletRequestMessageSchema.parse(input); + const nanos = borshTimestampNanos(msg.created_at); + const promises = msg.request.external.map((promise) => + concat( + borshString(promise.receiver_id), + promise.refund_to + ? concat(new Uint8Array([1]), borshString(promise.refund_to)) + : new Uint8Array([0]), + borshU32(promise.actions.length), + ...promise.actions.map((action) => { + if (action.action === "transfer") + return concat(new Uint8Array([3]), borshInteger(BigInt(action.payload.amount), 16)); + const payload = action.payload; + const args = payload.args ? base64.decode(payload.args) : new Uint8Array(); + return concat( + new Uint8Array([2]), + borshString(payload.function_name), + borshBytes(args), + borshInteger(BigInt(payload.deposit ?? "0"), 16), + borshInteger(BigInt(payload.gas ?? "0"), 8), + borshInteger(BigInt(payload.gas_weight ?? "1"), 8), + ); + }), + ), + ); + return concat( + new Uint8Array([0]), + borshString(msg.chain_id), + borshString(msg.signer_id), + borshU32(msg.nonce), + borshInteger(nanos, 8), + borshU32(msg.timeout_secs), + borshU32(0), + borshU32(promises.length), + ...promises, + ); +} + +export function walletRequestHash(message: WalletRequestMessage): Uint8Array { + return sha3_256( + concat( + new TextEncoder().encode("NEAR_WALLET_CONTRACT/V1"), + serializeWalletRequestMessage(message), + ), + ); +} diff --git a/packages/contracts/tests/surface.test.ts b/packages/contracts/tests/surface.test.ts new file mode 100644 index 000000000..a02f66090 --- /dev/null +++ b/packages/contracts/tests/surface.test.ts @@ -0,0 +1,54 @@ +import { + grantTokenPattern, + ownerWalletSchema, + policySchema, +} from "@near-intents-agent-api/contracts"; +import { buildLlmsText, buildOpenApiDocument } from "@near-intents-agent-api/contracts/api"; +import { describe, expect, it } from "vitest"; + +const validNearOwner = { + type: "near", + accountId: "v1.citynode.near", + publicKey: "ed25519:8hmo5Y36doLTzDnSUjR3bXntLW6VFmPrHLbnL5hMwwCp", +}; + +describe("vendored contracts surface", () => { + it("parses a NEAR owner wallet", () => { + const parsed = ownerWalletSchema.parse(validNearOwner); + expect(parsed.type).toBe("near"); + }); + + it("rejects owner wallets with missing keys or oversized accounts", () => { + expect(() => + ownerWalletSchema.parse({ type: "near", accountId: "v1.citynode.near" }), + ).toThrow(); + expect(() => + ownerWalletSchema.parse({ ...validNearOwner, accountId: "x".repeat(65) }), + ).toThrow(); + }); + + it("matches grant tokens and rejects partner keys", () => { + expect(grantTokenPattern.test(`ngt_${"A".repeat(43)}`)).toBe(true); + expect(grantTokenPattern.test("ngt_short")).toBe(false); + expect(grantTokenPattern.test(`naa_${"A".repeat(43)}`)).toBe(false); + }); + + it("policy rejects a wrong version and rejects missing required keys", () => { + expect(policySchema.safeParse({ version: 2 }).success).toBe(false); + expect(policySchema.safeParse({ version: 1, frozen: false }).success).toBe(false); + }); + + it("builds an OpenAPI document exposing the /v1 surface", () => { + const doc = buildOpenApiDocument({ serverUrl: "https://agents.example" }); + expect(doc.openapi).toBeDefined(); + const paths = Object.keys((doc.paths as Record) ?? {}); + expect(paths).toContain("/v1/generate-intent"); + expect(paths).toContain("/v1/submit-intent"); + expect(paths.some((p) => p.startsWith("/v1/agents/"))).toBe(true); + }); + + it("builds an llms.txt guide naming the intent flow", () => { + const text = buildLlmsText({ serverUrl: "https://agents.example" }); + expect(text).toContain("generate-intent"); + }); +}); diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 000000000..c1d8fd273 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/database/drizzle.config.ts b/packages/database/drizzle.config.ts new file mode 100644 index 000000000..3b9827429 --- /dev/null +++ b/packages/database/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/schema/index.ts", + out: "./drizzle", + dbCredentials: { + url: process.env.DATABASE_URL ?? "", + }, + migrations: { + schema: "drizzle", + table: "__drizzle_migrations", + }, +}); diff --git a/packages/database/drizzle/0000_initial_schema.sql b/packages/database/drizzle/0000_initial_schema.sql new file mode 100644 index 000000000..910b72d90 --- /dev/null +++ b/packages/database/drizzle/0000_initial_schema.sql @@ -0,0 +1,400 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "accountId" text NOT NULL, + "providerId" text NOT NULL, + "userId" text NOT NULL, + "accessToken" text, + "refreshToken" text, + "idToken" text, + "accessTokenExpiresAt" timestamp with time zone, + "refreshTokenExpiresAt" timestamp with time zone, + "scope" text, + "password" text, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agent_grants" ( + "id" text NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "wallet_id" text NOT NULL, + "label" text NOT NULL, + "credential_hash" text NOT NULL, + "actions" text[] NOT NULL, + "recipients" text[] NOT NULL, + "owner_epoch" bigint NOT NULL, + "issued_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "revoked_reason" text, + "owner_message" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agent_grants_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id") +); +--> statement-breakpoint +CREATE TABLE "agents" ( + "id" text NOT NULL, + "tenant_id" text NOT NULL, + "name" text NOT NULL, + "external_user_id" text, + "owner_identity" jsonb, + "owner_near" jsonb, + "owner_counter" bigint DEFAULT 0 NOT NULL, + "owner_account_id" text, + "owner_public_key" text, + "owner_epoch" bigint DEFAULT 1 NOT NULL, + "timelock_delay_seconds" integer DEFAULT 0 NOT NULL, + "timelock_revision" integer DEFAULT 0 NOT NULL, + "budget_daily_cents" bigint, + "budget_weekly_cents" bigint, + "budget_monthly_cents" bigint, + "budget_revision" integer DEFAULT 0 NOT NULL, + "policy_epoch" bigint DEFAULT 1 NOT NULL, + "lifecycle_epoch" bigint DEFAULT 1 NOT NULL, + "lifecycle" text DEFAULT 'pending' NOT NULL, + "archived_at" timestamp with time zone, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agents_tenant_id_id_pk" PRIMARY KEY("tenant_id","id"), + CONSTRAINT "agents_lifecycle_check" CHECK ("agents"."lifecycle" IN ('pending', 'active', 'archived', 'deleted', 'abandoned')) +); +--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "name" text NOT NULL, + "token_hash" text NOT NULL, + "prefix" 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, + CONSTRAINT "api_keys_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "audit_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text, + "action" text NOT NULL, + "resource_id" text NOT NULL, + "actor_key_id" text, + "actor_owner_id" text, + "grant_id" text, + "owner_epoch" bigint, + "policy_epoch" bigint, + "lifecycle_epoch" bigint, + "request_hash" text, + "provider_reference" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "audit_retention_holds" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text, + "reason" text NOT NULL, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "custody_wallets" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "provider_wallet_id" text NOT NULL, + "near_account_id" text NOT NULL, + "evm_address" text NOT NULL, + "credential_ciphertext" text NOT NULL, + "credential_nonce" text NOT NULL, + "credential_key_id" text NOT NULL, + "status" text NOT NULL, + "failure_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "custody_wallets_agent" UNIQUE("tenant_id","agent_id"), + CONSTRAINT "custody_wallets_status_check" CHECK ("custody_wallets"."status" IN ('provisioning', 'active', 'failed', 'deleted')) +); +--> statement-breakpoint +CREATE TABLE "delayed_executions" ( + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "id" text NOT NULL, + "grant_id" text NOT NULL, + "owner_epoch" bigint NOT NULL, + "execute_after" timestamp with time zone NOT NULL, + "state" text DEFAULT 'waiting' NOT NULL, + "ciphertext" text, + "nonce" text, + "key_id" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "delayed_executions_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id"), + CONSTRAINT "delayed_executions_state" CHECK ("delayed_executions"."state" IN ('waiting', 'dispatching', 'finished', 'cancelled', 'uncertain')) +); +--> statement-breakpoint +CREATE TABLE "intent_generations" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "idempotency_key" text, + "request_hash" text NOT NULL, + "agent_id" text NOT NULL, + "state" text DEFAULT 'reserved' NOT NULL, + "draft" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "intent_generations_idempotency" UNIQUE("tenant_id","idempotency_key"), + CONSTRAINT "intent_generations_state_check" CHECK ("intent_generations"."state" IN ('reserved', 'building', 'ready')) +); +--> statement-breakpoint +CREATE TABLE "operation_artifacts" ( + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "operation_id" text NOT NULL, + "grant_id" text NOT NULL, + "owner_epoch" bigint NOT NULL, + "action" text NOT NULL, + "ciphertext" text, + "key_id" text, + "nonce" text, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "operation_artifacts_tenant_id_agent_id_operation_id_pk" PRIMARY KEY("tenant_id","agent_id","operation_id"), + CONSTRAINT "operation_artifacts_action_check" CHECK ("operation_artifacts"."action" IN ('near_message', 'evm_message', 'evm_typed_data', 'evm_transaction')), + CONSTRAINT "operation_artifacts_payload_state_check" CHECK (("operation_artifacts"."consumed_at" IS NULL AND "operation_artifacts"."ciphertext" IS NOT NULL AND "operation_artifacts"."key_id" IS NOT NULL AND "operation_artifacts"."nonce" IS NOT NULL) OR ("operation_artifacts"."consumed_at" IS NOT NULL AND "operation_artifacts"."ciphertext" IS NULL AND "operation_artifacts"."key_id" IS NULL AND "operation_artifacts"."nonce" IS NULL)) +); +--> statement-breakpoint +CREATE TABLE "operation_tombstones" ( + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "id" text NOT NULL, + "request_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "operation_tombstones_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id") +); +--> statement-breakpoint +CREATE TABLE "operations" ( + "id" text NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "actor_key_id" text, + "kind" text NOT NULL, + "action" text, + "request_hash" text NOT NULL, + "status" text NOT NULL, + "authorized_owner_epoch" bigint, + "authorized_policy_epoch" bigint, + "authorized_lifecycle_epoch" bigint, + "authorized_grant_id" text, + "authorized_grant_label" text, + "dispatch_claimed_at" timestamp with time zone, + "dispatch_committed_at" timestamp with time zone, + "result" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "operations_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id"), + CONSTRAINT "operations_status_check" CHECK ("operations"."status" IN ('pending', 'completed', 'uncertain', 'failed')), + CONSTRAINT "operations_kind_check" CHECK ("operations"."kind" IN ('sign', 'execute', 'relay', 'policy')), + CONSTRAINT "operations_sign_result_safe_check" CHECK ("operations"."kind" <> 'sign' OR "operations"."result" IS NULL OR ( + jsonb_typeof("operations"."result") = 'object' + AND "operations"."result"->'redacted' = 'true'::jsonb + AND "operations"."result"->'artifact_redacted' = 'true'::jsonb + AND jsonb_typeof("operations"."result"->'status') = 'string' + AND "operations"."result"->>'status' IN ( + 'pending', 'pending_approval', 'pending_deposit', + 'pending_wallet_signature', 'processing', 'approved', 'success', 'partially_failed', + 'failed', 'refunded', 'rejected', 'expired', 'cancelled', 'needs_review', 'unknown', + 'completed', 'uncertain', 'dispatching', 'submitted', 'applied' + ) + AND ("operations"."result" - ARRAY['redacted', 'artifact_redacted', 'status', 'failure_code', 'provider_request_id']) = '{}'::jsonb + AND (NOT ("operations"."result" ? 'failure_code') OR ( + jsonb_typeof("operations"."result"->'failure_code') = 'string' + AND char_length("operations"."result"->>'failure_code') <= 256 + )) + AND (NOT ("operations"."result" ? 'provider_request_id') OR ( + "operations"."result"->'provider_request_id' = 'null'::jsonb OR ( + jsonb_typeof("operations"."result"->'provider_request_id') = 'string' + AND char_length("operations"."result"->>'provider_request_id') <= 128 + ) + )) + )) +); +--> statement-breakpoint +CREATE TABLE "owner_intents" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "actor_key_id" text NOT NULL, + "type" text NOT NULL, + "standard" text NOT NULL, + "signer" jsonb NOT NULL, + "intent" jsonb NOT NULL, + "context" jsonb NOT NULL, + "preview" jsonb NOT NULL, + "state" text DEFAULT 'pending_signature' NOT NULL, + "operation_id" text, + "result" jsonb, + "failure_code" text, + "idempotency_key" text, + "request_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "owner_intents_idempotency" UNIQUE("tenant_id","idempotency_key"), + CONSTRAINT "owner_intents_state_check" CHECK ("owner_intents"."state" IN ('pending_signature', 'submitted', 'completed', 'failed')), + CONSTRAINT "owner_intents_standard_check" CHECK ("owner_intents"."standard" IN ('nep413', 'nep366', 'eip712', 'webauthn')) +); +--> statement-breakpoint +CREATE TABLE "owner_nonces" ( + "nonce" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rateLimit" ( + "id" text PRIMARY KEY NOT NULL, + "key" text NOT NULL, + "count" integer NOT NULL, + "lastRequest" bigint NOT NULL, + CONSTRAINT "rateLimit_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "request_buckets" ( + "key" text PRIMARY KEY NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "count" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "token" text NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL, + "ipAddress" text, + "userAgent" text, + "userId" text NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "spend_charges" ( + "operation_id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "action" text NOT NULL, + "asset" text NOT NULL, + "amount" text NOT NULL, + "usd_micros" bigint NOT NULL, + "decimals" integer NOT NULL, + "price_coefficient" text NOT NULL, + "price_scale" integer NOT NULL, + "price_updated_at" timestamp with time zone NOT NULL, + "budget_revision" integer NOT NULL, + "dispatch_token" text NOT NULL, + "state" text NOT NULL, + "charged_at" timestamp with time zone NOT NULL, + "released_at" timestamp with time zone, + CONSTRAINT "spend_charges_state_check" CHECK ("spend_charges"."state" IN ('committed', 'released')), + CONSTRAINT "spend_charges_release_check" CHECK (("spend_charges"."state" = 'released') = ("spend_charges"."released_at" IS NOT NULL)), + CONSTRAINT "spend_charges_usd_check" CHECK ("spend_charges"."usd_micros" >= 0) +); +--> statement-breakpoint +CREATE TABLE "sponsor_reservations" ( + "operation_id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "state" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "released_at" timestamp with time zone, + CONSTRAINT "sponsor_reservations_state_check" CHECK ("sponsor_reservations"."state" IN ('reserved', 'committed', 'released')), + CONSTRAINT "sponsor_reservations_release_check" CHECK (("sponsor_reservations"."state" = 'released') = ("sponsor_reservations"."released_at" IS NOT NULL)) +); +--> statement-breakpoint +CREATE TABLE "tenants" ( + "id" text PRIMARY KEY NOT NULL, + "owner_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tenants_owner_user_id_unique" UNIQUE("owner_user_id") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "emailVerified" boolean DEFAULT false NOT NULL, + "image" text, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "wallet_policies" ( + "id" text NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "wallet_id" text NOT NULL, + "version" integer NOT NULL, + "policy_hash" text NOT NULL, + "encrypted_data" text NOT NULL, + "signature_hex" text NOT NULL, + "public_key_hex" text NOT NULL, + "rules" jsonb NOT NULL, + "status" text NOT NULL, + "transaction_hash" text, + "applied_at" timestamp with time zone, + "failure_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "wallet_policies_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id"), + CONSTRAINT "wallet_policies_status_check" CHECK ("wallet_policies"."status" IN ('draft', 'discarded', 'signed', 'applied', 'failed')) +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_grants" ADD CONSTRAINT "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agents" ADD CONSTRAINT "agents_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_retention_holds" ADD CONSTRAINT "audit_retention_holds_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "custody_wallets" ADD CONSTRAINT "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "delayed_executions" ADD CONSTRAINT "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk" FOREIGN KEY ("tenant_id","agent_id","id") REFERENCES "public"."operations"("tenant_id","agent_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "intent_generations" ADD CONSTRAINT "intent_generations_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "operation_artifacts" ADD CONSTRAINT "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk" FOREIGN KEY ("tenant_id","agent_id","operation_id") REFERENCES "public"."operations"("tenant_id","agent_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "operation_artifacts" ADD CONSTRAINT "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk" FOREIGN KEY ("tenant_id","agent_id","grant_id") REFERENCES "public"."agent_grants"("tenant_id","agent_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "operations" ADD CONSTRAINT "operations_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "owner_intents" ADD CONSTRAINT "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tenants" ADD CONSTRAINT "tenants_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_policies" ADD CONSTRAINT "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_user" ON "account" USING btree ("userId");--> statement-breakpoint +CREATE UNIQUE INDEX "agent_grants_credential" ON "agent_grants" USING btree ("credential_hash");--> statement-breakpoint +CREATE INDEX "agent_grants_agent" ON "agent_grants" USING btree ("tenant_id","agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "agents_tenant_created" ON "agents" USING btree ("tenant_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "agents_external_user" ON "agents" USING btree ("tenant_id","external_user_id");--> statement-breakpoint +CREATE INDEX "api_keys_tenant" ON "api_keys" USING btree ("tenant_id");--> statement-breakpoint +CREATE INDEX "audit_events_tenant" ON "audit_events" USING btree ("tenant_id","id");--> statement-breakpoint +CREATE INDEX "audit_events_actor" ON "audit_events" USING btree ("tenant_id","actor_key_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "audit_events_created_at" ON "audit_events" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "audit_retention_holds_active" ON "audit_retention_holds" USING btree ("tenant_id","agent_id","released_at");--> statement-breakpoint +CREATE INDEX "delayed_executions_due" ON "delayed_executions" USING btree ("state","execute_after");--> statement-breakpoint +CREATE INDEX "operation_artifacts_expires" ON "operation_artifacts" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "operations_tenant_status" ON "operations" USING btree ("tenant_id","status","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "operations_agent_created" ON "operations" USING btree ("tenant_id","agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "operations_actor_created" ON "operations" USING btree ("tenant_id","actor_key_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "operations_tenant_id" ON "operations" USING btree ("tenant_id","id");--> statement-breakpoint +CREATE INDEX "owner_intents_agent_created" ON "owner_intents" USING btree ("tenant_id","agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "owner_intents_operation" ON "owner_intents" USING btree ("tenant_id","agent_id","operation_id");--> statement-breakpoint +CREATE INDEX "owner_nonces_agent" ON "owner_nonces" USING btree ("tenant_id","agent_id");--> statement-breakpoint +CREATE INDEX "owner_nonces_expires" ON "owner_nonces" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "spend_charges_agent_window" ON "spend_charges" USING btree ("tenant_id","agent_id","charged_at");--> statement-breakpoint +CREATE INDEX "sponsor_reservations_window" ON "sponsor_reservations" USING btree ("created_at","tenant_id","agent_id");--> statement-breakpoint +CREATE INDEX "sponsor_reservations_agent" ON "sponsor_reservations" USING btree ("tenant_id","agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_policies_version" ON "wallet_policies" USING btree ("wallet_id","version") WHERE "wallet_policies"."status" NOT IN ('draft', 'discarded');--> statement-breakpoint +CREATE INDEX "wallet_policies_agent_version" ON "wallet_policies" USING btree ("tenant_id","agent_id","version" DESC NULLS LAST); \ No newline at end of file diff --git a/packages/database/drizzle/0001_mixed_namora.sql b/packages/database/drizzle/0001_mixed_namora.sql new file mode 100644 index 000000000..d8970e6ce --- /dev/null +++ b/packages/database/drizzle/0001_mixed_namora.sql @@ -0,0 +1,37 @@ +CREATE TABLE "owner_receipts" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "action" text NOT NULL, + "target_id" text NOT NULL, + "owner_epoch" bigint NOT NULL, + "nonce" text NOT NULL, + "payload_hash" text NOT NULL, + "message" jsonb NOT NULL, + "proof" jsonb NOT NULL, + "original" jsonb, + "verification" jsonb NOT NULL, + "decided_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "quota_changes" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "revision" integer NOT NULL, + "operator" text NOT NULL, + "reason" text NOT NULL, + "previous" jsonb NOT NULL, + "profile" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "agent_grants" ALTER COLUMN "recipients" SET DATA TYPE jsonb USING to_jsonb("recipients");--> statement-breakpoint +ALTER TABLE "agent_grants" ADD COLUMN "signing_audiences" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "tenants" ADD COLUMN "quota_profile" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "tenants" ADD COLUMN "quota_revision" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "owner_receipts" ADD CONSTRAINT "owner_receipts_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "quota_changes" ADD CONSTRAINT "quota_changes_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "owner_receipts_nonce" ON "owner_receipts" USING btree ("tenant_id","agent_id","nonce");--> statement-breakpoint +CREATE UNIQUE INDEX "quota_changes_revision" ON "quota_changes" USING btree ("tenant_id","revision"); +--> statement-breakpoint +UPDATE "agent_grants" SET "revoked_at" = COALESCE("revoked_at", clock_timestamp()), "revoked_reason" = COALESCE("revoked_reason", 'typed_destinations_reauthorization_required') WHERE "owner_message"->>'domain' IS DISTINCT FROM 'near-intents-agent-api.agent-grant.v4'; diff --git a/packages/database/drizzle/meta/0000_snapshot.json b/packages/database/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..97d466eca --- /dev/null +++ b/packages/database/drizzle/meta/0000_snapshot.json @@ -0,0 +1,2806 @@ +{ + "id": "2e1bc90d-4a28-4e85-84ee-005613f1c51b", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_grants": { + "name": "agent_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "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 + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_message": { + "name": "owner_message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_grants_credential": { + "name": "agent_grants_credential", + "columns": [ + { + "expression": "credential_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_grants_agent": { + "name": "agent_grants_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "agent_grants", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_grants_tenant_id_agent_id_id_pk": { + "name": "agent_grants_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_identity": { + "name": "owner_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_near": { + "name": "owner_near", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_counter": { + "name": "owner_counter", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_public_key": { + "name": "owner_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timelock_delay_seconds": { + "name": "timelock_delay_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timelock_revision": { + "name": "timelock_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_daily_cents": { + "name": "budget_daily_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_weekly_cents": { + "name": "budget_weekly_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_revision": { + "name": "budget_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "policy_epoch": { + "name": "policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "lifecycle_epoch": { + "name": "lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "agents_tenant_created": { + "name": "agents_tenant_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_external_user": { + "name": "agents_external_user", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_tenant_id_tenants_id_fk": { + "name": "agents_tenant_id_tenants_id_fk", + "tableFrom": "agents", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_tenant_id_id_pk": { + "name": "agents_tenant_id_id_pk", + "columns": [ + "tenant_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_lifecycle_check": { + "name": "agents_lifecycle_check", + "value": "\"agents\".\"lifecycle\" IN ('pending', 'active', 'archived', 'deleted', 'abandoned')" + } + }, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "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": { + "api_keys_tenant": { + "name": "api_keys_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_tenant_id_tenants_id_fk": { + "name": "api_keys_tenant_id_tenants_id_fk", + "tableFrom": "api_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_token_hash_unique": { + "name": "api_keys_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_owner_id": { + "name": "actor_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "policy_epoch": { + "name": "policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "lifecycle_epoch": { + "name": "lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_tenant": { + "name": "audit_events_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor": { + "name": "audit_events_actor", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_created_at": { + "name": "audit_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_tenant_id_tenants_id_fk": { + "name": "audit_events_tenant_id_tenants_id_fk", + "tableFrom": "audit_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_retention_holds": { + "name": "audit_retention_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_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": { + "audit_retention_holds_active": { + "name": "audit_retention_holds_active", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "released_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_retention_holds_tenant_id_tenants_id_fk": { + "name": "audit_retention_holds_tenant_id_tenants_id_fk", + "tableFrom": "audit_retention_holds", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custody_wallets": { + "name": "custody_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_wallet_id": { + "name": "provider_wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evm_address": { + "name": "evm_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_ciphertext": { + "name": "credential_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_nonce": { + "name": "credential_nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key_id": { + "name": "credential_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "custody_wallets", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custody_wallets_agent": { + "name": "custody_wallets_agent", + "nullsNotDistinct": false, + "columns": [ + "tenant_id", + "agent_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "custody_wallets_status_check": { + "name": "custody_wallets_status_check", + "value": "\"custody_wallets\".\"status\" IN ('provisioning', 'active', 'failed', 'deleted')" + } + }, + "isRLSEnabled": false + }, + "public.delayed_executions": { + "name": "delayed_executions", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "execute_after": { + "name": "execute_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delayed_executions_due": { + "name": "delayed_executions_due", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execute_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk": { + "name": "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk", + "tableFrom": "delayed_executions", + "tableTo": "operations", + "columnsFrom": [ + "tenant_id", + "agent_id", + "id" + ], + "columnsTo": [ + "tenant_id", + "agent_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "delayed_executions_tenant_id_agent_id_id_pk": { + "name": "delayed_executions_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "delayed_executions_state": { + "name": "delayed_executions_state", + "value": "\"delayed_executions\".\"state\" IN ('waiting', 'dispatching', 'finished', 'cancelled', 'uncertain')" + } + }, + "isRLSEnabled": false + }, + "public.intent_generations": { + "name": "intent_generations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "intent_generations_tenant_id_tenants_id_fk": { + "name": "intent_generations_tenant_id_tenants_id_fk", + "tableFrom": "intent_generations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "intent_generations_idempotency": { + "name": "intent_generations_idempotency", + "nullsNotDistinct": false, + "columns": [ + "tenant_id", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "intent_generations_state_check": { + "name": "intent_generations_state_check", + "value": "\"intent_generations\".\"state\" IN ('reserved', 'building', 'ready')" + } + }, + "isRLSEnabled": false + }, + "public.operation_artifacts": { + "name": "operation_artifacts", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_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": { + "operation_artifacts_expires": { + "name": "operation_artifacts_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk": { + "name": "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk", + "tableFrom": "operation_artifacts", + "tableTo": "operations", + "columnsFrom": [ + "tenant_id", + "agent_id", + "operation_id" + ], + "columnsTo": [ + "tenant_id", + "agent_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk": { + "name": "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk", + "tableFrom": "operation_artifacts", + "tableTo": "agent_grants", + "columnsFrom": [ + "tenant_id", + "agent_id", + "grant_id" + ], + "columnsTo": [ + "tenant_id", + "agent_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operation_artifacts_tenant_id_agent_id_operation_id_pk": { + "name": "operation_artifacts_tenant_id_agent_id_operation_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "operation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operation_artifacts_action_check": { + "name": "operation_artifacts_action_check", + "value": "\"operation_artifacts\".\"action\" IN ('near_message', 'evm_message', 'evm_typed_data', 'evm_transaction')" + }, + "operation_artifacts_payload_state_check": { + "name": "operation_artifacts_payload_state_check", + "value": "(\"operation_artifacts\".\"consumed_at\" IS NULL AND \"operation_artifacts\".\"ciphertext\" IS NOT NULL AND \"operation_artifacts\".\"key_id\" IS NOT NULL AND \"operation_artifacts\".\"nonce\" IS NOT NULL) OR (\"operation_artifacts\".\"consumed_at\" IS NOT NULL AND \"operation_artifacts\".\"ciphertext\" IS NULL AND \"operation_artifacts\".\"key_id\" IS NULL AND \"operation_artifacts\".\"nonce\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.operation_tombstones": { + "name": "operation_tombstones", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "operation_tombstones_tenant_id_agent_id_id_pk": { + "name": "operation_tombstones_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operations": { + "name": "operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorized_owner_epoch": { + "name": "authorized_owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_policy_epoch": { + "name": "authorized_policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_lifecycle_epoch": { + "name": "authorized_lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_grant_id": { + "name": "authorized_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_grant_label": { + "name": "authorized_grant_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatch_claimed_at": { + "name": "dispatch_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dispatch_committed_at": { + "name": "dispatch_committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operations_tenant_status": { + "name": "operations_tenant_status", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_agent_created": { + "name": "operations_agent_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_actor_created": { + "name": "operations_actor_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_tenant_id": { + "name": "operations_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operations_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "operations_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "operations", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operations_tenant_id_agent_id_id_pk": { + "name": "operations_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operations_status_check": { + "name": "operations_status_check", + "value": "\"operations\".\"status\" IN ('pending', 'completed', 'uncertain', 'failed')" + }, + "operations_kind_check": { + "name": "operations_kind_check", + "value": "\"operations\".\"kind\" IN ('sign', 'execute', 'relay', 'policy')" + }, + "operations_sign_result_safe_check": { + "name": "operations_sign_result_safe_check", + "value": "\"operations\".\"kind\" <> 'sign' OR \"operations\".\"result\" IS NULL OR (\n jsonb_typeof(\"operations\".\"result\") = 'object'\n AND \"operations\".\"result\"->'redacted' = 'true'::jsonb\n AND \"operations\".\"result\"->'artifact_redacted' = 'true'::jsonb\n AND jsonb_typeof(\"operations\".\"result\"->'status') = 'string'\n AND \"operations\".\"result\"->>'status' IN (\n 'pending', 'pending_approval', 'pending_deposit',\n 'pending_wallet_signature', 'processing', 'approved', 'success', 'partially_failed',\n 'failed', 'refunded', 'rejected', 'expired', 'cancelled', 'needs_review', 'unknown',\n 'completed', 'uncertain', 'dispatching', 'submitted', 'applied'\n )\n AND (\"operations\".\"result\" - ARRAY['redacted', 'artifact_redacted', 'status', 'failure_code', 'provider_request_id']) = '{}'::jsonb\n AND (NOT (\"operations\".\"result\" ? 'failure_code') OR (\n jsonb_typeof(\"operations\".\"result\"->'failure_code') = 'string'\n AND char_length(\"operations\".\"result\"->>'failure_code') <= 256\n ))\n AND (NOT (\"operations\".\"result\" ? 'provider_request_id') OR (\n \"operations\".\"result\"->'provider_request_id' = 'null'::jsonb OR (\n jsonb_typeof(\"operations\".\"result\"->'provider_request_id') = 'string'\n AND char_length(\"operations\".\"result\"->>'provider_request_id') <= 128\n )\n ))\n )" + } + }, + "isRLSEnabled": false + }, + "public.owner_intents": { + "name": "owner_intents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "standard": { + "name": "standard", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer": { + "name": "signer", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_signature'" + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_intents_agent_created": { + "name": "owner_intents_agent_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "owner_intents_operation": { + "name": "owner_intents_operation", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "owner_intents", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "owner_intents_idempotency": { + "name": "owner_intents_idempotency", + "nullsNotDistinct": false, + "columns": [ + "tenant_id", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "owner_intents_state_check": { + "name": "owner_intents_state_check", + "value": "\"owner_intents\".\"state\" IN ('pending_signature', 'submitted', 'completed', 'failed')" + }, + "owner_intents_standard_check": { + "name": "owner_intents_standard_check", + "value": "\"owner_intents\".\"standard\" IN ('nep413', 'nep366', 'eip712', 'webauthn')" + } + }, + "isRLSEnabled": false + }, + "public.owner_nonces": { + "name": "owner_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_nonces_agent": { + "name": "owner_nonces_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "owner_nonces_expires": { + "name": "owner_nonces_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rateLimit": { + "name": "rateLimit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lastRequest": { + "name": "lastRequest", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rateLimit_key_unique": { + "name": "rateLimit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_buckets": { + "name": "request_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spend_charges": { + "name": "spend_charges", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usd_micros": { + "name": "usd_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_coefficient": { + "name": "price_coefficient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_scale": { + "name": "price_scale", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_updated_at": { + "name": "price_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "budget_revision": { + "name": "budget_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dispatch_token": { + "name": "dispatch_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "charged_at": { + "name": "charged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spend_charges_agent_window": { + "name": "spend_charges_agent_window", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "charged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "spend_charges_state_check": { + "name": "spend_charges_state_check", + "value": "\"spend_charges\".\"state\" IN ('committed', 'released')" + }, + "spend_charges_release_check": { + "name": "spend_charges_release_check", + "value": "(\"spend_charges\".\"state\" = 'released') = (\"spend_charges\".\"released_at\" IS NOT NULL)" + }, + "spend_charges_usd_check": { + "name": "spend_charges_usd_check", + "value": "\"spend_charges\".\"usd_micros\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.sponsor_reservations": { + "name": "sponsor_reservations", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sponsor_reservations_window": { + "name": "sponsor_reservations_window", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsor_reservations_agent": { + "name": "sponsor_reservations_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sponsor_reservations_state_check": { + "name": "sponsor_reservations_state_check", + "value": "\"sponsor_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + }, + "sponsor_reservations_release_check": { + "name": "sponsor_reservations_release_check", + "value": "(\"sponsor_reservations\".\"state\" = 'released') = (\"sponsor_reservations\".\"released_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenants_owner_user_id_user_id_fk": { + "name": "tenants_owner_user_id_user_id_fk", + "tableFrom": "tenants", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_owner_user_id_unique": { + "name": "tenants_owner_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "owner_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_policies": { + "name": "wallet_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "policy_hash": { + "name": "policy_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature_hex": { + "name": "signature_hex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hex": { + "name": "public_key_hex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_policies_version": { + "name": "wallet_policies_version", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_policies\".\"status\" NOT IN ('draft', 'discarded')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_policies_agent_version": { + "name": "wallet_policies_agent_version", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "wallet_policies", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wallet_policies_tenant_id_agent_id_id_pk": { + "name": "wallet_policies_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "wallet_policies_status_check": { + "name": "wallet_policies_status_check", + "value": "\"wallet_policies\".\"status\" IN ('draft', 'discarded', 'signed', 'applied', 'failed')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/0001_snapshot.json b/packages/database/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..9d8fc9c25 --- /dev/null +++ b/packages/database/drizzle/meta/0001_snapshot.json @@ -0,0 +1,3061 @@ +{ + "id": "a85312f8-708e-490a-bf6a-cb3da57aa914", + "prevId": "2e1bc90d-4a28-4e85-84ee-005613f1c51b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_grants": { + "name": "agent_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "signing_audiences": { + "name": "signing_audiences", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "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 + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_message": { + "name": "owner_message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_grants_credential": { + "name": "agent_grants_credential", + "columns": [ + { + "expression": "credential_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_grants_agent": { + "name": "agent_grants_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "agent_grants", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_grants_tenant_id_agent_id_id_pk": { + "name": "agent_grants_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_identity": { + "name": "owner_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_near": { + "name": "owner_near", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_counter": { + "name": "owner_counter", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_public_key": { + "name": "owner_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timelock_delay_seconds": { + "name": "timelock_delay_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timelock_revision": { + "name": "timelock_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_daily_cents": { + "name": "budget_daily_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_weekly_cents": { + "name": "budget_weekly_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_revision": { + "name": "budget_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "policy_epoch": { + "name": "policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "lifecycle_epoch": { + "name": "lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "agents_tenant_created": { + "name": "agents_tenant_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_external_user": { + "name": "agents_external_user", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_tenant_id_tenants_id_fk": { + "name": "agents_tenant_id_tenants_id_fk", + "tableFrom": "agents", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_tenant_id_id_pk": { + "name": "agents_tenant_id_id_pk", + "columns": [ + "tenant_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_lifecycle_check": { + "name": "agents_lifecycle_check", + "value": "\"agents\".\"lifecycle\" IN ('pending', 'active', 'archived', 'deleted', 'abandoned')" + } + }, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "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": { + "api_keys_tenant": { + "name": "api_keys_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_tenant_id_tenants_id_fk": { + "name": "api_keys_tenant_id_tenants_id_fk", + "tableFrom": "api_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_token_hash_unique": { + "name": "api_keys_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_owner_id": { + "name": "actor_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "policy_epoch": { + "name": "policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "lifecycle_epoch": { + "name": "lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_tenant": { + "name": "audit_events_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor": { + "name": "audit_events_actor", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_created_at": { + "name": "audit_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_tenant_id_tenants_id_fk": { + "name": "audit_events_tenant_id_tenants_id_fk", + "tableFrom": "audit_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_retention_holds": { + "name": "audit_retention_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_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": { + "audit_retention_holds_active": { + "name": "audit_retention_holds_active", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "released_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_retention_holds_tenant_id_tenants_id_fk": { + "name": "audit_retention_holds_tenant_id_tenants_id_fk", + "tableFrom": "audit_retention_holds", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custody_wallets": { + "name": "custody_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_wallet_id": { + "name": "provider_wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evm_address": { + "name": "evm_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_ciphertext": { + "name": "credential_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_nonce": { + "name": "credential_nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key_id": { + "name": "credential_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "custody_wallets", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custody_wallets_agent": { + "name": "custody_wallets_agent", + "nullsNotDistinct": false, + "columns": [ + "tenant_id", + "agent_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "custody_wallets_status_check": { + "name": "custody_wallets_status_check", + "value": "\"custody_wallets\".\"status\" IN ('provisioning', 'active', 'failed', 'deleted')" + } + }, + "isRLSEnabled": false + }, + "public.delayed_executions": { + "name": "delayed_executions", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "execute_after": { + "name": "execute_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delayed_executions_due": { + "name": "delayed_executions_due", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execute_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk": { + "name": "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk", + "tableFrom": "delayed_executions", + "tableTo": "operations", + "columnsFrom": [ + "tenant_id", + "agent_id", + "id" + ], + "columnsTo": [ + "tenant_id", + "agent_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "delayed_executions_tenant_id_agent_id_id_pk": { + "name": "delayed_executions_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "delayed_executions_state": { + "name": "delayed_executions_state", + "value": "\"delayed_executions\".\"state\" IN ('waiting', 'dispatching', 'finished', 'cancelled', 'uncertain')" + } + }, + "isRLSEnabled": false + }, + "public.intent_generations": { + "name": "intent_generations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "intent_generations_tenant_id_tenants_id_fk": { + "name": "intent_generations_tenant_id_tenants_id_fk", + "tableFrom": "intent_generations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "intent_generations_idempotency": { + "name": "intent_generations_idempotency", + "nullsNotDistinct": false, + "columns": [ + "tenant_id", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "intent_generations_state_check": { + "name": "intent_generations_state_check", + "value": "\"intent_generations\".\"state\" IN ('reserved', 'building', 'ready')" + } + }, + "isRLSEnabled": false + }, + "public.operation_artifacts": { + "name": "operation_artifacts", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_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": { + "operation_artifacts_expires": { + "name": "operation_artifacts_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk": { + "name": "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk", + "tableFrom": "operation_artifacts", + "tableTo": "operations", + "columnsFrom": [ + "tenant_id", + "agent_id", + "operation_id" + ], + "columnsTo": [ + "tenant_id", + "agent_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk": { + "name": "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk", + "tableFrom": "operation_artifacts", + "tableTo": "agent_grants", + "columnsFrom": [ + "tenant_id", + "agent_id", + "grant_id" + ], + "columnsTo": [ + "tenant_id", + "agent_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operation_artifacts_tenant_id_agent_id_operation_id_pk": { + "name": "operation_artifacts_tenant_id_agent_id_operation_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "operation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operation_artifacts_action_check": { + "name": "operation_artifacts_action_check", + "value": "\"operation_artifacts\".\"action\" IN ('near_message', 'evm_message', 'evm_typed_data', 'evm_transaction')" + }, + "operation_artifacts_payload_state_check": { + "name": "operation_artifacts_payload_state_check", + "value": "(\"operation_artifacts\".\"consumed_at\" IS NULL AND \"operation_artifacts\".\"ciphertext\" IS NOT NULL AND \"operation_artifacts\".\"key_id\" IS NOT NULL AND \"operation_artifacts\".\"nonce\" IS NOT NULL) OR (\"operation_artifacts\".\"consumed_at\" IS NOT NULL AND \"operation_artifacts\".\"ciphertext\" IS NULL AND \"operation_artifacts\".\"key_id\" IS NULL AND \"operation_artifacts\".\"nonce\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.operation_tombstones": { + "name": "operation_tombstones", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "operation_tombstones_tenant_id_agent_id_id_pk": { + "name": "operation_tombstones_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operations": { + "name": "operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorized_owner_epoch": { + "name": "authorized_owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_policy_epoch": { + "name": "authorized_policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_lifecycle_epoch": { + "name": "authorized_lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_grant_id": { + "name": "authorized_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_grant_label": { + "name": "authorized_grant_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatch_claimed_at": { + "name": "dispatch_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dispatch_committed_at": { + "name": "dispatch_committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operations_tenant_status": { + "name": "operations_tenant_status", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_agent_created": { + "name": "operations_agent_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_actor_created": { + "name": "operations_actor_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_tenant_id": { + "name": "operations_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operations_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "operations_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "operations", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operations_tenant_id_agent_id_id_pk": { + "name": "operations_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operations_status_check": { + "name": "operations_status_check", + "value": "\"operations\".\"status\" IN ('pending', 'completed', 'uncertain', 'failed')" + }, + "operations_kind_check": { + "name": "operations_kind_check", + "value": "\"operations\".\"kind\" IN ('sign', 'execute', 'relay', 'policy')" + }, + "operations_sign_result_safe_check": { + "name": "operations_sign_result_safe_check", + "value": "\"operations\".\"kind\" <> 'sign' OR \"operations\".\"result\" IS NULL OR (\n jsonb_typeof(\"operations\".\"result\") = 'object'\n AND \"operations\".\"result\"->'redacted' = 'true'::jsonb\n AND \"operations\".\"result\"->'artifact_redacted' = 'true'::jsonb\n AND jsonb_typeof(\"operations\".\"result\"->'status') = 'string'\n AND \"operations\".\"result\"->>'status' IN (\n 'pending', 'pending_approval', 'pending_deposit',\n 'pending_wallet_signature', 'processing', 'approved', 'success', 'partially_failed',\n 'failed', 'refunded', 'rejected', 'expired', 'cancelled', 'needs_review', 'unknown',\n 'completed', 'uncertain', 'dispatching', 'submitted', 'applied'\n )\n AND (\"operations\".\"result\" - ARRAY['redacted', 'artifact_redacted', 'status', 'failure_code', 'provider_request_id']) = '{}'::jsonb\n AND (NOT (\"operations\".\"result\" ? 'failure_code') OR (\n jsonb_typeof(\"operations\".\"result\"->'failure_code') = 'string'\n AND char_length(\"operations\".\"result\"->>'failure_code') <= 256\n ))\n AND (NOT (\"operations\".\"result\" ? 'provider_request_id') OR (\n \"operations\".\"result\"->'provider_request_id' = 'null'::jsonb OR (\n jsonb_typeof(\"operations\".\"result\"->'provider_request_id') = 'string'\n AND char_length(\"operations\".\"result\"->>'provider_request_id') <= 128\n )\n ))\n )" + } + }, + "isRLSEnabled": false + }, + "public.owner_intents": { + "name": "owner_intents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "standard": { + "name": "standard", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer": { + "name": "signer", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_signature'" + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_intents_agent_created": { + "name": "owner_intents_agent_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "owner_intents_operation": { + "name": "owner_intents_operation", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "owner_intents", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "owner_intents_idempotency": { + "name": "owner_intents_idempotency", + "nullsNotDistinct": false, + "columns": [ + "tenant_id", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "owner_intents_state_check": { + "name": "owner_intents_state_check", + "value": "\"owner_intents\".\"state\" IN ('pending_signature', 'submitted', 'completed', 'failed')" + }, + "owner_intents_standard_check": { + "name": "owner_intents_standard_check", + "value": "\"owner_intents\".\"standard\" IN ('nep413', 'nep366', 'eip712', 'webauthn')" + } + }, + "isRLSEnabled": false + }, + "public.owner_nonces": { + "name": "owner_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_nonces_agent": { + "name": "owner_nonces_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "owner_nonces_expires": { + "name": "owner_nonces_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.owner_receipts": { + "name": "owner_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "proof": { + "name": "proof", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "original": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verification": { + "name": "verification", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_receipts_nonce": { + "name": "owner_receipts_nonce", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_receipts_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "owner_receipts_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "owner_receipts", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quota_changes": { + "name": "quota_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous": { + "name": "previous", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quota_changes_revision": { + "name": "quota_changes_revision", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quota_changes_tenant_id_tenants_id_fk": { + "name": "quota_changes_tenant_id_tenants_id_fk", + "tableFrom": "quota_changes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rateLimit": { + "name": "rateLimit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lastRequest": { + "name": "lastRequest", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rateLimit_key_unique": { + "name": "rateLimit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_buckets": { + "name": "request_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spend_charges": { + "name": "spend_charges", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usd_micros": { + "name": "usd_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_coefficient": { + "name": "price_coefficient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_scale": { + "name": "price_scale", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_updated_at": { + "name": "price_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "budget_revision": { + "name": "budget_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dispatch_token": { + "name": "dispatch_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "charged_at": { + "name": "charged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spend_charges_agent_window": { + "name": "spend_charges_agent_window", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "charged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "spend_charges_state_check": { + "name": "spend_charges_state_check", + "value": "\"spend_charges\".\"state\" IN ('committed', 'released')" + }, + "spend_charges_release_check": { + "name": "spend_charges_release_check", + "value": "(\"spend_charges\".\"state\" = 'released') = (\"spend_charges\".\"released_at\" IS NOT NULL)" + }, + "spend_charges_usd_check": { + "name": "spend_charges_usd_check", + "value": "\"spend_charges\".\"usd_micros\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.sponsor_reservations": { + "name": "sponsor_reservations", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sponsor_reservations_window": { + "name": "sponsor_reservations_window", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsor_reservations_agent": { + "name": "sponsor_reservations_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sponsor_reservations_state_check": { + "name": "sponsor_reservations_state_check", + "value": "\"sponsor_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + }, + "sponsor_reservations_release_check": { + "name": "sponsor_reservations_release_check", + "value": "(\"sponsor_reservations\".\"state\" = 'released') = (\"sponsor_reservations\".\"released_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "quota_profile": { + "name": "quota_profile", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quota_revision": { + "name": "quota_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenants_owner_user_id_user_id_fk": { + "name": "tenants_owner_user_id_user_id_fk", + "tableFrom": "tenants", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_owner_user_id_unique": { + "name": "tenants_owner_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "owner_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_policies": { + "name": "wallet_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "policy_hash": { + "name": "policy_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature_hex": { + "name": "signature_hex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hex": { + "name": "public_key_hex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_policies_version": { + "name": "wallet_policies_version", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_policies\".\"status\" NOT IN ('draft', 'discarded')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_policies_agent_version": { + "name": "wallet_policies_agent_version", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "wallet_policies", + "tableTo": "agents", + "columnsFrom": [ + "tenant_id", + "agent_id" + ], + "columnsTo": [ + "tenant_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wallet_policies_tenant_id_agent_id_id_pk": { + "name": "wallet_policies_tenant_id_agent_id_id_pk", + "columns": [ + "tenant_id", + "agent_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "wallet_policies_status_check": { + "name": "wallet_policies_status_check", + "value": "\"wallet_policies\".\"status\" IN ('draft', 'discarded', 'signed', 'applied', 'failed')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json new file mode 100644 index 000000000..bea39da2e --- /dev/null +++ b/packages/database/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1790753318965, + "tag": "0000_initial_schema", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1790756660354, + "tag": "0001_mixed_namora", + "breakpoints": true + } + ] +} diff --git a/packages/database/package.json b/packages/database/package.json new file mode 100644 index 000000000..223277886 --- /dev/null +++ b/packages/database/package.json @@ -0,0 +1,39 @@ +{ + "name": "@near-intents-agent-api/database", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "db:generate": "drizzle-kit generate --config=drizzle.config.ts", + "db:migrate": "drizzle-kit migrate --config=drizzle.config.ts", + "db:studio": "drizzle-kit studio --config=drizzle.config.ts", + "db:check": "drizzle-kit check --config=drizzle.config.ts" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./schema": { + "types": "./src/schema/index.ts", + "import": "./src/schema/index.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "dependencies": { + "@near-intents-agent-api/contracts": "workspace:*", + "drizzle-orm": "catalog:", + "pg": "catalog:" + }, + "devDependencies": { + "@electric-sql/pglite": "catalog:", + "@types/node": "catalog:", + "@types/pg": "catalog:", + "drizzle-kit": "catalog:" + } +} diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts new file mode 100644 index 000000000..13de72c1c --- /dev/null +++ b/packages/database/src/client.ts @@ -0,0 +1,35 @@ +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { advisoryLockRunner, type DatabaseHandle } from "./lock.js"; +import { schema } from "./schema/index.js"; +import { tryAdvisoryLockRunner } from "./try-lock.js"; + +export type Db = NodePgDatabase; +export type Tx = Parameters[0]>[0]; + +export type { DatabaseHandle } from "./lock.js"; + +export function createDatabase( + connectionString: string, + options: { max?: number; connectionTimeoutMillis?: number } = {}, +): DatabaseHandle { + const pool = new Pool({ + connectionString, + max: options.max ?? 10, + connectionTimeoutMillis: options.connectionTimeoutMillis ?? 10_000, + }); + const lockPool = new Pool({ + connectionString, + max: options.max ?? 10, + connectionTimeoutMillis: 1_000, + }); + return { + tryAdvisoryLock: tryAdvisoryLockRunner(lockPool), + db: drizzle(pool, { schema }), + close: async () => { + await Promise.all([pool.end(), lockPool.end()]); + }, + withAdvisoryLock: advisoryLockRunner(pool), + }; +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts new file mode 100644 index 000000000..fdff84529 --- /dev/null +++ b/packages/database/src/index.ts @@ -0,0 +1,44 @@ +export type { DatabaseHandle, Db, Tx } from "./client.js"; +export { createDatabase } from "./client.js"; +export { advisoryLockKey } from "./lock.js"; +export type { + AgentLifecycle, + CustodyWalletStatus, + OperationKind, + OperationStatus, + OwnerIntentState, + SpendChargeState, + SponsorReservationState, + WalletPolicyStatus, +} from "./schema/domain.js"; +export { + account, + agentGrants, + agents, + apiKeys, + auditEvents, + auditRetentionHolds, + authSchema, + custodyWallets, + delayedExecutions, + intentGenerations, + operationArtifacts, + operations, + operationTombstones, + ownerIntents, + ownerNonces, + ownerReceipts, + quotaChanges, + rateLimit, + requestBuckets, + schema, + session, + spendCharges, + sponsorReservations, + tenants, + user, + verification, + walletPolicies, +} from "./schema/index.js"; + +export type { TryAdvisoryLock, TryLockResult } from "./try-lock.js"; diff --git a/packages/database/src/lock.ts b/packages/database/src/lock.ts new file mode 100644 index 000000000..97806b5d0 --- /dev/null +++ b/packages/database/src/lock.ts @@ -0,0 +1,73 @@ +import type { Pool } from "pg"; +import type { Db } from "./client.js"; +import type { TryAdvisoryLock } from "./try-lock.js"; + +export type DatabaseHandle = { + db: Db; + close(): Promise; + tryAdvisoryLock?: TryAdvisoryLock; + /** + * Runs `run` while holding a Postgres session advisory lock derived from `key`. + * + * One pooled client is held for the whole critical section, because a session advisory lock + * belongs to the connection that took it — releasing through a different pooled client would + * silently fail and leak the lock. Callers must keep `run` short. Postgres also releases the + * lock if the connection dies mid-flight. + */ + withAdvisoryLock(key: string, run: () => Promise): Promise; +}; + +/** Stable signed 64-bit advisory-lock key from an arbitrary string. */ +export function advisoryLockKey(key: string): bigint { + // FNV-1a over UTF-8 bytes, folded into the signed bigint range Postgres accepts. + let hash = 0xcbf29ce484222325n; + for (const byte of Buffer.from(key, "utf8")) { + hash ^= BigInt(byte); + hash = BigInt.asUintN(64, hash * 0x100000001b3n); + } + return BigInt.asIntN(64, hash); +} + +/** + * Session advisory locks, queued in-process per key first. + * + * A waiter blocks inside `pg_advisory_lock` while holding a pooled connection. Without the local + * queue, a burst of callers on one replica can take every pooled connection while they wait, and + * the lock holder, which needs connections to finish its critical section, then starves until the + * pool's connection timeout fails it. Queuing locally keeps at most one connection per key per + * process waiting on Postgres; Postgres still serializes across processes and replicas. + */ +export function advisoryLockRunner(pool: Pool) { + const tails = new Map>(); + return async function withAdvisoryLock(key: string, run: () => Promise): Promise { + const previous = tails.get(key) ?? Promise.resolve(); + let release!: () => void; + const turn = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => turn); + tails.set(key, tail); + await previous; + try { + return await holdSessionLock(pool, key, run); + } finally { + release(); + if (tails.get(key) === tail) tails.delete(key); + } + }; +} + +async function holdSessionLock(pool: Pool, key: string, run: () => Promise): Promise { + const client = await pool.connect(); + const lockKey = advisoryLockKey(key); + try { + await client.query("SELECT pg_advisory_lock($1)", [lockKey]); + try { + return await run(); + } finally { + await client.query("SELECT pg_advisory_unlock($1)", [lockKey]); + } + } finally { + client.release(); + } +} diff --git a/packages/database/src/schema/auth.ts b/packages/database/src/schema/auth.ts new file mode 100644 index 000000000..87df307ee --- /dev/null +++ b/packages/database/src/schema/auth.ts @@ -0,0 +1,62 @@ +import { bigint, boolean, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("emailVerified").notNull().default(false), + image: text("image"), + createdAt: timestamp("createdAt", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updatedAt", { withTimezone: true }).notNull().defaultNow(), +}); + +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp("expiresAt", { withTimezone: true }).notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("createdAt", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updatedAt", { withTimezone: true }).notNull().defaultNow(), + ipAddress: text("ipAddress"), + userAgent: text("userAgent"), + userId: text("userId") + .notNull() + .references(() => user.id), +}); + +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("accountId").notNull(), + providerId: text("providerId").notNull(), + userId: text("userId") + .notNull() + .references(() => user.id), + accessToken: text("accessToken"), + refreshToken: text("refreshToken"), + idToken: text("idToken"), + accessTokenExpiresAt: timestamp("accessTokenExpiresAt", { withTimezone: true }), + refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", { withTimezone: true }), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("createdAt", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updatedAt", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index("account_user").on(table.userId)], +); + +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expiresAt", { withTimezone: true }).notNull(), + createdAt: timestamp("createdAt", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updatedAt", { withTimezone: true }).notNull().defaultNow(), +}); + +export const rateLimit = pgTable("rateLimit", { + id: text("id").primaryKey(), + key: text("key").notNull().unique(), + count: integer("count").notNull(), + lastRequest: bigint("lastRequest", { mode: "number" }).notNull(), +}); diff --git a/packages/database/src/schema/domain.ts b/packages/database/src/schema/domain.ts new file mode 100644 index 000000000..d8b727d00 --- /dev/null +++ b/packages/database/src/schema/domain.ts @@ -0,0 +1,681 @@ +import type { + GrantDestination, + OwnerNear, + OwnerWallet, + PartnerQuotaProfile, +} from "@near-intents-agent-api/contracts"; +import { sql } from "drizzle-orm"; +import { + bigint, + bigserial, + check, + foreignKey, + index, + integer, + jsonb, + pgTable, + primaryKey, + text, + timestamp, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { user } from "./auth.js"; + +export type OperationKind = "sign" | "execute" | "relay" | "policy"; +export type OperationStatus = "pending" | "completed" | "uncertain" | "failed"; +export type CustodyWalletStatus = "provisioning" | "active" | "failed" | "deleted"; +export type WalletPolicyStatus = "draft" | "discarded" | "signed" | "applied" | "failed"; +export type SponsorReservationState = "reserved" | "committed" | "released"; +export type SpendChargeState = "committed" | "released"; +export type AgentLifecycle = "pending" | "active" | "archived" | "deleted" | "abandoned"; +export type OwnerIntentState = "pending_signature" | "submitted" | "completed" | "failed"; + +export const tenants = pgTable("tenants", { + quotaProfile: jsonb("quota_profile").$type().notNull().default({}), + quotaRevision: integer("quota_revision").notNull().default(0), + id: text("id").primaryKey(), + ownerUserId: text("owner_user_id") + .notNull() + .unique() + .references(() => user.id), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const apiKeys = pgTable( + "api_keys", + { + id: text("id").primaryKey(), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id), + name: text("name").notNull(), + tokenHash: text("token_hash").notNull().unique(), + prefix: text("prefix").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index("api_keys_tenant").on(table.tenantId)], +); + +export const agents = pgTable( + "agents", + { + id: text("id").notNull(), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id), + name: text("name").notNull(), + externalUserId: text("external_user_id"), + ownerIdentity: jsonb("owner_identity").$type(), + ownerNear: jsonb("owner_near").$type(), + ownerCounter: bigint("owner_counter", { mode: "number" }).notNull().default(0), + ownerAccountId: text("owner_account_id"), + ownerPublicKey: text("owner_public_key"), + /** + * Monotonic epochs. One dispatch fence compares the epochs an operation was authorized under + * with the epochs at its commitment point, so a policy update, owner change or lifecycle + * change between admission and dispatch invalidates the pending authorization. They only ever + * increase and are never reset after an error. + */ + ownerEpoch: bigint("owner_epoch", { mode: "number" }).notNull().default(1), + timelockDelaySeconds: integer("timelock_delay_seconds").notNull().default(0), + timelockRevision: integer("timelock_revision").notNull().default(0), + /** Owner-signed USD spend caps in cents over rolling 24 h / 7 d / 30 d; null is uncapped. */ + budgetDailyCents: bigint("budget_daily_cents", { mode: "number" }), + budgetWeeklyCents: bigint("budget_weekly_cents", { mode: "number" }), + budgetMonthlyCents: bigint("budget_monthly_cents", { mode: "number" }), + budgetRevision: integer("budget_revision").notNull().default(0), + policyEpoch: bigint("policy_epoch", { mode: "number" }).notNull().default(1), + lifecycleEpoch: bigint("lifecycle_epoch", { mode: "number" }).notNull().default(1), + /** + * Authoritative lifecycle state. Deriving archived/deleted from the audit log conflated + * append-only forensic output with active authorization state; these columns are the state, + * and the audit rows are the record of how it changed. + */ + /** + * `pending` until the owner's onboarding request finalizes on chain; only then are the owner + * columns written. `abandoned` agents never received a valid onboarding signature. + */ + lifecycle: text("lifecycle").$type().notNull().default("pending"), + archivedAt: timestamp("archived_at", { withTimezone: true }), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.tenantId, table.id] }), + // Cursor pagination orders by (created_at, id) per tenant; without this it sorts the tenant. + index("agents_tenant_created").on(table.tenantId, table.createdAt.desc(), table.id.desc()), + index("agents_external_user").on(table.tenantId, table.externalUserId), + check( + "agents_lifecycle_check", + sql`${table.lifecycle} IN ('pending', 'active', 'archived', 'deleted', 'abandoned')`, + ), + ], +); + +export const custodyWallets = pgTable( + "custody_wallets", + { + id: text("id").primaryKey(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + providerWalletId: text("provider_wallet_id").notNull(), + nearAccountId: text("near_account_id").notNull(), + evmAddress: text("evm_address").notNull(), + credentialCiphertext: text("credential_ciphertext").notNull(), + credentialNonce: text("credential_nonce").notNull(), + credentialKeyId: text("credential_key_id").notNull(), + status: text("status").$type().notNull(), + failureReason: text("failure_reason"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ + columns: [table.tenantId, table.agentId], + foreignColumns: [agents.tenantId, agents.id], + }), + unique("custody_wallets_agent").on(table.tenantId, table.agentId), + check( + "custody_wallets_status_check", + sql`${table.status} IN ('provisioning', 'active', 'failed', 'deleted')`, + ), + ], +); + +export const walletPolicies = pgTable( + "wallet_policies", + { + id: text("id").notNull(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + walletId: text("wallet_id").notNull(), + version: integer("version").notNull(), + policyHash: text("policy_hash").notNull(), + encryptedData: text("encrypted_data").notNull(), + signatureHex: text("signature_hex").notNull(), + publicKeyHex: text("public_key_hex").notNull(), + rules: jsonb("rules").$type().notNull(), + status: text("status").$type().notNull(), + transactionHash: text("transaction_hash"), + appliedAt: timestamp("applied_at", { withTimezone: true }), + failureReason: text("failure_reason"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.tenantId, table.agentId, table.id] }), + foreignKey({ + columns: [table.tenantId, table.agentId], + foreignColumns: [agents.tenantId, agents.id], + }), + uniqueIndex("wallet_policies_version") + .on(table.walletId, table.version) + .where(sql`${table.status} NOT IN ('draft', 'discarded')`), + // `latestPolicy`/`latestAppliedPolicy` sort by version within one agent; without this the + // dashboard and every pre-flight check scans the tenant's policy history. + index("wallet_policies_agent_version").on(table.tenantId, table.agentId, table.version.desc()), + check( + "wallet_policies_status_check", + sql`${table.status} IN ('draft', 'discarded', 'signed', 'applied', 'failed')`, + ), + ], +); + +export const operations = pgTable( + "operations", + { + id: text("id").notNull(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + actorKeyId: text("actor_key_id"), + kind: text("kind").$type().notNull(), + /** + * What the operation does, recorded at admission: the execution action (`swap`, `withdraw`, + * …), `sign:`, `relay`, `policy`, `onboarding` or `delete`. Status reads type a + * correlation id from it before any provider result exists. + */ + action: text("action"), + requestHash: text("request_hash").notNull(), + status: text("status").$type().notNull(), + /** + * The epoch snapshot the operation was authorized under. The fence compares it with the + * agent's current epochs at the commitment point; a mismatch means the authorization is + * stale and dispatch must fail closed. + */ + authorizedOwnerEpoch: bigint("authorized_owner_epoch", { mode: "number" }), + authorizedPolicyEpoch: bigint("authorized_policy_epoch", { mode: "number" }), + authorizedLifecycleEpoch: bigint("authorized_lifecycle_epoch", { mode: "number" }), + /** + * Owner grant a delegated operation was admitted under. The fence re-validates this exact + * grant and the key that sent the request at dispatch, so revoking either or expiry after + * admission fails closed. Null for owner-signed operations, whose authority is the owner + * signature rather than a delegation. + */ + authorizedGrantId: text("authorized_grant_id"), + /** The grant's owner-signed label at admission, for attribution in status and history. */ + authorizedGrantLabel: text("authorized_grant_label"), + /** + * When the operation was first claimed for dispatch. From here it reads `uncertain`, but local + * preparation (readiness reads, gas sponsorship) still runs before any provider write. + */ + dispatchClaimedAt: timestamp("dispatch_claimed_at", { withTimezone: true }), + /** + * The latest dispatch commitment: the serialized authority check taken immediately before a + * provider write. Revocation serializes against it, so it is the point that decides whether a + * revocation or a dispatch won. A claimed operation without it never reached a provider. + * Provider idempotency retention is measured from here. + */ + dispatchCommittedAt: timestamp("dispatch_committed_at", { withTimezone: true }), + result: jsonb("result").$type(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.tenantId, table.agentId, table.id] }), + foreignKey({ + columns: [table.tenantId, table.agentId], + foreignColumns: [agents.tenantId, agents.id], + }), + // Dashboard counts and pending-deletion lookups filter by tenant + status; policy + // reconciliation looks up one operation by result->>'policy_id'. + index("operations_tenant_status").on(table.tenantId, table.status, table.createdAt.desc()), + index("operations_agent_created").on(table.tenantId, table.agentId, table.createdAt.desc()), + index("operations_actor_created").on(table.tenantId, table.actorKeyId, table.createdAt.desc()), + // Status reads resolve a correlation id without its agent. + index("operations_tenant_id").on(table.tenantId, table.id), + check( + "operations_status_check", + sql`${table.status} IN ('pending', 'completed', 'uncertain', 'failed')`, + ), + check("operations_kind_check", sql`${table.kind} IN ('sign', 'execute', 'relay', 'policy')`), + check( + "operations_sign_result_safe_check", + sql`${table.kind} <> 'sign' OR ${table.result} IS NULL OR ( + jsonb_typeof(${table.result}) = 'object' + AND ${table.result}->'redacted' = 'true'::jsonb + AND ${table.result}->'artifact_redacted' = 'true'::jsonb + AND jsonb_typeof(${table.result}->'status') = 'string' + AND ${table.result}->>'status' IN ( + 'pending', 'pending_approval', 'pending_deposit', + 'pending_wallet_signature', 'processing', 'approved', 'success', 'partially_failed', + 'failed', 'refunded', 'rejected', 'expired', 'cancelled', 'needs_review', 'unknown', + 'completed', 'uncertain', 'dispatching', 'submitted', 'applied' + ) + AND (${table.result} - ARRAY['redacted', 'artifact_redacted', 'status', 'failure_code', 'provider_request_id']) = '{}'::jsonb + AND (NOT (${table.result} ? 'failure_code') OR ( + jsonb_typeof(${table.result}->'failure_code') = 'string' + AND char_length(${table.result}->>'failure_code') <= 256 + )) + AND (NOT (${table.result} ? 'provider_request_id') OR ( + ${table.result}->'provider_request_id' = 'null'::jsonb OR ( + jsonb_typeof(${table.result}->'provider_request_id') = 'string' + AND char_length(${table.result}->>'provider_request_id') <= 128 + ) + )) + )`, + ), + ], +); + +/** Compact, permanent admission record prevents an old idempotency key from dispatching again. */ +export const operationTombstones = pgTable( + "operation_tombstones", + { + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + id: text("id").notNull(), + requestHash: text("request_hash").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [primaryKey({ columns: [table.tenantId, table.agentId, table.id] })], +); + +/** + * Short-lived operation artifact channel for signatures. Payloads + * stay encrypted and out of generic operation results; ACK atomically clears ciphertext. + */ +export const operationArtifacts = pgTable( + "operation_artifacts", + { + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + operationId: text("operation_id").notNull(), + /** Only a request carrying this grant's token (or the owner) can read the signature. */ + grantId: text("grant_id").notNull(), + ownerEpoch: bigint("owner_epoch", { mode: "number" }).notNull(), + action: text("action").notNull(), + ciphertext: text("ciphertext"), + keyId: text("key_id"), + nonce: text("nonce"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.tenantId, table.agentId, table.operationId] }), + foreignKey({ + columns: [table.tenantId, table.agentId, table.operationId], + foreignColumns: [operations.tenantId, operations.agentId, operations.id], + }).onDelete("cascade"), + foreignKey({ + columns: [table.tenantId, table.agentId, table.grantId], + foreignColumns: [agentGrants.tenantId, agentGrants.agentId, agentGrants.id], + }).onDelete("cascade"), + index("operation_artifacts_expires").on(table.expiresAt), + check( + "operation_artifacts_action_check", + sql`${table.action} IN ('near_message', 'evm_message', 'evm_typed_data', 'evm_transaction')`, + ), + check( + "operation_artifacts_payload_state_check", + sql`(${table.consumedAt} IS NULL AND ${table.ciphertext} IS NOT NULL AND ${table.keyId} IS NOT NULL AND ${table.nonce} IS NOT NULL) OR (${table.consumedAt} IS NOT NULL AND ${table.ciphertext} IS NULL AND ${table.keyId} IS NULL AND ${table.nonce} IS NULL)`, + ), + ], +); + +export const auditEvents = pgTable( + "audit_events", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id), + agentId: text("agent_id"), + action: text("action").notNull(), + resourceId: text("resource_id").notNull(), + /** + * Provenance: who acted, under which delegation, against which versions, and what exact + * authorization was presented. Without this an incident review can only see that something + * happened, not whether the owner's intent covered it. Raw secrets are never recorded here. + */ + actorKeyId: text("actor_key_id"), + /** Verified wallet/passkey principal that authorized owner-exclusive actions. */ + actorOwnerId: text("actor_owner_id"), + grantId: text("grant_id"), + ownerEpoch: bigint("owner_epoch", { mode: "number" }), + policyEpoch: bigint("policy_epoch", { mode: "number" }), + lifecycleEpoch: bigint("lifecycle_epoch", { mode: "number" }), + /** Canonical hash of the exact request or owner authorization this event concerns. */ + requestHash: text("request_hash"), + /** Provider transaction/request identity, recorded independently of application state. */ + providerReference: text("provider_reference"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("audit_events_tenant").on(table.tenantId, table.id), + index("audit_events_actor").on(table.tenantId, table.actorKeyId, table.createdAt.desc()), + index("audit_events_created_at").on(table.createdAt), + ], +); + +/** Operator-applied legal/incident hold; null agent ID protects whole tenant. */ +export const auditRetentionHolds = pgTable( + "audit_retention_holds", + { + id: text("id").primaryKey(), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id), + agentId: text("agent_id"), + reason: text("reason").notNull(), + releasedAt: timestamp("released_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("audit_retention_holds_active").on(table.tenantId, table.agentId, table.releasedAt), + ], +); + +export const requestBuckets = pgTable("request_buckets", { + key: text("key").primaryKey(), + windowStart: timestamp("window_start", { withTimezone: true }).notNull(), + count: integer("count").notNull(), +}); + +/** + * Sponsorship budget accounting. A reservation is a first-class row with one state rather than a + * pair of log rows that must be correlated by hand. `releasedAt` is only set through an atomic + * `reserved -> released` transition, so a repeated release cannot credit twice. + */ +export const sponsorReservations = pgTable( + "sponsor_reservations", + { + operationId: text("operation_id").primaryKey(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + state: text("state").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + releasedAt: timestamp("released_at", { withTimezone: true }), + }, + (table) => [ + // The daily window is counted per tenant and per agent, excluding released rows. + index("sponsor_reservations_window").on(table.createdAt, table.tenantId, table.agentId), + index("sponsor_reservations_agent").on(table.tenantId, table.agentId, table.createdAt.desc()), + check( + "sponsor_reservations_state_check", + sql`${table.state} IN ('reserved', 'committed', 'released')`, + ), + check( + "sponsor_reservations_release_check", + sql`(${table.state} = 'released') = (${table.releasedAt} IS NOT NULL)`, + ), + ], +); + +/** + * USD spend accounting for the owner's budget. One row per execution that moves value out of the + * agent (swap, withdraw, transfer), inserted in the same transaction that commits the execution's + * dispatch, so a charge exists exactly when the write may reach the provider and there is never a + * provisional hold to strand or to release from the wrong attempt. + * + * `released` is reached only by the winning commitment, through an atomic + * `committed -> released` transition bound to `dispatchToken`, for a write the provider refused + * before admitting it. Anything that may have moved value stays counted. + */ +export const spendCharges = pgTable( + "spend_charges", + { + operationId: text("operation_id").primaryKey(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + action: text("action").notNull(), + asset: text("asset").notNull(), + /** Atomic amount of `asset`. */ + amount: text("amount").notNull(), + /** USD value in millionths, rounded up once from the exact inputs below. */ + usdMicros: bigint("usd_micros", { mode: "number" }).notNull(), + /** Token decimals and the exact price (`coefficient / 10^scale` USD per whole token). */ + decimals: integer("decimals").notNull(), + priceCoefficient: text("price_coefficient").notNull(), + priceScale: integer("price_scale").notNull(), + /** The feed's own timestamp for the price, not when it was fetched. */ + priceUpdatedAt: timestamp("price_updated_at", { withTimezone: true }).notNull(), + /** The budget revision whose caps admitted this charge. */ + budgetRevision: integer("budget_revision").notNull(), + /** Random identity of the dispatch commitment that created the charge; only it may refund. */ + dispatchToken: text("dispatch_token").notNull(), + state: text("state").$type().notNull(), + /** Database time of the admission decision; the rolling windows count from it. */ + chargedAt: timestamp("charged_at", { withTimezone: true }).notNull(), + releasedAt: timestamp("released_at", { withTimezone: true }), + }, + (table) => [ + index("spend_charges_agent_window").on(table.tenantId, table.agentId, table.chargedAt), + check("spend_charges_state_check", sql`${table.state} IN ('committed', 'released')`), + check( + "spend_charges_release_check", + sql`(${table.state} = 'released') = (${table.releasedAt} IS NOT NULL)`, + ), + check("spend_charges_usd_check", sql`${table.usdMicros} >= 0`), + ], +); + +/** + * Owner-issued delegation of bounded execution authority to one grant token. A tenant API key + * proves which partner is calling; only this row proves the end owner authorized the holder of + * this grant's token to act on this agent, within these actions and recipients. Rows are + * immutable except for revocation; an agent holds many live grants at once. + */ +export const agentGrants = pgTable( + "agent_grants", + { + id: text("id").notNull(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + walletId: text("wallet_id").notNull(), + label: text("label").notNull(), + /** SHA-256 of the grant token; the token itself is never stored. */ + credentialHash: text("credential_hash").notNull(), + actions: text("actions").array().$type().notNull(), + recipients: jsonb("recipients").$type().notNull(), + signingAudiences: text("signing_audiences").array().$type().notNull().default([]), + /** Owner authority generation that issued this grant. */ + ownerEpoch: bigint("owner_epoch", { mode: "number" }).notNull(), + issuedAt: timestamp("issued_at", { withTimezone: true }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + revokedReason: text("revoked_reason"), + /** The canonical owner-signed message, kept for independent audit reconstruction. */ + ownerMessage: jsonb("owner_message").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.tenantId, table.agentId, table.id] }), + foreignKey({ + columns: [table.tenantId, table.agentId], + foreignColumns: [agents.tenantId, agents.id], + }), + uniqueIndex("agent_grants_credential").on(table.credentialHash), + index("agent_grants_agent").on(table.tenantId, table.agentId, table.createdAt.desc()), + ], +); + +export const ownerNonces = pgTable( + "owner_nonces", + { + nonce: text("nonce").primaryKey(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("owner_nonces_agent").on(table.tenantId, table.agentId), + // Expired-nonce cleanup deletes by expiry; without this it scans the table each time. + index("owner_nonces_expires").on(table.expiresAt), + ], +); + +export const delayedExecutions = pgTable( + "delayed_executions", + { + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + id: text("id").notNull(), + grantId: text("grant_id").notNull(), + ownerEpoch: bigint("owner_epoch", { mode: "number" }).notNull(), + executeAfter: timestamp("execute_after", { withTimezone: true }).notNull(), + state: text("state") + .$type<"waiting" | "dispatching" | "finished" | "cancelled" | "uncertain">() + .notNull() + .default("waiting"), + ciphertext: text("ciphertext"), + nonce: text("nonce"), + keyId: text("key_id"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.tenantId, table.agentId, table.id] }), + foreignKey({ + columns: [table.tenantId, table.agentId, table.id], + foreignColumns: [operations.tenantId, operations.agentId, operations.id], + }), + index("delayed_executions_due").on(table.state, table.executeAfter), + check( + "delayed_executions_state", + sql`${table.state} IN ('waiting', 'dispatching', 'finished', 'cancelled', 'uncertain')`, + ), + ], +); + +/** + * One owner action awaiting, or authorized by, the owner's wallet signature. The server builds + * the exact payload the wallet signs and keeps it here with the data needed to verify and apply + * the signature, so a client only has to carry the correlation id (the row id). Payloads hold + * single-use nonces and public identities, never secrets. + */ +export const ownerIntents = pgTable( + "owner_intents", + { + id: text("id").primaryKey(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + actorKeyId: text("actor_key_id").notNull(), + type: text("type").notNull(), + standard: text("standard").notNull(), + /** Public identity whose wallet must sign. */ + signer: jsonb("signer").$type().notNull(), + /** `{ standard, payload }` exactly as returned to the client. */ + intent: jsonb("intent").$type().notNull(), + /** Server-side data the submission is verified and applied against. */ + context: jsonb("context").$type().notNull(), + preview: jsonb("preview").$type().notNull(), + state: text("state").$type().notNull().default("pending_signature"), + /** Operation that carries the on-chain or provider outcome, when there is one. */ + operationId: text("operation_id"), + /** Final details of an intent applied synchronously. */ + result: jsonb("result").$type(), + failureCode: text("failure_code"), + idempotencyKey: text("idempotency_key"), + requestHash: text("request_hash").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ + columns: [table.tenantId, table.agentId], + foreignColumns: [agents.tenantId, agents.id], + }), + unique("owner_intents_idempotency").on(table.tenantId, table.idempotencyKey), + index("owner_intents_agent_created").on(table.tenantId, table.agentId, table.createdAt.desc()), + index("owner_intents_operation").on(table.tenantId, table.agentId, table.operationId), + check( + "owner_intents_state_check", + sql`${table.state} IN ('pending_signature', 'submitted', 'completed', 'failed')`, + ), + check( + "owner_intents_standard_check", + sql`${table.standard} IN ('nep413', 'nep366', 'eip712', 'webauthn')`, + ), + ], +); + +/** Reserves request/resource identity before any generation side effect; no agent FK yet. */ +export const intentGenerations = pgTable( + "intent_generations", + { + id: text("id").primaryKey(), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id), + idempotencyKey: text("idempotency_key"), + requestHash: text("request_hash").notNull(), + agentId: text("agent_id").notNull(), + state: text("state").$type<"reserved" | "building" | "ready">().notNull().default("reserved"), + draft: jsonb("draft").$type(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique("intent_generations_idempotency").on(table.tenantId, table.idempotencyKey), + check( + "intent_generations_state_check", + sql`${table.state} IN ('reserved', 'building', 'ready')`, + ), + ], +); + +/** Original consent evidence. No public API exposes these proof fields. Retained with grant history. */ +export const ownerReceipts = pgTable( + "owner_receipts", + { + id: text("id").primaryKey(), + tenantId: text("tenant_id").notNull(), + agentId: text("agent_id").notNull(), + action: text("action").notNull(), + targetId: text("target_id").notNull(), + ownerEpoch: bigint("owner_epoch", { mode: "number" }).notNull(), + nonce: text("nonce").notNull(), + payloadHash: text("payload_hash").notNull(), + message: jsonb("message").$type().notNull(), + proof: jsonb("proof").$type().notNull(), + original: jsonb("original").$type(), + verification: jsonb("verification").$type().notNull(), + decidedAt: timestamp("decided_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ + columns: [table.tenantId, table.agentId], + foreignColumns: [agents.tenantId, agents.id], + }), + uniqueIndex("owner_receipts_nonce").on(table.tenantId, table.agentId, table.nonce), + ], +); + +export const quotaChanges = pgTable( + "quota_changes", + { + id: text("id").primaryKey(), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id), + revision: integer("revision").notNull(), + operator: text("operator").notNull(), + reason: text("reason").notNull(), + previous: jsonb("previous").$type().notNull(), + profile: jsonb("profile").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex("quota_changes_revision").on(table.tenantId, table.revision)], +); diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts new file mode 100644 index 000000000..02b6ee102 --- /dev/null +++ b/packages/database/src/schema/index.ts @@ -0,0 +1,72 @@ +import { account, rateLimit, session, user, verification } from "./auth.js"; +import { + agentGrants, + agents, + apiKeys, + auditEvents, + auditRetentionHolds, + custodyWallets, + delayedExecutions, + intentGenerations, + operationArtifacts, + operations, + operationTombstones, + ownerIntents, + ownerNonces, + ownerReceipts, + quotaChanges, + requestBuckets, + spendCharges, + sponsorReservations, + tenants, + walletPolicies, +} from "./domain.js"; + +export { account, rateLimit, session, user, verification } from "./auth.js"; +export { + agentGrants, + agents, + apiKeys, + auditEvents, + auditRetentionHolds, + custodyWallets, + delayedExecutions, + intentGenerations, + operationArtifacts, + operations, + operationTombstones, + ownerIntents, + ownerNonces, + ownerReceipts, + quotaChanges, + requestBuckets, + spendCharges, + sponsorReservations, + tenants, + walletPolicies, +} from "./domain.js"; + +export const authSchema = { user, session, account, verification, rateLimit }; +export const schema = { + ...authSchema, + apiKeys, + agents, + agentGrants, + auditEvents, + auditRetentionHolds, + custodyWallets, + delayedExecutions, + operationArtifacts, + operations, + operationTombstones, + intentGenerations, + ownerIntents, + ownerNonces, + ownerReceipts, + requestBuckets, + quotaChanges, + sponsorReservations, + spendCharges, + tenants, + walletPolicies, +}; diff --git a/packages/database/src/testing.ts b/packages/database/src/testing.ts new file mode 100644 index 000000000..26d84bdc4 --- /dev/null +++ b/packages/database/src/testing.ts @@ -0,0 +1,30 @@ +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import type { DatabaseHandle, Db } from "./client.js"; +import { advisoryLockKey } from "./lock.js"; +import { schema } from "./schema/index.js"; + +export async function createTestDatabase(): Promise { + const client = new PGlite(); + const db = drizzle(client, { schema }); + await migrate(db, { + migrationsFolder: fileURLToPath(new URL("../drizzle", import.meta.url)), + }); + return { + db: db as unknown as Db, + close: () => client.close(), + // PGlite is a single connection, so lock acquisition and release cannot race here; the + // semantics still match Postgres closely enough for tests that assert serialization. + withAdvisoryLock: async (key, run) => { + const lockKey = advisoryLockKey(key); + await client.query("SELECT pg_advisory_lock($1)", [lockKey]); + try { + return await run(); + } finally { + await client.query("SELECT pg_advisory_unlock($1)", [lockKey]); + } + }, + }; +} diff --git a/packages/database/src/try-lock.ts b/packages/database/src/try-lock.ts new file mode 100644 index 000000000..c9fe0c575 --- /dev/null +++ b/packages/database/src/try-lock.ts @@ -0,0 +1,99 @@ +import type { Pool, PoolClient } from "pg"; +import { advisoryLockKey } from "./lock.js"; + +export type TryLockResult = { acquired: false } | { acquired: true; value: T }; +export type TryAdvisoryLock = ( + key: string, + deadline: number, + run: (assertHeld?: () => void) => Promise, +) => Promise>; + +/** Dedicated lock pool: holders never consume connections needed for business writes. */ +export function tryAdvisoryLockRunner(pool: Pool): TryAdvisoryLock { + return async (key, deadline, run) => { + const client = await connectBefore(pool, deadline); + if (!client) return { acquired: false }; + const lockKey = advisoryLockKey(key); + let locked = false; + let broken = false; + const onError = () => { + broken = true; + }; + const assertHeld = () => { + if (broken) throw new Error("sponsor_lock_lost"); + }; + client.on("error", onError); + try { + try { + if (Date.now() >= deadline) return { acquired: false }; + const query = { + text: "SELECT pg_try_advisory_lock($1) AS locked", + values: [lockKey], + query_timeout: Math.max(1, deadline - Date.now()), + }; + const result = await client.query<{ locked: boolean }>(query); + locked = result.rows[0]?.locked === true; + } catch { + // A timed-out query may still acquire the lock server-side. Destroy this session. + broken = true; + return { acquired: false }; + } + if (!locked || Date.now() >= deadline) return { acquired: false }; + assertHeld(); + const value = await run(assertHeld); + assertHeld(); + return { acquired: true, value }; + } finally { + if (locked && !broken) { + broken = !(await unlock(client, lockKey)); + } + client.removeListener("error", onError); + client.release(broken); + } + }; +} + +async function connectBefore(pool: Pool, deadline: number): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) return undefined; + let expired = false; + let timer: ReturnType | undefined; + const connection = pool.connect().then((client) => { + if (expired) { + client.release(); + return undefined; + } + return client; + }); + let client: PoolClient | undefined; + try { + client = await Promise.race([ + connection, + new Promise((resolve) => { + timer = setTimeout(() => { + expired = true; + resolve(undefined); + }, remaining); + }), + ]); + } catch { + return undefined; + } finally { + clearTimeout(timer); + } + return client; +} + +async function unlock(client: PoolClient, lockKey: bigint): Promise { + try { + const query = { + text: "SELECT pg_advisory_unlock($1)", + values: [lockKey], + query_timeout: 1_000, + }; + await client.query(query); + return true; + } catch { + return false; + } +} diff --git a/packages/database/tests/surface.test.ts b/packages/database/tests/surface.test.ts new file mode 100644 index 000000000..379225a83 --- /dev/null +++ b/packages/database/tests/surface.test.ts @@ -0,0 +1,41 @@ +import { advisoryLockKey, createDatabase } from "@near-intents-agent-api/database"; +import { agents, apiKeys, tenants, user } from "@near-intents-agent-api/database/schema"; +import { createTestDatabase } from "@near-intents-agent-api/database/testing"; +import { describe, expect, it } from "vitest"; + +describe("vendored database surface", () => { + it("derives a stable advisory-lock key per name", () => { + expect(advisoryLockKey("agents/x")).toBe(advisoryLockKey("agents/x")); + expect(advisoryLockKey("agents/x")).not.toBe(advisoryLockKey("agents/y")); + }); + + it("boots an in-memory database with migrations applied and writes a tenant row", async () => { + const handle = await createTestDatabase(); + try { + await handle.db + .insert(user) + .values({ id: "u1", name: "Test User", email: "u@citynode.app" }) + .returning(); + await handle.db.insert(tenants).values({ id: "t1", ownerUserId: "u1" }).returning(); + const inserted = await handle.db + .insert(apiKeys) + .values({ + id: "k1", + tenantId: "t1", + name: "test key", + tokenHash: "0".repeat(64), + prefix: "naa_test", + expiresAt: new Date(), + }) + .returning(); + expect(inserted[0]?.id).toBe("k1"); + expect(agents).toBeDefined(); + } finally { + handle.close(); + } + }); + + it("createDatabase is the production entry point", () => { + expect(typeof createDatabase).toBe("function"); + }); +}); diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json new file mode 100644 index 000000000..217d8f80b --- /dev/null +++ b/packages/database/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts", "drizzle.config.ts"] +} diff --git a/packages/everything-dev/src/config.ts b/packages/everything-dev/src/config.ts index f8401e31a..d0f1e95a9 100644 --- a/packages/everything-dev/src/config.ts +++ b/packages/everything-dev/src/config.ts @@ -813,7 +813,15 @@ export async function resolveComposableReference( entry: stripUnsafeLocalDevelopment(resolvedEntry, allowLocalPaths || Boolean(localDevelopment)), providerBaseDir, targetPath, - associatedUi: stripUnsafeLocalDevelopment(associatedUi, allowLocalPaths), + // The associated ui comes from the same authored source as the entry — + // when the entry itself resolves locally, its ui's local development is + // equally trusted. Without this, a local plugin's folder-form ui is + // stripped (no plugin ships a nested bos.config.json, so allowLocalPaths + // stays false) and the dev harness never wires BOS_UI_PORT for it. + associatedUi: stripUnsafeLocalDevelopment( + associatedUi, + allowLocalPaths || Boolean(localDevelopment), + ), }; } diff --git a/packages/outlayer/package.json b/packages/outlayer/package.json new file mode 100644 index 000000000..0ee99578d --- /dev/null +++ b/packages/outlayer/package.json @@ -0,0 +1,22 @@ +{ + "name": "@near-intents-agent-api/outlayer", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/outlayer/src/client.ts b/packages/outlayer/src/client.ts new file mode 100644 index 000000000..b5530e2d1 --- /dev/null +++ b/packages/outlayer/src/client.ts @@ -0,0 +1,23 @@ +import { createCrossChainOperations } from "./cross-chain.js"; +import { createFundOperations } from "./funds.js"; +import { createQueryOperations } from "./query.js"; +import { createSigningOperations } from "./signing.js"; +import { createOutlayerTransport } from "./transport.js"; +import type { OutlayerClientOptions } from "./types.js"; +import { outlayerNetworks } from "./types.js"; +import { createWalletOperations } from "./wallet.js"; + +export function createOutlayerClient(options: OutlayerClientOptions = {}) { + const request = createOutlayerTransport(options); + return { + network: "mainnet" as const, + contractId: outlayerNetworks.mainnet.contractId, + ...createWalletOperations(request), + ...createSigningOperations(request), + ...createFundOperations(request), + ...createCrossChainOperations(request), + ...createQueryOperations(request), + }; +} + +export type OutlayerWalletClient = ReturnType; diff --git a/packages/outlayer/src/cross-chain.ts b/packages/outlayer/src/cross-chain.ts new file mode 100644 index 000000000..2048c213b --- /dev/null +++ b/packages/outlayer/src/cross-chain.ts @@ -0,0 +1,46 @@ +import type { ProviderRequest } from "./types.js"; + +type CrossChainInput = { + sourceAsset?: string; + destinationAsset?: string; + amount: string; + chain?: string; + token?: string; + refundAddress?: string; +}; + +const crossChainBody = (input: CrossChainInput) => ({ + source_asset: input.sourceAsset, + destination_asset: input.destinationAsset, + amount: input.amount, + chain: input.chain, + token: input.token, + refund_address: input.refundAddress, +}); + +/** + * Cross-chain deposit routes do not deduplicate on `X-Idempotency-Key`, so they send none: the + * transport retries only keyed mutations, and each of these creates a new deposit intent per POST. + */ +export function createCrossChainOperations(request: ProviderRequest) { + const intents = (apiKey: string, input: CrossChainInput) => + request({ + apiKey, + body: crossChainBody(input), + method: "POST", + path: "/wallet/v1/intents/deposit/cross-chain", + }); + const confidential = (apiKey: string, input: Omit) => + request({ + apiKey, + body: crossChainBody(input), + method: "POST", + path: "/wallet/v1/confidential/deposit/cross-chain", + }); + return { + intentsDepositCrossChain: intents, + confidentialDepositCrossChain: confidential, + intentsDepositIntent: intents, + confidentialDepositIntent: confidential, + }; +} diff --git a/packages/outlayer/src/errors.ts b/packages/outlayer/src/errors.ts new file mode 100644 index 000000000..0877644a0 --- /dev/null +++ b/packages/outlayer/src/errors.ts @@ -0,0 +1,40 @@ +/** Stable provider error consumed by the application error mapper. */ +export const providerRefusalCodes = new Set([ + // The route's minimum: 1Click refuses to bridge less (confidential swaps and deposits). + "amount_too_low", + // 1Click will not quote this asset pair right now; nothing was admitted. + "route_unavailable", + "custody_native_balance_required", + "invalid_address", + "insufficient_balance", + "invalid_request", + "policy_denied", + "policy_not_found", + "request_failed", + "sign_recipient_denied", + "unsupported_chain", + "unsupported_token", + // One money operation per custody wallet: an overlapping request is refused before admission. + "wallet_busy", + "wallet_frozen", +]); + +export class OutlayerError extends Error { + constructor( + public code: string, + public status?: number, + /** + * True when the transport had already sent this request once before the answer this error + * reports, so an earlier attempt may still be running at the provider. + */ + public resent = false, + /** + * The provider's own `error` code when it is not one of the stable codes above, for operator + * logs only. Kept only when it is a bare identifier, so no message or reflected value leaks. + */ + public providerCode?: string, + ) { + super(code); + this.name = "OutlayerError"; + } +} diff --git a/packages/outlayer/src/funds.ts b/packages/outlayer/src/funds.ts new file mode 100644 index 000000000..e089aa6cf --- /dev/null +++ b/packages/outlayer/src/funds.ts @@ -0,0 +1,142 @@ +import type { ProviderRequest } from "./types.js"; + +export function createFundOperations(request: ProviderRequest) { + return { + intentsSwap( + apiKey: string, + input: { amountIn: string; minAmountOut?: string; tokenIn: string; tokenOut: string }, + idempotencyKey?: string, + ) { + return request({ + apiKey, + idempotencyKey, + body: { + amount_in: input.amountIn, + min_amount_out: input.minAmountOut, + token_in: input.tokenIn, + token_out: input.tokenOut, + }, + method: "POST", + path: "/wallet/v1/intents/swap", + }); + }, + + intentsWithdraw( + apiKey: string, + input: { + amount: string; + chain: string; + memo?: string; + token: string; + to: string; + async?: boolean; + }, + idempotencyKey?: string, + ) { + return request({ + apiKey, + idempotencyKey, + body: input, + method: "POST", + path: "/wallet/v1/intents/withdraw", + }); + }, + + confidentialSwap( + apiKey: string, + input: { amountIn: string; minAmountOut?: string; tokenIn: string; tokenOut: string }, + idempotencyKey?: string, + ) { + return request({ + apiKey, + idempotencyKey, + body: { + amount_in: input.amountIn, + min_amount_out: input.minAmountOut, + token_in: input.tokenIn, + token_out: input.tokenOut, + }, + method: "POST", + path: "/wallet/v1/confidential/swap", + }); + }, + + confidentialWithdraw( + apiKey: string, + input: { + amount: string; + chain: string; + memo?: string; + token: string; + to: string; + async?: boolean; + }, + idempotencyKey?: string, + ) { + return request({ + apiKey, + idempotencyKey, + body: input, + method: "POST", + path: "/wallet/v1/confidential/withdraw", + }); + }, + + confidentialShield( + apiKey: string, + input: { amount: string; token: string }, + idempotencyKey?: string, + ) { + return request({ + apiKey, + idempotencyKey, + body: input, + method: "POST", + path: "/wallet/v1/confidential/shield", + }); + }, + + confidentialUnshield( + apiKey: string, + input: { amount: string; token: string }, + idempotencyKey?: string, + ) { + return request({ + apiKey, + idempotencyKey, + body: input, + method: "POST", + path: "/wallet/v1/confidential/unshield", + }); + }, + + deleteWallet( + apiKey: string, + input: { beneficiary: string; chain?: "near" }, + idempotencyKey?: string, + ) { + return request({ + apiKey, + idempotencyKey, + body: { beneficiary: input.beneficiary, chain: input.chain ?? "near" }, + method: "POST", + path: "/wallet/v1/delete", + }); + }, + + walletExecution( + kind: "intents/transfer" | "confidential/transfer" | "confidential/deposit", + apiKey: string, + body: unknown, + idempotencyKey: string, + ) { + return request({ + apiKey, + body, + idempotencyKey, + method: "POST", + path: `/wallet/v1/${kind}`, + }); + }, + }; +} diff --git a/packages/outlayer/src/index.ts b/packages/outlayer/src/index.ts new file mode 100644 index 000000000..95359a267 --- /dev/null +++ b/packages/outlayer/src/index.ts @@ -0,0 +1,41 @@ +export type { OutlayerWalletClient } from "./client.js"; +export { createOutlayerClient } from "./client.js"; +export { OutlayerError, providerRefusalCodes } from "./errors.js"; +export type { + OutlayerAddressResult, + OutlayerBalanceResult, + OutlayerChain, + OutlayerConfidentialBalanceEntry, + OutlayerConfidentialBalancesResult, + OutlayerEncryptPolicyResult, + OutlayerProviderResponse, + OutlayerRegisterResult, + OutlayerSignatureResponse, + OutlayerSignPolicyResult, + OutlayerToken, + OutlayerTokensResult, +} from "./schemas.js"; +export { + addressResponseSchema, + balanceResponseSchema, + chainSchema, + confidentialBalanceEntrySchema, + confidentialBalancesResponseSchema, + encryptPolicyResponseSchema, + providerResponseSchema, + registerResponseSchema, + signatureResponseSchema, + signPolicyResponseSchema, + tokenSchema, + tokensResponseSchema, +} from "./schemas.js"; +export { createOutlayerTransport } from "./transport.js"; +export type { + OutlayerClientOptions, + OutlayerNetwork, + OutlayerPolicyDocument, + OutlayerPolicyRules, + ProviderRequest, + WalletRequest, +} from "./types.js"; +export { outlayerNetworks } from "./types.js"; diff --git a/packages/outlayer/src/query.ts b/packages/outlayer/src/query.ts new file mode 100644 index 000000000..b39bc7159 --- /dev/null +++ b/packages/outlayer/src/query.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import { + balanceResponseSchema, + confidentialBalancesResponseSchema, + providerResponseSchema, + tokensResponseSchema, +} from "./schemas.js"; +import type { ProviderRequest } from "./types.js"; + +export function createQueryOperations(request: ProviderRequest) { + return { + depositIntentStatus(apiKey: string, id: string) { + return request({ + apiKey, + method: "GET", + path: "/wallet/v1/intents/deposit/cross-chain/status", + query: { id }, + }).then((value) => providerResponseSchema.parse(value)); + }, + + depositHistory(apiKey: string, input: { limit?: number; offset?: number } = {}) { + return request({ + apiKey, + method: "GET", + path: "/wallet/v1/intents/deposit/cross-chain/list", + query: input, + }).then((value) => z.union([providerResponseSchema, z.array(z.unknown())]).parse(value)); + }, + + /** Single confidential asset when `token` is given, the full list otherwise. */ + async confidentialBalance(apiKey: string, token?: string) { + const value = await request({ + apiKey, + method: "GET", + path: "/wallet/v1/confidential/balance", + query: { token }, + }); + if (token) return { single: true as const, value: balanceResponseSchema.parse(value) }; + return { single: false as const, value: confidentialBalancesResponseSchema.parse(value) }; + }, + + pendingApprovals(apiKey: string) { + return request({ apiKey, method: "GET", path: "/wallet/v1/pending_approvals" }); + }, + + approval(apiKey: string, id: string) { + return request({ + apiKey, + method: "GET", + path: `/wallet/v1/approval/${encodeURIComponent(id)}`, + }); + }, + + vote( + apiKey: string, + id: string, + verdict: "approve" | "reject", + proof: { account_id: string; public_key: string; signature: string; nonce: string }, + ) { + return request({ + apiKey, + body: proof, + method: "POST", + path: `/wallet/v1/${verdict}/${encodeURIComponent(id)}`, + }); + }, + + /** + * Supported Intents token catalog: the assets the provider can route swaps and cross-chain + * withdraws through. Typed rather than passed through, because callers index it by token id + * and read symbol/decimals for display. + */ + async tokens(apiKey: string) { + return tokensResponseSchema.parse( + await request({ apiKey, method: "GET", path: "/wallet/v1/tokens" }), + ); + }, + + walletRead( + query: { + path: + | "address" + | "confidential/balance" + | "pending_approvals" + | "requests" + | "audit" + | "intents/deposit/cross-chain/list" + | "intents/deposit/cross-chain/status"; + params?: Record; + }, + apiKey: string, + ) { + return request({ + apiKey, + method: "GET", + path: `/wallet/v1/${query.path}`, + query: query.params, + }).then((value) => z.union([providerResponseSchema, z.array(z.unknown())]).parse(value)); + }, + + walletQuote( + kind: + | "intents/swap/quote" + | "confidential/swap/quote" + | "intents/withdraw/dry-run" + | "confidential/withdraw/dry-run", + apiKey: string, + body: unknown, + ) { + return request({ apiKey, method: "POST", path: `/wallet/v1/${kind}`, body }).then((value) => + providerResponseSchema.parse(value), + ); + }, + + requestStatus(apiKey: string, requestId: string) { + return request({ + apiKey, + method: "GET", + path: `/wallet/v1/requests/${encodeURIComponent(requestId)}`, + }); + }, + }; +} diff --git a/packages/outlayer/src/schemas.ts b/packages/outlayer/src/schemas.ts new file mode 100644 index 000000000..3192b441d --- /dev/null +++ b/packages/outlayer/src/schemas.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; + +const amountSchema = z.string().regex(/^[0-9]{1,78}$/); + +export const registerResponseSchema = z + .object({ + api_key: z.string().min(1), + handoff_url: z.string().min(1).optional(), + near_account_id: z.string().regex(/^[0-9a-f]{64}$/), + wallet_id: z.string().min(1).max(256), + }) + .passthrough(); + +export const addressResponseSchema = z + .object({ + address: z.string().min(1).max(256), + chain: z.string().min(1).max(64).optional(), + }) + .passthrough(); + +export const balanceResponseSchema = z + .object({ + account_id: z.string().min(2).max(64), + balance: amountSchema, + token: z.string().min(1).max(128).nullable().optional(), + source: z.enum(["chain", "intents"]).optional(), + }) + .passthrough(); + +export const tokenSchema = z + .object({ + id: z.string().min(1).max(256), + symbol: z.string().min(1).max(64), + chains: z.array(z.string().min(1).max(64)).max(32), + decimals: z.number().int().nonnegative().max(36), + defuse_asset_id: z.string().min(1).max(256).optional(), + }) + .passthrough(); + +export const tokensResponseSchema = z + .object({ tokens: z.array(tokenSchema).max(4096) }) + .passthrough(); + +/** One entry of the provider's confidential balance list (`?token` omitted). */ +export const confidentialBalanceEntrySchema = z + .object({ + token: z.string().min(1).max(256), + balance: amountSchema, + decimals: z.number().int().nonnegative().max(36).optional(), + symbol: z.string().min(1).max(64).optional(), + }) + .passthrough(); + +export const confidentialBalancesResponseSchema = z + .object({ + account_id: z.string().min(2).max(64), + balances: z.array(confidentialBalanceEntrySchema).max(4096), + }) + .passthrough(); + +export const encryptPolicyResponseSchema = z + .object({ encrypted_base64: z.string().min(1).max(262_144) }) + .passthrough(); + +export const signPolicyResponseSchema = z + .object({ + public_key_hex: z.string().regex(/^[0-9a-f]{64}$/), + signature_hex: z.string().regex(/^[0-9a-f]{128}$/), + }) + .passthrough(); + +export const providerResponseSchema = z.object({}).catchall(z.unknown()); + +export const signatureResponseSchema = z + .object({ + address: z.string().min(1).max(256).optional(), + public_key: z.string().min(1).max(128).optional(), + signature: z.string().min(1).max(512).optional(), + }) + .catchall(z.unknown()); + +export const chainSchema = z.enum([ + "near", + "ethereum", + "base", + "arbitrum", + "bitcoin", + "bsc", + "polygon", + "optimism", + "avalanche", +]); + +export type OutlayerRegisterResult = z.infer; +export type OutlayerAddressResult = z.infer; +export type OutlayerBalanceResult = z.infer; +export type OutlayerToken = z.infer; +export type OutlayerTokensResult = z.infer; +export type OutlayerConfidentialBalanceEntry = z.infer; +export type OutlayerConfidentialBalancesResult = z.infer; +export type OutlayerEncryptPolicyResult = z.infer; +export type OutlayerSignPolicyResult = z.infer; +export type OutlayerProviderResponse = z.infer; +export type OutlayerSignatureResponse = z.infer; +export type OutlayerChain = z.infer; diff --git a/packages/outlayer/src/signing.ts b/packages/outlayer/src/signing.ts new file mode 100644 index 000000000..36d9623cc --- /dev/null +++ b/packages/outlayer/src/signing.ts @@ -0,0 +1,63 @@ +import { providerResponseSchema, signatureResponseSchema } from "./schemas.js"; +import type { OutlayerChain, ProviderRequest } from "./types.js"; + +export function createSigningOperations(request: ProviderRequest) { + return { + async signNearMessage( + apiKey: string, + input: { message: string; recipient: string; nonce?: string }, + ) { + return signatureResponseSchema.parse( + await request({ + apiKey, + body: { message: input.message, nonce: input.nonce, recipient: input.recipient }, + method: "POST", + path: "/wallet/v1/sign-message", + }), + ); + }, + + async signEvmMessage( + apiKey: string, + input: { message: string; encoding?: "utf8" | "hex"; chain: OutlayerChain }, + ) { + return signatureResponseSchema.parse( + await request({ + apiKey, + body: { + chain: input.chain ?? "ethereum", + encoding: input.encoding, + message: input.message, + }, + method: "POST", + path: "/wallet/v1/evm/sign-message", + }), + ); + }, + + async signEvmTypedData( + apiKey: string, + input: { chain: OutlayerChain; typedData: Record }, + ) { + return providerResponseSchema.parse( + await request({ + apiKey, + body: { chain: input.chain, typed_data: input.typedData }, + method: "POST", + path: "/wallet/v1/evm/sign-typed-data", + }), + ); + }, + + async signEvmTransaction(apiKey: string, input: { chain: OutlayerChain; unsignedTx: string }) { + return providerResponseSchema.parse( + await request({ + apiKey, + body: { chain: input.chain, unsigned_tx: input.unsignedTx }, + method: "POST", + path: "/wallet/v1/evm/sign-transaction", + }), + ); + }, + }; +} diff --git a/packages/outlayer/src/transport.ts b/packages/outlayer/src/transport.ts new file mode 100644 index 000000000..275349be1 --- /dev/null +++ b/packages/outlayer/src/transport.ts @@ -0,0 +1,224 @@ +import { OutlayerError, providerRefusalCodes } from "./errors.js"; +import type { OutlayerClientOptions, ProviderRequest, WalletRequest } from "./types.js"; +import { outlayerNetworks } from "./types.js"; + +const maxResponseBytes = 1_048_576; + +function queryString(params: Record = {}): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) query.set(key, String(value)); + } + const text = query.toString(); + return text ? `?${text}` : ""; +} + +async function readBody(response: Response, parse: (value: unknown) => T): Promise { + if (!response.body) throw new OutlayerError("invalid_outlayer_response"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > maxResponseBytes) throw new OutlayerError("outlayer_response_too_large"); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + const text = Buffer.concat(chunks).toString("utf8"); + if (!text) return parse({}); + try { + return parse(JSON.parse(text)); + } catch (error) { + if (error instanceof OutlayerError) throw error; + throw new OutlayerError("invalid_outlayer_response"); + } +} + +/** + * OutLayer's own code, sharpened where its 400 says more: `bad_request` is a validation refusal + * decided before anything executes, and a bridge minimum or a pair 1Click will not quote is worth + * its own code. Only these fixed provider phrases are matched; the message itself never leaves. + */ +function refusalCode(body: unknown) { + const record = body && typeof body === "object" ? (body as Record) : {}; + const code = record.error; + if (code !== "bad_request") return code; + const message = typeof record.message === "string" ? record.message : ""; + if (/amount is too low/i.test(message)) return "amount_too_low"; + if (/quoting for this pair is not available/i.test(message)) return "route_unavailable"; + return "invalid_request"; +} + +async function responseError(response: Response, resent: boolean) { + // 402 is OutLayer's `WalletUnderfunded`: the custody account cannot prepay an on-chain call's + // gas, and the request never reached the chain. + if (response.status === 402) + return new OutlayerError("custody_native_balance_required", response.status, resent); + const body = await readBody(response, (value) => value).catch(() => undefined); + const code = refusalCode(body); + // Only expose stable allowlisted codes, never provider messages or reflected credentials. + if (typeof code === "string" && providerRefusalCodes.has(code)) + return new OutlayerError(code, response.status, resent); + const identifier = typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code) ? code : undefined; + return new OutlayerError("outlayer_request_failed", response.status, resent, identifier); +} + +/** Fixed-origin provider transport. Credentials never come from request bodies. */ +export function createOutlayerTransport(options: OutlayerClientOptions): ProviderRequest { + const url = outlayerNetworks.mainnet.baseUrl; + const fetcher = options.fetch ?? fetch; + const timeoutMs = options.timeoutMs ?? 30_000; + + const send = (input: WalletRequest) => + fetcher(`${url}${input.path}${queryString(input.query)}`, { + method: input.method, + headers: { + accept: "application/json", + // OutLayer deduplicates on `X-Idempotency-Key`; a plain `Idempotency-Key` alone is + // ignored, and a resubmission then executes a second time. Both names are sent. + ...(input.idempotencyKey + ? { "X-Idempotency-Key": input.idempotencyKey, "Idempotency-Key": input.idempotencyKey } + : {}), + ...(input.apiKey ? { authorization: `Bearer ${input.apiKey}` } : {}), + ...(input.body === undefined ? {} : { "content-type": "application/json" }), + }, + body: input.body === undefined ? undefined : JSON.stringify(input.body), + redirect: "error", + credentials: "omit", + signal: AbortSignal.timeout(timeoutMs), + }); + + const delayFor = options.retryDelayMs ?? ((attempt: number) => 2_000 * (attempt + 1)); + const sleep = (attempt: number) => new Promise((done) => setTimeout(done, delayFor(attempt))); + + const breaker = createBreaker(options.breakerOptions); + + // Provider rate limiting and transport blips are transient. Retries are limited to calls that + // cannot execute twice: GETs, and POSTs carrying an Idempotency-Key. Only routes OutLayer + // deduplicates send a key (see cross-chain.ts), so a key here is the route's dedup guarantee. + const retryable = (input: WalletRequest) => + input.method === "GET" || Boolean(input.idempotencyKey); + + async function attemptRequest( + input: WalletRequest, + attempt: number, + ): Promise<{ response: Response } | { retry: true } | { error: OutlayerError }> { + let response: Response; + try { + response = await send(input); + } catch (error) { + if (error instanceof OutlayerError) return { error }; + if (!retryable(input) || attempt >= 2) + return { error: new OutlayerError("outlayer_unavailable") }; + await sleep(attempt); + return { retry: true }; + } + // 429 and 503 are the provider telling us to slow down; both are safe to retry. + if ((response.status === 429 || response.status === 503) && retryable(input) && attempt < 2) { + await sleep(attempt); + return { retry: true }; + } + return { response }; + } + + return async function request(input: WalletRequest): Promise { + if (breaker.open()) throw new OutlayerError("outlayer_unavailable", 503); + let lastError = new OutlayerError("outlayer_unavailable"); + for (let attempt = 0; attempt < 3; attempt++) { + const outcome = await attemptRequest(input, attempt); + if ("retry" in outcome) continue; + if ("error" in outcome) { + lastError = outcome.error; + breaker.recordFailure(); + break; + } + if (!outcome.response.ok) throw await failedResponse(outcome.response, breaker, attempt > 0); + breaker.recordSuccess(); + return successfulBody(input, outcome.response, request); + } + throw lastError; + }; +} + +async function successfulBody(input: WalletRequest, response: Response, request: ProviderRequest) { + const body = await readBody(response, (value) => value); + return input.idempotencyKey ? originalOfDuplicate(input, body, request) : body; +} + +/** + * A resubmitted `X-Idempotency-Key` does not re-execute and does not echo the first result: OutLayer + * answers 200 with `{ error: "duplicate_idempotency_key", message: "Request already processed: + * " }`. The original request is read back, so a retry or recovery observes the first + * execution instead of a body with no status. An unreadable id stays unknown, never a success. + */ +async function originalOfDuplicate( + input: WalletRequest, + body: unknown, + request: ProviderRequest, +): Promise { + const record = body && typeof body === "object" ? (body as Record) : null; + if (record?.error !== "duplicate_idempotency_key") return body; + const message = typeof record.message === "string" ? record.message : ""; + const requestId = /Request already processed:\s*([A-Za-z0-9-]{8,128})\b/.exec(message)?.[1]; + if (!requestId) throw new OutlayerError("duplicate_idempotency_key"); + return request({ + apiKey: input.apiKey, + method: "GET", + path: `/wallet/v1/requests/${encodeURIComponent(requestId)}`, + }); +} + +type Breaker = { + open(): boolean; + recordFailure(): void; + recordSuccess(): void; +}; + +/** Transport statuses mean the provider is unhealthy; a refusal means it answered. */ +async function failedResponse( + response: Response, + breaker: Breaker, + resent: boolean, +): Promise { + if (response.status >= 500 || response.status === 429) breaker.recordFailure(); + else breaker.recordSuccess(); + return responseError(response, resent); +} + +type BreakerState = { failures: number; openedAt: number | null }; + +/** + * Minimal circuit breaker for one client instance. After `failureThreshold` consecutive + * transport failures the client fails fast for `cooldownMs` instead of making every caller wait + * out its own timeout. One trial request is allowed through after the cooldown (half-open): + * success closes the breaker, failure reopens it. + */ +function createBreaker(options: OutlayerClientOptions["breakerOptions"]) { + const threshold = options?.failureThreshold ?? 5; + const cooldownMs = options?.cooldownMs ?? 10_000; + const state: BreakerState = { failures: 0, openedAt: null }; + return { + open(): boolean { + if (state.openedAt === null) return false; + if (Date.now() - state.openedAt >= cooldownMs) { + state.openedAt = null; + state.failures = 0; + return false; + } + return true; + }, + recordFailure() { + state.failures += 1; + if (state.failures >= threshold) state.openedAt = Date.now(); + }, + recordSuccess() { + state.failures = 0; + state.openedAt = null; + }, + }; +} diff --git a/packages/outlayer/src/types.ts b/packages/outlayer/src/types.ts new file mode 100644 index 000000000..3a8e954bd --- /dev/null +++ b/packages/outlayer/src/types.ts @@ -0,0 +1,50 @@ +import type { OutlayerChain } from "./schemas.js"; + +export const outlayerNetworks = { + mainnet: { + baseUrl: "https://api.outlayer.fastnear.com", + contractId: "outlayer.near", + }, +} as const; + +export type OutlayerNetwork = keyof typeof outlayerNetworks; + +export type OutlayerPolicyRules = { + allowed_tokens: string[]; + transaction_types: string[]; + limits?: Record>; + rate_limit?: { max_per_hour: number }; + addresses?: { mode: "whitelist" | "blacklist"; list: string[] }; + items?: Record; +}; + +export type OutlayerPolicyDocument = { + version: 1; + capabilities: Record; + frozen: boolean; + rules: OutlayerPolicyRules; + approval?: Record; +}; + +export type OutlayerClientOptions = { + fetch?: typeof fetch; + /** Bounded provider timeout in milliseconds. */ + timeoutMs?: number; + /** Retry backoff for rate limiting and transport blips. Tests override this. */ + retryDelayMs?: (attempt: number) => number; + /** Circuit-breaker tuning; defaults are 5 consecutive failures and a 10s cooldown. */ + breakerOptions?: { failureThreshold?: number; cooldownMs?: number }; +}; + +export type WalletRequest = { + apiKey: string; + idempotencyKey?: string; + body?: unknown; + method: "GET" | "POST"; + path: string; + query?: Record; +}; + +export type ProviderRequest = (input: WalletRequest) => Promise; + +export type { OutlayerChain }; diff --git a/packages/outlayer/src/wallet.ts b/packages/outlayer/src/wallet.ts new file mode 100644 index 000000000..b7f9f7338 --- /dev/null +++ b/packages/outlayer/src/wallet.ts @@ -0,0 +1,99 @@ +import type { + OutlayerAddressResult, + OutlayerBalanceResult, + OutlayerEncryptPolicyResult, + OutlayerRegisterResult, + OutlayerSignPolicyResult, +} from "./schemas.js"; +import { + addressResponseSchema, + balanceResponseSchema, + encryptPolicyResponseSchema, + providerResponseSchema, + registerResponseSchema, + signPolicyResponseSchema, +} from "./schemas.js"; +import type { OutlayerPolicyDocument, ProviderRequest } from "./types.js"; + +export function createWalletOperations(request: ProviderRequest) { + return { + async registerWallet(): Promise { + return registerResponseSchema.parse( + await request({ apiKey: "", body: {}, method: "POST", path: "/register" }), + ); + }, + + async address(apiKey: string, chain = "near"): Promise { + return addressResponseSchema.parse( + await request({ + apiKey, + method: "GET", + path: "/wallet/v1/address", + query: { chain }, + }), + ); + }, + + async balance( + apiKey: string, + params: { chain?: string; source?: "chain" | "intents"; token?: string } = {}, + ): Promise { + return balanceResponseSchema.parse( + await request({ + apiKey, + method: "GET", + path: "/wallet/v1/balance", + query: { + chain: params.chain ?? "near", + source: params.source ?? "chain", + token: params.token, + }, + }), + ); + }, + + async policy(apiKey: string) { + return providerResponseSchema.parse( + await request({ apiKey, method: "GET", path: "/wallet/v1/policy" }), + ); + }, + + async encryptPolicy( + apiKey: string, + policy: OutlayerPolicyDocument, + walletId: string, + ): Promise { + return encryptPolicyResponseSchema.parse( + await request({ + apiKey, + body: { ...policy, wallet_id: walletId }, + method: "POST", + path: "/wallet/v1/encrypt-policy", + }), + ); + }, + + async signPolicy( + apiKey: string, + input: { caller: string; encryptedData: string }, + ): Promise { + return signPolicyResponseSchema.parse( + await request({ + apiKey, + body: { caller: input.caller, encrypted_data: input.encryptedData }, + method: "POST", + path: "/wallet/v1/sign-policy", + }), + ); + }, + + invalidatePolicyCache(apiKey: string, input: { walletId?: string } = {}) { + return request({ + apiKey, + body: { wallet_id: input.walletId }, + method: "POST", + path: "/wallet/v1/invalidate-cache", + }); + }, + }; +} diff --git a/packages/outlayer/tests/surface.test.ts b/packages/outlayer/tests/surface.test.ts new file mode 100644 index 000000000..73548d8ee --- /dev/null +++ b/packages/outlayer/tests/surface.test.ts @@ -0,0 +1,32 @@ +import { + createOutlayerClient, + OutlayerError, + providerRefusalCodes, +} from "@near-intents-agent-api/outlayer"; +import { describe, expect, it } from "vitest"; + +describe("vendored outlayer surface", () => { + it("creates a client exposing wallet, signing, funds, cross-chain and query operations", () => { + const client = createOutlayerClient() as unknown as Record; + expect(client.network).toBe("mainnet"); + expect(client.contractId).toBeTruthy(); + expect(typeof client.registerWallet).toBe("function"); + expect(typeof client.address).toBe("function"); + expect(typeof client.depositIntentStatus).toBe("function"); + }); + + it("exposes the provider refusal code vocabulary including the serialization rules", () => { + expect(providerRefusalCodes).toBeInstanceOf(Set); + expect(providerRefusalCodes.has("wallet_busy")).toBe(true); + expect(providerRefusalCodes.has("route_unavailable")).toBe(true); + expect(providerRefusalCodes.has("made_up_code")).toBe(false); + }); + + it("OutlayerError carries a code, status and resent flag", () => { + const error = new OutlayerError("wallet_busy", 409); + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe("wallet_busy"); + expect(error.status).toBe(409); + expect(error.resent).toBe(false); + }); +}); diff --git a/packages/outlayer/tsconfig.json b/packages/outlayer/tsconfig.json new file mode 100644 index 000000000..c1d8fd273 --- /dev/null +++ b/packages/outlayer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/owner-auth/package.json b/packages/owner-auth/package.json new file mode 100644 index 000000000..f2164488a --- /dev/null +++ b/packages/owner-auth/package.json @@ -0,0 +1,26 @@ +{ + "name": "@near-intents-agent-api/owner-auth", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@near-intents-agent-api/contracts": "workspace:*", + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "catalog:", + "@simplewebauthn/server": "14.0.2" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/owner-auth/src/evm-authorization.ts b/packages/owner-auth/src/evm-authorization.ts new file mode 100644 index 000000000..e7bddd7aa --- /dev/null +++ b/packages/owner-auth/src/evm-authorization.ts @@ -0,0 +1,130 @@ +import { + type EvmWalletRequestMessage, + evmAuthorizationTypedData, + evmWalletTypedData, +} from "@near-intents-agent-api/contracts"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { base58 } from "@scure/base"; + +type TypedData = + | ReturnType + | ReturnType; +const hash = (value: string) => keccak_256(new TextEncoder().encode(value)); + +/** Trezu wallet-contract EIP-712 digest, limited to its two exact message types. */ +function typedDigest(data: TypedData): Uint8Array { + const domain = keccak_256( + Buffer.concat([ + hash("EIP712Domain(string name,string version)"), + hash(data.domain.name), + hash(data.domain.version), + ]), + ); + const fields: Array<{ name: string; type: string }> = + data.primaryType === "Authorization" + ? evmAuthorizationTypedData({ purpose: "", recipient: "", payload: "" }).types.Authorization + : evmWalletTypedData({ + chain_id: "mainnet", + signer_id: "x.near", + nonce: 0, + created_at: "2026-01-01T00:00:00Z", + timeout_secs: 1, + request: { + ops: [], + out: { + after: [], + // biome-ignore lint/suspicious/noThenProperty: Required wallet-contract request field. + then: [], + }, + }, + }).types.WalletMessage; + const typeName = data.primaryType; + const typeHash = hash( + `${typeName}(${fields.map((field) => `${field.type} ${field.name}`).join(",")})`, + ); + const values = data.message as unknown as Record; + const words = fields.map((field) => { + const value = values[field.name]; + if (field.type === "string" && typeof value === "string") return hash(value); + if ( + field.type === "uint32" && + typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + value <= 0xffffffff + ) { + const word = Buffer.alloc(32); + word.writeUInt32BE(value, 28); + return word; + } + throw new Error("evm_typed_data_invalid"); + }); + const struct = keccak_256(Buffer.concat([typeHash, ...words])); + return keccak_256(Buffer.concat([Buffer.from([0x19, 0x01]), domain, struct])); +} + +function recover(digest: Uint8Array, signature: Uint8Array): string { + if (signature.length !== 65 || signature[64] === undefined || signature[64] > 1) + throw new Error("evm_wallet_signature_invalid"); + const recovered = secp256k1.Signature.fromBytes( + Buffer.concat([Buffer.from([signature[64]]), signature.subarray(0, 64)]), + "recovered", + ); + if (recovered.hasHighS()) throw new Error("evm_wallet_signature_malleable"); + return `0x${Buffer.from(recovered.recoverPublicKey(digest).toBytes(false).subarray(1)).toString("hex")}`; +} + +function signatureBytes(encoded: string): Uint8Array { + if (!encoded.startsWith("secp256k1:")) throw new Error("evm_wallet_signature_invalid"); + return base58.decode(encoded.slice("secp256k1:".length)); +} + +export function verifyEvmWalletAuthorization(input: { + publicKey: string; + authorization: string; + recipient: string; + payload: string; +}) { + const auth = JSON.parse(input.authorization) as { + purpose?: unknown; + recipient?: unknown; + payload?: unknown; + signature?: unknown; + }; + if ( + auth.purpose !== "PROVE_OWNERSHIP" || + auth.recipient !== input.recipient || + auth.payload !== input.payload || + typeof auth.signature !== "string" + ) + throw new Error("evm_wallet_authorization_invalid"); + const key = recover( + typedDigest( + evmAuthorizationTypedData({ + purpose: auth.purpose, + recipient: auth.recipient, + payload: auth.payload, + }), + ), + signatureBytes(auth.signature), + ); + if (key !== input.publicKey) throw new Error("evm_wallet_owner_mismatch"); +} + +export function verifyEvmWalletRequest(input: { + publicKey: string; + message: EvmWalletRequestMessage; + proof: string; +}) { + const proof = JSON.parse(input.proof) as Record; + const expected = evmWalletTypedData(input.message).message; + for (const [key, value] of Object.entries(expected)) + if (proof[key] !== value) throw new Error("evm_wallet_request_mismatch"); + if (typeof proof.signature !== "string") throw new Error("evm_wallet_signature_invalid"); + const key = recover( + typedDigest(evmWalletTypedData(input.message)), + signatureBytes(proof.signature), + ); + if (key !== input.publicKey) throw new Error("evm_wallet_owner_mismatch"); +} diff --git a/packages/owner-auth/src/evm-wallet.ts b/packages/owner-auth/src/evm-wallet.ts new file mode 100644 index 000000000..1475291ba --- /dev/null +++ b/packages/owner-auth/src/evm-wallet.ts @@ -0,0 +1,41 @@ +import { borshBytes, borshString, borshU32, concat } from "@near-intents-agent-api/contracts/borsh"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { base58 } from "@scure/base"; + +/** Trezu EIP-712 wallet-contract revision; separate from NEP-616 P-256 wallet code. */ +export const EVM_WALLET_CODE_ID = "eip712-wallet-contract.trezu.near"; + +/** Trezu's wallet state binds one 64-byte secp256k1 key to one deterministic 0s account. */ +export function deriveEvmWallet(publicKeyHex: string) { + if (!/^0x[0-9a-f]{128}$/.test(publicKeyHex)) throw new Error("evm_public_key_invalid"); + const key = Buffer.from(publicKeyHex.slice(2), "hex"); + const state = concat( + new Uint8Array([1]), // signature_enabled + borshU32(0), // wallet_id + key, + borshU32(3600), // timeout_secs + borshU32(0), // last_cleaned_at + borshU32(0), // old nonces + borshU32(0), // current nonces + borshU32(0), // extensions + ); + const stateInitBorsh = concat( + new Uint8Array([0, 1]), // StateInit::V1, GlobalContractId::AccountId + borshString(EVM_WALLET_CODE_ID), + borshU32(1), + borshBytes(new Uint8Array()), + borshBytes(state), + ); + return { + accountId: `0s${Buffer.from(keccak_256(stateInitBorsh)).subarray(12).toString("hex")}`, + publicKey: `secp256k1:${base58.encode(key)}`, + stateInitBorsh, + }; +} + +export function evmAddressFromPublicKey(publicKeyHex: string) { + if (!/^0x[0-9a-f]{128}$/.test(publicKeyHex)) throw new Error("evm_public_key_invalid"); + return `0x${Buffer.from(keccak_256(Buffer.from(publicKeyHex.slice(2), "hex"))) + .subarray(12) + .toString("hex")}`; +} diff --git a/packages/owner-auth/src/index.ts b/packages/owner-auth/src/index.ts new file mode 100644 index 000000000..9a8eb1315 --- /dev/null +++ b/packages/owner-auth/src/index.ts @@ -0,0 +1,296 @@ +import { createHash, createPublicKey } from "node:crypto"; +import type { OwnerProof, OwnerWallet } from "@near-intents-agent-api/contracts"; +import { canonical } from "@near-intents-agent-api/contracts"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import { convertCOSEtoPKCS, isoCBOR } from "@simplewebauthn/server/helpers"; + +export { verifyEvmWalletAuthorization, verifyEvmWalletRequest } from "./evm-authorization.js"; +export { deriveEvmWallet, EVM_WALLET_CODE_ID, evmAddressFromPublicKey } from "./evm-wallet.js"; +export { + verifyPasskeyChallengeProof, + verifyPasskeyWalletAuthorization, +} from "./passkey-authorization.js"; +export { derivePasskeyWallet, PASSKEY_WALLET_CODE_ID } from "./passkey-wallet.js"; +export { + evmWalletSignature, + nearSignatureHex, + passkeyWalletProof, + WalletOutputError, + type WebauthnAssertion, +} from "./wallet-output.js"; + +export type { OwnerProof, OwnerWallet }; + +/** Fail-closed verification error shared by every owner-proof consumer. */ +export class OwnerAuthError extends Error { + constructor( + public code: string, + public status: 400 | 401 | 403 | 409 = 401, + ) { + super(code); + } +} + +/** Verifies a NEP-413 proof for a NEAR `OwnerWallet`. Chain access stays in the host. */ +export type NearProofVerifier = { + verify(input: { + accountId: string; + publicKey: string; + message: string; + nonceHex: string; + recipient: string; + signatureHex: string; + }): Promise; +}; + +/** Consent envelope: canonical JSON plus the nonce/recipient the signature commits to. */ +export type OwnerMessage = { nonce: string; recipient: string } & Record; + +export type OwnerProofInput = { + owner: OwnerWallet; + message: OwnerMessage; + proof: OwnerProof; + counter?: number; +}; + +export type OwnerProofVerifier = { + verify(input: OwnerProofInput): Promise; +}; + +export function assertOwnerIdentity(actual: OwnerWallet | null, expected: OwnerWallet): void { + if (!actual || canonical(actual) !== canonical(expected)) + throw new OwnerAuthError("owner_mismatch", 409); +} + +/** + * Verifies one owner proof over the canonical JSON of `message`. Returns the next passkey + * counter so callers can persist replay protection atomically with their own nonce store. + */ +export function createOwnerProofVerifier(dependencies: { + verifyNear: NearProofVerifier; +}): OwnerProofVerifier { + return { + async verify(input) { + const message = canonical(input.message); + try { + switch (input.owner.type) { + case "near": + await dependencies.verifyNear.verify({ + accountId: input.owner.accountId, + publicKey: input.owner.publicKey, + message, + nonceHex: input.message.nonce, + recipient: input.message.recipient, + signatureHex: signatureOrThrow(input.proof), + }); + return 0; + case "evm": + return verifyEvm(input.owner, input.proof, message); + case "passkey": + return await verifyPasskey(input.owner, input.proof, message, input.counter ?? 0); + default: + throw new OwnerAuthError("owner_type_unsupported", 400); + } + } catch (error) { + if (error instanceof OwnerAuthError) throw error; + throw new OwnerAuthError("owner_proof_invalid", 401); + } + }, + }; +} + +function signatureOrThrow(proof: OwnerProof): string { + if (!("signature" in proof) || !/^[0-9a-f]{128}$/.test(proof.signature)) + throw new Error("signature_format"); + return proof.signature; +} + +/** + * Recovers the lowercase EVM address that produced an EIP-191 `personal_sign` signature + * over a UTF-8 message. Shared by owner-proof verification and SIWE login checks. + */ +export function recoverEvmAddress(input: { + message: string; + signature: `0x${string}` | string; +}): `0x${string}` { + const publicKey = recoverEvmPublicKey(input); + return `0x${Buffer.from(keccak_256(Buffer.from(publicKey.slice(2), "hex"))) + .subarray(12) + .toString("hex")}`; +} + +/** Discover the signer key from an owner-approved personal_sign message. */ +export function recoverEvmPublicKey(input: { + message: string; + signature: `0x${string}` | string; +}): `0x${string}` { + if (!/^0x[0-9a-fA-F]{130}$/.test(input.signature)) throw new Error("signature_format"); + const signature = Buffer.from(input.signature.slice(2), "hex"); + const recovery = signature[64] ?? -1; + if (![0, 1, 27, 28].includes(recovery)) throw new Error("signature_recovery"); + const bytes = Buffer.from(input.message); + const digest = keccak_256( + Buffer.concat([Buffer.from(`\x19Ethereum Signed Message:\n${bytes.length}`), bytes]), + ); + const recovered = secp256k1.Signature.fromBytes( + Buffer.concat([ + Buffer.from([recovery >= 27 ? recovery - 27 : recovery]), + signature.subarray(0, 64), + ]), + "recovered", + ); + if (recovered.hasHighS()) throw new Error("signature_malleable"); + const publicKey = recovered.recoverPublicKey(digest).toBytes(false); + return `0x${Buffer.from(publicKey.subarray(1)).toString("hex")}`; +} + +function verifyEvm( + owner: Extract, + proof: OwnerProof, + message: string, +): number { + if (!("signature" in proof)) throw new Error("signature_format"); + if (recoverEvmAddress({ message, signature: proof.signature }) !== owner.address) + throw new Error("signature_invalid"); + return 0; +} + +/** Requires exact HTTPS origin (localhost HTTP allowed) and a matching RP domain. */ +export function assertPasskeyOwnerOrigin(owner: Extract): URL { + const origin = new URL(owner.origin); + if ( + origin.origin !== owner.origin || + (origin.protocol !== "https:" && + !(origin.protocol === "http:" && origin.hostname === "localhost")) + ) + throw new Error("passkey_origin_invalid"); + if (origin.hostname !== owner.rpId && !origin.hostname.endsWith(`.${owner.rpId}`)) + throw new Error("passkey_rp_invalid"); + return origin; +} + +/** + * Converts a base64 (standard, padded) COSE public key — the format Better Auth's passkey + * plugin stores — into SPKI DER base64url for an `OwnerWallet` descriptor. + * + * `convertCOSEtoPKCS` returns only the uncompressed EC point, so the curve parameters are + * reattached through Node's own SPKI encoder rather than a hand-written DER header. + */ +export function spkiFromCoseBase64(coseBase64: string): string { + const point = convertCOSEtoPKCS(new Uint8Array(Buffer.from(coseBase64, "base64"))); + if (point[0] !== 0x04 || point.length !== 65) throw new Error("passkey_algorithm_unsupported"); + return createPublicKey({ + key: { + kty: "EC", + crv: "P-256", + x: toBase64Url(point.subarray(1, 33)), + y: toBase64Url(point.subarray(33)), + }, + format: "jwk", + }) + .export({ format: "der", type: "spki" }) + .toString("base64url"); +} + +function toBase64Url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64url"); +} + +/** Converts stored SPKI metadata into the COSE key WebAuthn verification expects. */ +export function cosePublicKeyFromSpki(spkiBase64url: string): Uint8Array { + const key = createPublicKey({ + key: Buffer.from(spkiBase64url, "base64url"), + format: "der", + type: "spki", + }).export({ format: "jwk" }); + if (key.kty !== "EC" || key.crv !== "P-256" || !key.x || !key.y) + throw new Error("passkey_algorithm_unsupported"); + return isoCBOR.encode( + new Map([ + [1, 2], + [3, -7], + [-1, 1], + [-2, Buffer.from(key.x, "base64url")], + [-3, Buffer.from(key.y, "base64url")], + ]), + ) as Uint8Array; +} + +/** + * Reads the COSE algorithm identifier from a WebAuthn registration response. + * + * The algorithm is not a field of the serialized response: `PublicKeyCredential` exposes it to + * the page, but what reaches the server is `attestationObject`, whose `authData` ends with the + * COSE key. Returning `null` for an unreadable object lets a caller refuse the credential before + * storing one it could never verify. + */ +export function coseAlgorithmFromRegistration(response: Record): number | null { + // WebAuthn JSON: the attestation sits under `response`, one level below the credential. + const inner = response.response; + const attestation = + typeof response.attestationObject === "string" + ? response.attestationObject + : inner && typeof inner === "object" + ? (inner as { attestationObject?: unknown }).attestationObject + : undefined; + if (typeof attestation !== "string") return null; + try { + // `decodeFirst` returns CBOR maps as `Map` instances, so the field is read through both shapes. + const decoded = isoCBOR.decodeFirst(new Uint8Array(Buffer.from(attestation, "base64url"))) as + | Map + | Record; + const authData = decoded instanceof Map ? decoded.get("authData") : decoded.authData; + if (!(authData instanceof Uint8Array)) return null; + // authData: rpIdHash(32) | flags(1) | counter(4) | [aaguid(16) | credIdLen(2) | credId | COSE]. + const flags = authData[32] ?? 0; + if ((flags & 0x40) === 0) return null; + const credentialIdLength = ((authData[53] ?? 0) << 8) | (authData[54] ?? 0); + const coseStart = 55 + credentialIdLength; + const coseBytes = Uint8Array.from(authData.subarray(coseStart)); + const cose = isoCBOR.decodeFirst(coseBytes) as Map; + // COSE key labels are negative/positive integers; the algorithm is label 3. + const algorithm = cose instanceof Map ? cose.get(3) : (cose as Record)["3"]; + return typeof algorithm === "number" ? algorithm : null; + } catch { + return null; + } +} + +/** WebAuthn challenge value: base64url SHA-256 of the canonical message. */ +export function ownerChallengeDigest(message: string): string { + return createHash("sha256").update(message).digest("base64url"); +} + +async function verifyPasskey( + owner: Extract, + proof: OwnerProof, + message: string, + counter: number, +): Promise { + if (!("authentication" in proof)) throw new Error("signature_format"); + const clientData = JSON.parse( + Buffer.from(proof.authentication.response.clientDataJSON, "base64url").toString("utf8"), + ) as { crossOrigin?: boolean; topOrigin?: string }; + if ( + (clientData.crossOrigin !== undefined && clientData.crossOrigin !== false) || + clientData.topOrigin !== undefined + ) + throw new Error("passkey_cross_origin_unsupported"); + assertPasskeyOwnerOrigin(owner); + const result = await verifyAuthenticationResponse({ + response: proof.authentication, + expectedChallenge: ownerChallengeDigest(message), + expectedOrigin: owner.origin, + expectedRPID: owner.rpId, + requireUserVerification: true, + credential: { + id: owner.credentialId, + publicKey: cosePublicKeyFromSpki(owner.publicKey), + counter, + }, + }); + if (!result.verified) throw new Error("signature_invalid"); + return result.authenticationInfo.newCounter; +} diff --git a/packages/owner-auth/src/passkey-authorization.ts b/packages/owner-auth/src/passkey-authorization.ts new file mode 100644 index 000000000..e58be2bfc --- /dev/null +++ b/packages/owner-auth/src/passkey-authorization.ts @@ -0,0 +1,96 @@ +import { createHash } from "node:crypto"; +import { + type OffchainMessage, + type OwnerWallet, + offchainMessageHash, +} from "@near-intents-agent-api/contracts"; +import { p256 } from "@noble/curves/nist.js"; +import { base58 } from "@scure/base"; +import { derivePasskeyWallet } from "./passkey-wallet.js"; + +type PasskeyOwner = Extract; + +function decodeBase64url(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid_base64url"); + return Buffer.from(value, "base64url"); +} + +/** Verify owner possession before sponsoring a not-yet-created NEP-616 wallet. */ +export function verifyPasskeyWalletAuthorization(input: { + owner: PasskeyOwner; + accountId: string; + authorization: string; + expectedPayload: string; + nowMs?: number; +}): void { + const wallet = derivePasskeyWallet(input.owner.publicKey); + if (wallet.accountId !== input.accountId) throw new Error("wallet_account_mismatch"); + if (input.authorization.length > 32_768) throw new Error("wallet_authorization_too_large"); + const authorization = JSON.parse(input.authorization) as { + signature?: { msg?: OffchainMessage; proof?: string }; + }; + const message = authorization.signature?.msg; + const proofText = authorization.signature?.proof; + if (!message || !proofText || proofText.length > 16_384) throw new Error("wallet_proof_invalid"); + const signedAt = Date.parse(message.timestamp); + const now = input.nowMs ?? Date.now(); + if ( + message.chain_id !== "mainnet" || + message.signer_id !== input.accountId || + message.payload !== input.expectedPayload || + (message.path?.length ?? 0) !== 0 || + !Number.isFinite(signedAt) || + signedAt > now + 30_000 || + signedAt < now - 300_000 + ) + throw new Error("wallet_message_invalid"); + verifyPasskeyChallengeProof({ + owner: input.owner, + proofText, + challenge: offchainMessageHash(message), + }); +} + +/** Local preflight before spending sponsor gas; contract remains final transaction authority. */ +export function verifyPasskeyChallengeProof(input: { + owner: PasskeyOwner; + proofText: string; + challenge: Uint8Array; +}): void { + const proof = JSON.parse(input.proofText) as { + authenticator_data?: string; + client_data_json?: string; + signature?: string; + }; + if (!proof.authenticator_data || !proof.client_data_json || !proof.signature?.startsWith("p256:")) + throw new Error("wallet_proof_invalid"); + const authenticatorData = decodeBase64url(proof.authenticator_data); + if (authenticatorData.length < 37) throw new Error("wallet_authenticator_data_invalid"); + const rpHash = createHash("sha256").update(input.owner.rpId).digest(); + if (!Buffer.from(authenticatorData.subarray(0, 32)).equals(rpHash)) + throw new Error("wallet_rp_mismatch"); + const flags = authenticatorData[32] ?? 0; + if ((flags & 0x05) !== 0x05) throw new Error("wallet_user_verification_required"); + const clientData = JSON.parse(proof.client_data_json) as { + type?: string; + challenge?: string; + origin?: string; + crossOrigin?: boolean; + }; + if ( + clientData.type !== "webauthn.get" || + clientData.origin !== input.owner.origin || + clientData.crossOrigin === true || + clientData.challenge !== Buffer.from(input.challenge).toString("base64url") + ) + throw new Error("wallet_challenge_mismatch"); + const signature = base58.decode(proof.signature.slice(5)); + if (signature.length !== 64) throw new Error("wallet_signature_invalid"); + const signedBytes = Buffer.concat([ + authenticatorData, + createHash("sha256").update(proof.client_data_json).digest(), + ]); + const publicKey = base58.decode(derivePasskeyWallet(input.owner.publicKey).publicKey.slice(5)); + if (!p256.verify(signature, signedBytes, publicKey, { format: "compact" })) + throw new Error("wallet_signature_invalid"); +} diff --git a/packages/owner-auth/src/passkey-wallet.ts b/packages/owner-auth/src/passkey-wallet.ts new file mode 100644 index 000000000..6c8716f2d --- /dev/null +++ b/packages/owner-auth/src/passkey-wallet.ts @@ -0,0 +1,60 @@ +import { createPublicKey } from "node:crypto"; +import { borshBytes, borshString, borshU32, concat } from "@near-intents-agent-api/contracts/borsh"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { base58 } from "@scure/base"; + +/** Mainnet P-256 wallet code published by near/intents for the NEP-616 wallet. */ +export const PASSKEY_WALLET_CODE_ID = "0saf343be226341c0eca7dba6d0b29d49bdff3ad03"; + +export type PasskeyWallet = { + accountId: string; + publicKey: string; + stateInitBorsh: Uint8Array; + stateInit: { V1: { code: { account_id: string }; data: Record } }; +}; + +/** + * Derive NEP-616 account from the same ES256 key stored by demo registration. + * Layout matches near/intents wallet State and StateInit at deployed P-256 code revision. + */ +export function derivePasskeyWallet(publicKeySpki: string): PasskeyWallet { + const jwk = createPublicKey({ + key: Buffer.from(publicKeySpki, "base64url"), + format: "der", + type: "spki", + }).export({ format: "jwk" }); + if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) + throw new Error("passkey_algorithm_unsupported"); + const x = Buffer.from(jwk.x, "base64url"); + const y = Buffer.from(jwk.y, "base64url"); + if (x.length !== 32 || y.length !== 32) throw new Error("passkey_algorithm_unsupported"); + const key = concat(new Uint8Array([(y[31] ?? 0) & 1 ? 3 : 2]), x); + const state = concat( + new Uint8Array([1]), // signature_enabled + borshU32(0), // subwallet_id + key, + borshU32(3600), // nonce timeout in seconds + new Uint8Array(8), // last_cleaned_at + borshU32(0), // old nonce map + borshU32(0), // current nonce map + borshU32(0), // extensions + ); + const stateInitBorsh = concat( + new Uint8Array([0, 1]), // StateInit::V1, GlobalContractId::AccountId + borshString(PASSKEY_WALLET_CODE_ID), + borshU32(1), // one data entry + borshBytes(new Uint8Array()), // empty state key + borshBytes(state), + ); + return { + accountId: `0s${Buffer.from(keccak_256(stateInitBorsh)).subarray(12).toString("hex")}`, + publicKey: `p256:${base58.encode(key)}`, + stateInitBorsh, + stateInit: { + V1: { + code: { account_id: PASSKEY_WALLET_CODE_ID }, + data: { "": Buffer.from(state).toString("base64") }, + }, + }, + }; +} diff --git a/packages/owner-auth/src/wallet-output.ts b/packages/owner-auth/src/wallet-output.ts new file mode 100644 index 000000000..c39cf7d9d --- /dev/null +++ b/packages/owner-auth/src/wallet-output.ts @@ -0,0 +1,91 @@ +import { p256 } from "@noble/curves/nist.js"; +import { base58, base64, base64urlnopad } from "@scure/base"; + +/** + * Normalizes raw wallet output into the encodings the owner verifiers and on-chain wallet + * contracts expect. Clients submit what their wallet returned; only this module knows the + * contract-side formats. + */ + +export class WalletOutputError extends Error { + constructor(public code: string) { + super(code); + } +} + +function toHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} + +/** + * NEP-413 signature as 64-byte lowercase hex. Wallets return `ed25519:`, base64, + * base58 or hex; base58 and base64 overlap, so an unprefixed value is accepted in the encoding + * that yields exactly 64 bytes. + */ +export function nearSignatureHex(value: string): string { + if (/^[0-9a-f]{128}$/i.test(value)) return value.toLowerCase(); + const prefixed = /^ed25519:([1-9A-HJ-NP-Za-km-z]+)$/.exec(value); + const candidates: Array<() => Uint8Array> = prefixed + ? [() => base58.decode(prefixed[1] ?? "")] + : [() => base64.decode(value), () => base58.decode(value)]; + for (const decode of candidates) { + try { + const bytes = decode(); + if (bytes.length === 64) return toHex(bytes); + } catch { + // Try the next encoding. + } + } + throw new WalletOutputError("signature_invalid"); +} + +/** + * EVM `0x` signature (r ‖ s ‖ v) as the wallet-contract `secp256k1:` form with a 0/1 + * recovery byte. + */ +export function evmWalletSignature(value: string): string { + if (!/^0x[0-9a-fA-F]{130}$/.test(value)) throw new WalletOutputError("signature_invalid"); + const bytes = Uint8Array.from(Buffer.from(value.slice(2), "hex")); + const recovery = bytes[64] ?? -1; + if (recovery === 27 || recovery === 28) bytes[64] = recovery - 27; + if (bytes[64] !== 0 && bytes[64] !== 1) throw new WalletOutputError("signature_invalid"); + return `secp256k1:${base58.encode(bytes)}`; +} + +/** The subset of a WebAuthn `AuthenticationResponseJSON` the wallet contract needs. */ +export type WebauthnAssertion = { + id: string; + response: { clientDataJSON: string; authenticatorData: string; signature: string }; +}; + +/** + * NEP-616 passkey proof: authenticator data, the exact client data JSON text and a low-S compact + * P-256 signature. Authenticators return DER signatures that may carry a high S. + */ +export function passkeyWalletProof(assertion: WebauthnAssertion): string { + let signature: InstanceType; + try { + signature = p256.Signature.fromBytes( + base64urlnopad.decode(assertion.response.signature.replace(/=+$/, "")), + "der", + ); + } catch { + throw new WalletOutputError("signature_invalid"); + } + const order = p256.Point.Fn.ORDER; + const lowS = + signature.s * 2n > order ? new p256.Signature(signature.r, order - signature.s) : signature; + let clientData: string; + try { + clientData = new TextDecoder("utf-8", { fatal: true }).decode( + base64urlnopad.decode(assertion.response.clientDataJSON.replace(/=+$/, "")), + ); + } catch { + throw new WalletOutputError("signature_invalid"); + } + return JSON.stringify({ + authenticator_data: assertion.response.authenticatorData, + client_data_json: clientData, + signature: `p256:${base58.encode(lowS.toBytes("compact"))}`, + }); +} diff --git a/packages/owner-auth/tests/surface.test.ts b/packages/owner-auth/tests/surface.test.ts new file mode 100644 index 000000000..fa7bad081 --- /dev/null +++ b/packages/owner-auth/tests/surface.test.ts @@ -0,0 +1,59 @@ +import { generateKeyPairSync } from "node:crypto"; +import { + deriveEvmWallet, + derivePasskeyWallet, + evmAddressFromPublicKey, +} from "@near-intents-agent-api/owner-auth"; +import { describe, expect, it } from "vitest"; + +function p256SpkiBase64Url(): string { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + return publicKey.export({ format: "der", type: "spki" }).toString("base64url"); +} + +function ed25519SpkiBase64Url(): string { + const { publicKey } = generateKeyPairSync("ed25519"); + return publicKey.export({ format: "der", type: "spki" }).toString("base64url"); +} + +function evmPublicKeyHex(): string { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "secp256k1" }); + const raw = publicKey.export({ format: "der", type: "spki" }); + // uncompressed point: the final 65 bytes are 0x04 || X (32) || Y (32) + const uncompressed = raw.subarray(raw.length - 65); + return `0x${uncompressed.subarray(1).toString("hex")}`; +} + +describe("vendored owner-auth wallet derivation", () => { + it("derives a stable NEP-616 0s account from a passkey SPKI key", () => { + const spki = p256SpkiBase64Url(); + const a = derivePasskeyWallet(spki); + const b = derivePasskeyWallet(spki); + expect(a.accountId).toBe(b.accountId); + expect(a.accountId.startsWith("0s")).toBe(true); + }); + + it("derives different passkey accounts from different keys", () => { + const a = derivePasskeyWallet(p256SpkiBase64Url()); + const b = derivePasskeyWallet(p256SpkiBase64Url()); + expect(a.accountId).not.toBe(b.accountId); + }); + + it("refuses non-P-256 passkey material with the protocol error", () => { + expect(() => derivePasskeyWallet(ed25519SpkiBase64Url())).toThrow( + "passkey_algorithm_unsupported", + ); + }); + + it("derives a deterministic 0s account from an EVM key, distinct from the keccak address", () => { + const publicKeyHex = evmPublicKeyHex(); + const wallet = deriveEvmWallet(publicKeyHex); + expect(wallet.accountId).toMatch(/^0s[0-9a-f]{40}$/); + expect(evmAddressFromPublicKey(publicKeyHex)).toMatch(/^0x[0-9a-f]{40}$/); + expect(deriveEvmWallet(publicKeyHex).accountId).toBe(wallet.accountId); + }); + + it("refuses malformed EVM public keys", () => { + expect(() => deriveEvmWallet("0x1234")).toThrow("evm_public_key_invalid"); + }); +}); diff --git a/packages/owner-auth/tsconfig.json b/packages/owner-auth/tsconfig.json new file mode 100644 index 000000000..c1d8fd273 --- /dev/null +++ b/packages/owner-auth/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/relayer/package.json b/packages/relayer/package.json new file mode 100644 index 000000000..db127c0cc --- /dev/null +++ b/packages/relayer/package.json @@ -0,0 +1,33 @@ +{ + "name": "@near-intents-agent-api/relayer", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./schema": { + "types": "./src/schema.ts", + "import": "./src/schema.ts" + }, + "./owner-message": { + "types": "./src/owner-message.ts", + "import": "./src/owner-message.ts" + } + }, + "dependencies": { + "@near-intents-agent-api/contracts": "workspace:*", + "@scure/base": "catalog:", + "near-api-js": "7.2.0", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/relayer/src/confirmation.ts b/packages/relayer/src/confirmation.ts new file mode 100644 index 000000000..d6dc9a12a --- /dev/null +++ b/packages/relayer/src/confirmation.ts @@ -0,0 +1,64 @@ +import type { FailoverRpcProvider } from "near-api-js"; +import { type NonceWitness, nonceConsumed } from "./finality.js"; +import { isUnknownEverywhere } from "./near-rpc.js"; + +/** `dropped`: the transaction never executed and never can; its nonce went to another one. */ +export type SponsorTransactionStatus = "pending" | "succeeded" | "failed" | "dropped"; + +/** + * Portable relay outcome. `status` is the raw execution status the chain returned, kept as an + * opaque record so consumers do not depend on the RPC provider's generated types. + */ +export type RelayTransactionOutcome = { + transactionHash: string; + finalExecutionStatus: string; + status: Record; +}; + +/** + * A relayed transaction is only settled when the chain finalized it *and* execution produced a + * success value. `FINAL` alone means "decided", not "succeeded": a finalized failure receipt is a + * failed relay. + */ +export function transactionSucceeded( + result: T, +): result is T & { status: Record } { + return ( + result.final_execution_status === "FINAL" && + typeof result.status === "object" && + result.status !== null && + "SuccessValue" in result.status + ); +} + +/** + * Observation-only re-read of a relayed transaction, used to reconcile a persisted hash. The read + * returns the current status immediately; a long-poll `FINAL` read is cut by rate-limited RPCs. + * + * With the nonce the transaction was signed at, a hash no endpoint knows is `dropped` once the + * finalized access-key nonce has reached it. The nonce is read first: if the transaction itself had + * consumed it, it was final before the status read, and the status read would find it. The caller + * must only pass a witness while the transaction is within the RPC retention window, because a + * garbage-collected hash is also unknown. + */ +export async function sponsorTransactionStatus( + provider: FailoverRpcProvider, + accountId: string, + transactionHash: string, + witness?: NonceWitness, +): Promise { + const consumed = witness ? await nonceConsumed(provider, accountId, witness) : undefined; + let result: Awaited>; + try { + result = await provider.viewTransactionStatus({ + txHash: transactionHash, + accountId, + waitUntil: "NONE", + }); + } catch (error) { + if (consumed === true && isUnknownEverywhere(error)) return "dropped"; + throw error; + } + if (result.final_execution_status !== "FINAL") return "pending"; + return transactionSucceeded(result) ? "succeeded" : "failed"; +} diff --git a/packages/relayer/src/deterministic-wallet.ts b/packages/relayer/src/deterministic-wallet.ts new file mode 100644 index 000000000..bdf787adb --- /dev/null +++ b/packages/relayer/src/deterministic-wallet.ts @@ -0,0 +1,184 @@ +import { createHash } from "node:crypto"; +import { + borshString, + borshU32, + borshU64, + borshU128, + concat, +} from "@near-intents-agent-api/contracts/borsh"; +import { base58 } from "@scure/base"; +import { KeyPair, type KeyPairString } from "near-api-js"; +import { awaitFinalTransaction, type NonceWitness } from "./finality.js"; +import { createNearRpcProvider } from "./near-rpc.js"; +import { + nextOperationalNonce, + recordOperationalNonce, + resetOperationalNonce, +} from "./operational-account.js"; +import { assertSponsorBalance } from "./sponsor-balance.js"; + +/** Gas and new-account storage a state init takes from the sponsor; about 0.008 NEAR on mainnet. */ +const stateInitReserveYocto = 10n ** 22n; + +/** NEAR protocol Action::DeterministicStateInit, variant 11. */ +function stateInitAction(stateInitBorsh: Uint8Array): Uint8Array { + return concat(new Uint8Array([11]), stateInitBorsh, borshU128(0n)); +} + +/** A `send_tx` reply is a small JSON envelope; anything past this is oversized or malformed. */ +const sendStateInitMaxResponseBytes = 1_048_576; + +const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** A broadcast acceptance or rejection from a cleartext remote or redirected endpoint is not trustworthy. */ +function trustedRpcUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.protocol === "https:" || + (parsed.protocol === "http:" && loopbackHosts.has(parsed.hostname)) + ); + } catch { + return false; + } +} + +/** Reads at most `sendStateInitMaxResponseBytes` and cancels the stream beyond it. */ +async function readBoundedSendTxResponse( + response: Response, +): Promise<{ error?: unknown } | undefined> { + if (!response.body) return undefined; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > sendStateInitMaxResponseBytes) return undefined; + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +/** + * Raw `send_tx` for an action near-api-js cannot encode. `wait_until: NONE` returns once a node + * accepts the bytes; finality is observed separately by short status reads. Acceptance proves + * nothing, so the identical bytes go to every trusted endpoint at once (see `near-rpc.ts`). + * + * Exported only for direct unit testing of the endpoint-bounding behavior below; not part of the + * package's public surface (see `index.ts`). + */ +export async function sendStateInit(urls: string[], signed: Uint8Array): Promise { + const request = JSON.stringify({ + jsonrpc: "2.0", + id: "wallet-init", + method: "send_tx", + params: { signed_tx_base64: Buffer.from(signed).toString("base64"), wait_until: "NONE" }, + }); + const answers = await Promise.all( + urls + .filter(trustedRpcUrl) + .map(async (url): Promise<{ accepted: true } | { rejection?: unknown }> => { + try { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: request, + redirect: "error", + credentials: "omit", + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + await response.body?.cancel(); + return {}; + } + const body = await readBoundedSendTxResponse(response); + // Oversized or malformed: not proof of either outcome. + if (body === undefined) return {}; + return body.error ? { rejection: body.error } : { accepted: true }; + } catch { + return {}; + } + }), + ); + if (answers.some((answer) => "accepted" in answer)) return; + const rejection = answers.find( + (answer): answer is { rejection: unknown } => "rejection" in answer && !!answer.rejection, + ); + if (rejection) throw new Error("wallet_init_rejected", { cause: rejection.rejection }); + throw Object.assign(new Error("wallet_init_rpc_unavailable"), { type: "RetriesExceeded" }); +} + +export function createDeterministicWalletInitializer(input: { + rpcUrls: string[]; + accountId: string; + privateKey: KeyPairString; + beforeBroadcast?: (hash: string, witness?: NonceWitness) => Promise; +}) { + const provider = createNearRpcProvider(input.rpcUrls); + const keyPair = KeyPair.fromString(input.privateKey); + const publicKey = keyPair.getPublicKey().toString(); + const rawPublicKey = base58.decode(publicKey.slice("ed25519:".length)); + if (rawPublicKey.length !== 32) throw new Error("wallet_sponsor_key_invalid"); + + const initialize = async (walletAccountId: string, stateInitBorsh: Uint8Array) => { + const access = await provider.viewAccessKey({ + accountId: input.accountId, + publicKey, + finalityQuery: { finality: "final" }, + }); + if (access.permission !== "FullAccess") throw new Error("wallet_sponsor_key_restricted"); + const witness = { + publicKey, + nonce: nextOperationalNonce(input.accountId, publicKey, access.nonce + 1n), + }; + const blockHash = base58.decode(access.block_hash); + const transaction = concat( + borshString(input.accountId), + new Uint8Array([0]), + rawPublicKey, + borshU64(witness.nonce), + borshString(walletAccountId), + blockHash, + borshU32(1), + stateInitAction(stateInitBorsh), + ); + const hash = createHash("sha256").update(transaction).digest(); + const signature = keyPair.sign(hash).signature; + const signed = concat(transaction, new Uint8Array([0]), signature); + const transactionHash = base58.encode(hash); + await assertSponsorBalance(provider, input.accountId, { + depositYocto: stateInitReserveYocto, + gas: 0n, + }); + recordOperationalNonce(input.accountId, witness); + await input.beforeBroadcast?.(transactionHash, witness); + try { + const result = await awaitFinalTransaction({ + provider, + transactionHash, + senderId: input.accountId, + broadcast: () => sendStateInit(input.rpcUrls, signed), + }); + if ( + !result.status || + typeof result.status !== "object" || + !("SuccessValue" in result.status || "SuccessReceiptId" in result.status) + ) + throw new Error("wallet_init_unconfirmed"); + } finally { + // Raw deterministic-state-init bypasses near-api-js's shared nonce manager. + await resetOperationalNonce(input); + } + return transactionHash; + }; + return async (walletAccountId: string, stateInitBorsh: Uint8Array) => { + if (!/^0s[0-9a-f]{40}$/.test(walletAccountId)) throw new Error("wallet_account_invalid"); + return initialize(walletAccountId, stateInitBorsh); + }; +} diff --git a/packages/relayer/src/errors.ts b/packages/relayer/src/errors.ts new file mode 100644 index 000000000..42dab9984 --- /dev/null +++ b/packages/relayer/src/errors.ts @@ -0,0 +1,37 @@ +/** + * Stable relay rejection codes. Every one of these is decided before any transaction is + * broadcast, so a caller that sees one can treat the relay as refuted rather than uncertain. + */ +export type RelayErrorCode = + | "relay_receiver_denied" + | "relay_owner_mismatch" + | "relay_signature_invalid" + | "relay_gas_limit" + | "relay_authority_expired" + | "relay_disabled" + | "policy_delegate_expired" + | "sponsor_balance_insufficient"; + +const statusByCode: Record = { + relay_receiver_denied: 403, + relay_owner_mismatch: 403, + relay_signature_invalid: 401, + relay_gas_limit: 400, + relay_authority_expired: 409, + relay_disabled: 503, + policy_delegate_expired: 409, + sponsor_balance_insufficient: 503, +}; + +export class RelayError extends Error { + readonly status: 400 | 401 | 403 | 409 | 503; + + constructor( + public code: RelayErrorCode, + cause?: unknown, + ) { + super(code, cause === undefined ? undefined : { cause }); + this.name = "RelayError"; + this.status = statusByCode[code]; + } +} diff --git a/packages/relayer/src/finality.ts b/packages/relayer/src/finality.ts new file mode 100644 index 000000000..da9024e90 --- /dev/null +++ b/packages/relayer/src/finality.ts @@ -0,0 +1,108 @@ +import type { FailoverRpcProvider } from "near-api-js"; +import { isTransportFailure } from "./near-rpc.js"; + +type FinalOutcome = Awaited>; + +export type FinalityOptions = { + /** Upper bound on the whole wait. The caller keeps the persisted hash for reconciliation. */ + timeoutMs?: number; + pollIntervalMs?: number; + /** Identical signed bytes are resent while the chain has not seen the hash. */ + rebroadcastIntervalMs?: number; +}; + +/** The access key a transaction was signed with and the nonce it consumes. */ +export type NonceWitness = { publicKey: string; nonce: bigint }; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Broadcasts signed bytes and polls for finality instead of holding one RPC request open. + * + * `send_tx` with `wait_until: FINAL` keeps an HTTP request open for several blocks. Rate-limited + * RPC gateways cut such requests with HTTP 408 (drpc's free plan does so after ~2 s), and + * near-api-js retries 408 twelve times with exponential backoff: ~85 s of sleeping before the + * failover provider is even tried, for a transaction that finalized in ~3 s. Here the broadcast + * returns once the node accepts the transaction, and short `tx` status reads, each answered + * immediately, observe finality. + * + * `broadcast` must resend the same signed bytes: a resubmission has the same hash and nonce, so + * the chain executes it at most once. Validation errors from the first broadcast (for example + * `InvalidNonce`) are rethrown, because they prove the transaction never executed. A transport + * failure is not proof either way, so it only falls through to polling. + * + * Nodes accept a stale-nonce transaction under `wait_until: NONE` without error, so the broadcast + * alone never proves `InvalidNonce`. A consumed nonce can belong to the original transaction + * while status is unavailable, so finality times out uncertain instead of authorizing replacement. + */ +export async function awaitFinalTransaction(input: { + provider: Pick; + transactionHash: string; + senderId: string; + broadcast: () => Promise; + options?: FinalityOptions; +}): Promise { + const timeoutMs = input.options?.timeoutMs ?? 60_000; + const pollIntervalMs = input.options?.pollIntervalMs ?? 500; + const rebroadcastIntervalMs = input.options?.rebroadcastIntervalMs ?? 5_000; + const deadline = Date.now() + timeoutMs; + try { + await input.broadcast(); + } catch (error) { + if (!isTransportFailure(error)) throw error; + } + let lastBroadcast = Date.now(); + while (true) { + const outcome = await readOutcome(input.provider, input.transactionHash, input.senderId); + if (outcome?.final_execution_status === "FINAL") return outcome; + if (Date.now() >= deadline) + throw Object.assign(new Error("transaction_finality_timeout"), { + code: "transaction_finality_timeout", + }); + if (!outcome && Date.now() - lastBroadcast >= rebroadcastIntervalMs) { + lastBroadcast = Date.now(); + // A rebroadcast may race inclusion and report a used nonce; the next status read decides. + await input.broadcast().catch(() => undefined); + } + await sleep(pollIntervalMs); + } +} + +/** Whether the finalized access-key nonce reached the witness; `undefined` when unreadable. */ +export async function nonceConsumed( + provider: Partial>, + accountId: string, + witness: NonceWitness, +): Promise { + if (!provider.viewAccessKey) return undefined; + try { + const access = await provider.viewAccessKey({ + accountId, + publicKey: witness.publicKey, + finalityQuery: { finality: "final" }, + }); + return BigInt(access.nonce) >= witness.nonce; + } catch { + return undefined; + } +} + +/** + * One immediate status read. Unknown and unreachable both read as "not yet": neither is evidence + * that the hash is absent, so the wait can only end FINAL or timed out. + */ +async function readOutcome( + provider: Pick, + transactionHash: string, + senderId: string, +): Promise { + try { + return await provider.viewTransactionStatus({ + txHash: transactionHash, + accountId: senderId, + waitUntil: "NONE", + }); + } catch { + return null; + } +} diff --git a/packages/relayer/src/index.ts b/packages/relayer/src/index.ts new file mode 100644 index 000000000..5f0256f86 --- /dev/null +++ b/packages/relayer/src/index.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto"; +import { + actions, + encodeDelegateAction, + type KeyPairString, + PublicKey, + Signature, +} from "near-api-js"; +import { transactionSucceeded } from "./confirmation.js"; +import { RelayError } from "./errors.js"; +import { awaitFinalTransaction, type NonceWitness } from "./finality.js"; +import { createNearRpcProvider } from "./near-rpc.js"; +import { sendOperationalTransaction, signOperationalTransaction } from "./operational-account.js"; +import type { RelayInput, RelayResult } from "./schema.js"; + +export { createDeterministicWalletInitializer } from "./deterministic-wallet.js"; +export type { RelayErrorCode } from "./errors.js"; +export { RelayError } from "./errors.js"; +export { awaitFinalTransaction, type FinalityOptions, type NonceWitness } from "./finality.js"; +export { createNearRpcProvider, nearRpcRetryOptions } from "./near-rpc.js"; +export { + operationalAccount, + resetOperationalNonce, + sendOperationalTransaction, +} from "./operational-account.js"; +export { policyStorageDeposit } from "./policy-storage.js"; +export type { RelayInput, RelayResult } from "./schema.js"; +export { relaySchema } from "./schema.js"; +export { + createSignedWalletRelayer, + createWalletPolicyFunder, + SIGNED_WALLET_RELAY_GAS, +} from "./signed-wallet.js"; +export { + addCosts, + assertSponsorBalance, + observeSponsorBalance, + type SponsorBalanceObservation, + type SponsorCost, + transactionCost, +} from "./sponsor-balance.js"; +export type { KeyPairString }; +/** Protocol ceiling for one relayed transaction, shared across its actions. */ +export const RELAY_MAX_TOTAL_GAS = 100_000_000_000_000n; +/** One action may not consume the whole budget, so a multi-action request stays bounded. */ +export const RELAY_MAX_ACTION_GAS = 50_000_000_000_000n; + +/** Signed NEP-366 relay boundary with owner signature and receiver checks. */ +export function validateDelegate( + input: RelayInput, + ownerPublicKeyHex: string, + allowedReceivers: string[], +) { + if (!allowedReceivers.includes(input.receiverId)) throw new RelayError("relay_receiver_denied"); + let publicKey: PublicKey; + try { + publicKey = PublicKey.from(input.publicKey); + } catch (cause) { + throw new RelayError("relay_owner_mismatch", cause); + } + if (Buffer.from(publicKey.data).toString("hex") !== ownerPublicKeyHex) + throw new RelayError("relay_owner_mismatch"); + if (publicKey.data.every((byte) => byte === 0)) throw new RelayError("relay_signature_invalid"); + let totalGas = 0n; + for (const action of input.actions) { + const gas = BigInt(action.gas); + if (gas <= 0n || gas > RELAY_MAX_ACTION_GAS) throw new RelayError("relay_gas_limit"); + totalGas += gas; + } + if (totalGas <= 0n || totalGas > RELAY_MAX_TOTAL_GAS) throw new RelayError("relay_gas_limit"); + const delegateAction = { + senderId: input.senderId, + receiverId: input.receiverId, + publicKey, + nonce: BigInt(input.nonce), + maxBlockHeight: BigInt(input.maxBlockHeight), + actions: input.actions.map((a) => + actions.functionCall(a.methodName, Buffer.from(a.argsBase64, "base64"), BigInt(a.gas), 0n), + ), + }; + const signature = Buffer.from(input.signatureHex, "hex"); + if ( + !publicKey.verify( + createHash("sha256").update(encodeDelegateAction(delegateAction)).digest(), + signature, + ) + ) + throw new RelayError("relay_signature_invalid"); + return { delegateAction, signature: new Signature({ keyType: 0, data: signature }) }; +} +export function createNearRelayer(config: { + rpcUrls: string[]; + accountId: string; + privateKey: KeyPairString; + allowedReceivers: string[]; +}): ( + input: RelayInput, + ownerPublicKeyHex: string, + beforeBroadcast: (transactionHash: string, witness?: NonceWitness) => Promise, +) => Promise { + const provider = createNearRpcProvider(config.rpcUrls); + return async (input: RelayInput, ownerPublicKeyHex: string, beforeBroadcast) => { + const signed = validateDelegate(input, ownerPublicKeyHex, config.allowedReceivers); + const access = await provider.viewAccessKey({ + accountId: input.senderId, + publicKey: input.publicKey, + finalityQuery: { finality: "final" }, + }); + if ( + access.permission !== "FullAccess" || + BigInt(input.nonce) <= access.nonce || + BigInt(input.maxBlockHeight) <= BigInt(access.block_height) + ) + throw new RelayError("relay_authority_expired"); + // Fence after signing and before any broadcast; a refusal means nothing reached the chain. + // The signed delegate has its own nonce; this call signs one outer transaction. + const { hash: transactionHash, result } = await sendOperationalTransaction({ + ...config, + beforeBroadcast, + prepare: async (account) => { + const prepared = await signOperationalTransaction(account, { + receiverId: signed.delegateAction.senderId, + actions: [actions.signedDelegate(signed)], + }); + return { + ...prepared, + send: () => + awaitFinalTransaction({ + provider, + transactionHash: prepared.hash, + senderId: config.accountId, + broadcast: () => provider.sendTransactionUntil(prepared.signedTransaction, "NONE"), + }), + }; + }, + }); + if (!transactionSucceeded(result)) + throw new Error("relay_transaction_failed", { cause: result.status }); + return { + transactionHash, + finalExecutionStatus: result.final_execution_status, + }; + }; +} + +export { createOwnerSigningRequest, ownerNep413SigningDigest } from "./owner-message.js"; +export type { + OwnerPolicyDelegate, + OwnerPolicySponsor, + OwnerPolicySponsorConfig, + OwnerPolicySubmission, +} from "./owner-policy.js"; +export { + createOwnerPolicySponsor, + ownerPolicyAction, + ownerPolicyDelegateSchema, + ownerPolicySigningDigest, + validateOwnerPolicyDelegate, +} from "./owner-policy.js"; diff --git a/packages/relayer/src/near-rpc.ts b/packages/relayer/src/near-rpc.ts new file mode 100644 index 000000000..4d5689c0c --- /dev/null +++ b/packages/relayer/src/near-rpc.ts @@ -0,0 +1,119 @@ +import { FailoverRpcProvider, JsonRpcProvider, TypedError } from "near-api-js"; + +/** + * Per-endpoint retry budget. near-api-js defaults to 12 attempts with 1.5x backoff from 500 ms, + * roughly 85 s of sleeping when an endpoint keeps answering 408/5xx. Failing fast lets the + * failover provider move to the next endpoint instead. + */ +export const nearRpcRetryOptions = { retries: 3, wait: 250, backoff: 2 } as const; + +export function isTransportFailure(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const value = error as { name?: unknown; type?: unknown; cause?: unknown; message?: unknown }; + return ( + value.name === "ProviderError" || + value.type === "RetriesExceeded" || + typeof value.cause === "number" || + (typeof value.message === "string" && /fetch failed|network|timeout/i.test(value.message)) + ); +} + +function isUnknownTransaction(error: unknown): boolean { + return ( + !!error && + typeof error === "object" && + (error as { name?: unknown }).name === "UnknownTransactionError" + ); +} + +/** Whether a failed status read proves that no configured endpoint knows the transaction. */ +export function isUnknownEverywhere(error: unknown): boolean { + return ( + !!error && + typeof error === "object" && + (error as { unknownEverywhere?: unknown }).unknownEverywhere === true + ); +} + +class TransactionSafeFailoverRpcProvider extends FailoverRpcProvider { + private async broadcast(send: (provider: JsonRpcProvider) => Promise): Promise { + let lastError: unknown; + for (const provider of this.providers as JsonRpcProvider[]) { + try { + return await send(provider); + } catch (error) { + if (!isTransportFailure(error)) throw error; + lastError = error; + } + } + throw lastError; + } + + /** + * `wait_until: NONE` acceptance proves nothing: public endpoints also accept transactions the + * chain will drop. Identical signed bytes therefore go to every endpoint at once, so one gateway + * that silently loses a transaction cannot strand it; the same bytes have the same hash and + * execute at most once. A rejection surfaces only when no endpoint accepted. Transport + * ambiguity takes precedence over another endpoint's validation error. + */ + private async broadcastEverywhere( + send: (provider: JsonRpcProvider) => Promise, + ): Promise { + const results = await Promise.allSettled((this.providers as JsonRpcProvider[]).map(send)); + const accepted = results.find((result) => result.status === "fulfilled"); + if (accepted) return accepted.value; + const errors = results.map((result) => (result as PromiseRejectedResult).reason); + throw errors.find(isTransportFailure) ?? errors.at(-1); + } + + /** + * Status reads are polled while a fresh transaction is still unknown to the node, so an + * `UnknownTransaction` answer is the expected "not yet". The base failover loop logs every + * endpoint error with `console.error`; this loop fails over the same way without the noise and + * keeps the base `RetriesExceeded` contract, with the last endpoint error as its cause and + * `unknownEverywhere` when every endpoint answered that it does not know the hash. + */ + override async viewTransactionStatus( + ...args: Parameters + ): ReturnType { + const errors: unknown[] = []; + for (const provider of this.providers as JsonRpcProvider[]) { + try { + return await provider.viewTransactionStatus(...args); + } catch (error) { + errors.push(error); + } + } + throw Object.assign( + new TypedError( + `Exceeded ${this.providers.length} providers to execute request`, + "RetriesExceeded", + ), + { + cause: errors.at(-1), + // Every endpoint answered, and none knows the hash: not an unreachable-network failure. + unknownEverywhere: errors.every(isUnknownTransaction), + }, + ); + } + + override sendTransaction(...args: Parameters) { + return this.broadcast((provider) => provider.sendTransaction(...args)); + } + + override sendTransactionUntil(...args: Parameters) { + return args[1] === "NONE" + ? this.broadcastEverywhere((provider) => provider.sendTransactionUntil(...args)) + : this.broadcast((provider) => provider.sendTransactionUntil(...args)); + } + + override sendTransactionAsync(...args: Parameters) { + return this.broadcast((provider) => provider.sendTransactionAsync(...args)); + } +} + +export function createNearRpcProvider(urls: readonly string[]): FailoverRpcProvider { + return new TransactionSafeFailoverRpcProvider( + urls.map((url) => new JsonRpcProvider({ url }, nearRpcRetryOptions)), + ); +} diff --git a/packages/relayer/src/operational-account.ts b/packages/relayer/src/operational-account.ts new file mode 100644 index 000000000..8087f74e5 --- /dev/null +++ b/packages/relayer/src/operational-account.ts @@ -0,0 +1,150 @@ +import { + Account, + type Action, + baseEncode, + type KeyPairString, + type PublicKey, + type SignedTransaction, +} from "near-api-js"; +import type { NonceWitness } from "./finality.js"; +import { createNearRpcProvider } from "./near-rpc.js"; +import { assertSponsorBalance, type SponsorCost, transactionCost } from "./sponsor-balance.js"; + +/** + * One `Account` instance per operational key, process-wide. + * + * `near-api-js` keeps its access-key nonce cache inside `Account` (`NonceManager`), keyed only + * by public key. Constructing a fresh `Account` per call therefore makes two concurrent + * transactions read the same chain nonce, and the second one is rejected as `InvalidNonce`. + * Reusing the instance lets that in-process cache serialize nonce allocation. + * + * This is necessary but not sufficient across replicas: `apps/server` additionally wraps each + * broadcast in a Postgres advisory lock so only one process prepares a transaction per key. + */ +const accounts = new Map(); + +function accountKey(input: { rpcUrls: string[]; accountId: string; privateKey: KeyPairString }) { + return `${input.rpcUrls.join(",")}\u0000${input.accountId}\u0000${input.privateKey}`; +} + +export function operationalAccount(input: { + rpcUrls: string[]; + accountId: string; + privateKey: KeyPairString; +}): Account { + const key = accountKey(input); + const existing = accounts.get(key); + if (existing) return existing; + const provider = createNearRpcProvider(input.rpcUrls); + const account = new Account(input.accountId, provider, input.privateKey); + accounts.set(key, account); + return account; +} + +/** + * Highest nonce this process signed per sponsor key. + * + * `near-api-js` reads the next nonce at `optimistic` finality, and a load-balanced RPC can answer + * from a node a few blocks behind. Back-to-back transactions from one key (wallet init, storage + * funding, relay) would then reuse a nonce, and the node silently drops the duplicate. Nonce gaps + * are valid, so never signing at or below the floor costs nothing. + */ +const nonceFloors = new Map(); + +function floorKey(accountId: string, publicKey: string) { + return `${accountId}\u0000${publicKey}`; +} + +/** The chain's candidate nonce, raised above every nonce this process already signed. */ +export function nextOperationalNonce(accountId: string, publicKey: string, candidate: bigint) { + const floor = nonceFloors.get(floorKey(accountId, publicKey)); + return floor !== undefined && candidate <= floor ? floor + 1n : candidate; +} + +export function recordOperationalNonce(accountId: string, witness: NonceWitness) { + const key = floorKey(accountId, witness.publicKey); + const floor = nonceFloors.get(key); + if (floor === undefined || witness.nonce > floor) nonceFloors.set(key, witness.nonce); +} + +/** One signed operational transaction, with the nonce it consumes and what it costs the sponsor. */ +export type PreparedOperational = { + hash: string; + send: () => Promise; + witness?: NonceWitness; + cost?: SponsorCost; +}; + +/** Signs `actions` with the account's key at a nonce above this process's floor. */ +export async function signOperationalTransaction( + account: Account, + input: { receiverId: string; actions: Action[] }, +): Promise<{ + hash: string; + signedTransaction: SignedTransaction; + witness: NonceWitness; + cost: SponsorCost; +}> { + const signer = account.getSigner(); + if (!signer) throw new Error("operational_signer_missing"); + const publicKey = await signer.getPublicKey(); + const transaction = await account.createTransaction({ ...input, publicKey }); + transaction.nonce = nextOperationalNonce( + account.accountId, + publicKey.toString(), + transaction.nonce, + ); + const { txHash, signedTransaction } = await signer.signTransaction(transaction); + return { + hash: baseEncode(txHash), + signedTransaction, + witness: { publicKey: publicKey.toString(), nonce: transaction.nonce }, + cost: transactionCost(input.actions), + }; +} + +/** + * Prepares, persists and broadcasts exactly one operational transaction. An error after + * `beforeBroadcast` can be ambiguous across RPC endpoints, and a consumed nonce can belong to this + * very transaction, so no error ever authorizes a replacement at a fresh nonce. + */ +export async function sendOperationalTransaction(input: { + rpcUrls: string[]; + accountId: string; + privateKey: KeyPairString; + prepare: (account: Account) => Promise>; + beforeBroadcast: (hash: string, witness?: NonceWitness) => Promise; +}): Promise<{ hash: string; result: T }> { + // Other replicas sign with the same key under the shared sponsor lock, so this process's cached + // nonce can be behind the chain. Every send starts from the chain's access-key nonce instead of + // discovering the drift through a superseded transaction. + await resetOperationalNonce(input); + const account = operationalAccount(input); + const { hash, send, witness, cost } = await input.prepare(account); + if (cost) await assertSponsorBalance(account.provider, input.accountId, cost); + if (witness) recordOperationalNonce(input.accountId, witness); + await input.beforeBroadcast(hash, witness); + return { hash, result: await send() }; +} + +/** + * Forget the cached access-key nonce for one key. `nonceManager` is private in the type + * definitions but present at runtime; when the shape is unavailable this degrades to a no-op and + * the caller still fails closed. + */ +export async function resetOperationalNonce(input: { + rpcUrls: string[]; + accountId: string; + privateKey: KeyPairString; +}): Promise { + const account = accounts.get(accountKey(input)); + if (!account) return; + const manager = ( + account as unknown as { + nonceManager?: { invalidate(publicKey: PublicKey): Promise }; + } + ).nonceManager; + const signer = account.getSigner(); + if (!manager || !signer) return; + await manager.invalidate(await signer.getPublicKey()); +} diff --git a/packages/relayer/src/owner-message.ts b/packages/relayer/src/owner-message.ts new file mode 100644 index 000000000..bc8155e3b --- /dev/null +++ b/packages/relayer/src/owner-message.ts @@ -0,0 +1,52 @@ +import { createHash } from "node:crypto"; +import { + canonical, + type OwnerSigningRequest, + type OwnerWallet, +} from "@near-intents-agent-api/contracts"; + +/** + * Creates the NEP-413 digest for an external wallet signer. + * The SDK never owns or invokes the owner's private key. + */ +export function ownerNep413SigningDigest(input: { + message: string; + recipient: string; + nonce: Uint8Array; +}): Buffer { + if (input.nonce.length !== 32) throw new Error("NEP-413 nonce must be 32 bytes"); + const encodeString = (value: string) => { + const bytes = Buffer.from(value); + const length = Buffer.alloc(4); + length.writeUInt32LE(bytes.length); + return Buffer.concat([length, bytes]); + }; + const prefix = Buffer.alloc(4); + prefix.writeUInt32LE(2147484061); + return createHash("sha256") + .update( + Buffer.concat([ + prefix, + encodeString(input.message), + Buffer.from(input.nonce), + encodeString(input.recipient), + Buffer.from([0]), + ]), + ) + .digest(); +} + +export function createOwnerSigningRequest(message: { + owner: OwnerWallet; + nonce: string; + recipient: string; +}): OwnerSigningRequest { + const text = canonical(message); + return { + owner: message.owner, + message: text, + nonce: message.nonce, + recipient: message.recipient, + challenge: createHash("sha256").update(text).digest("base64url"), + }; +} diff --git a/packages/relayer/src/owner-policy.ts b/packages/relayer/src/owner-policy.ts new file mode 100644 index 000000000..c5f22441f --- /dev/null +++ b/packages/relayer/src/owner-policy.ts @@ -0,0 +1,230 @@ +import { createHash } from "node:crypto"; +import { type PolicyDelegate, policyDelegateSchema } from "@near-intents-agent-api/contracts"; +import { + actions, + encodeDelegateAction, + type KeyPairString, + PublicKey, + Signature, +} from "near-api-js"; +import { sponsorTransactionStatus, transactionSucceeded } from "./confirmation.js"; +import { RelayError } from "./errors.js"; +import { awaitFinalTransaction, type NonceWitness } from "./finality.js"; +import { createNearRpcProvider } from "./near-rpc.js"; +import { sendOperationalTransaction, signOperationalTransaction } from "./operational-account.js"; +import { policyStorageDeposit } from "./policy-storage.js"; +import { assertSponsorBalance } from "./sponsor-balance.js"; + +export const ownerPolicyDelegateSchema = policyDelegateSchema; +export type OwnerPolicyDelegate = PolicyDelegate; +export type OwnerPolicySponsorConfig = { + rpcUrls: string[]; + accountId: string; + privateKey: KeyPairString; + receiverId: string; + gas: string; + storageDepositYocto: string; +}; +export type OwnerPolicySubmission = { + transactionHash: string; + finalExecutionStatus: string; +}; +export function ownerPolicyAction(input: OwnerPolicyDelegate) { + const value = ownerPolicyDelegateSchema.parse(input); + return { + senderId: value.senderId, + receiverId: value.receiverId, + publicKey: PublicKey.from(value.publicKey), + nonce: BigInt(value.nonce), + maxBlockHeight: BigInt(value.maxBlockHeight), + actions: value.actions.map((action) => + actions.functionCall( + action.methodName, + Buffer.from(action.argsBase64, "base64"), + BigInt(action.gas), + BigInt(action.depositYocto), + ), + ), + }; +} +export function ownerPolicySigningDigest(input: OwnerPolicyDelegate) { + return createHash("sha256") + .update(encodeDelegateAction(ownerPolicyAction(input))) + .digest(); +} +export function validateOwnerPolicyDelegate( + input: OwnerPolicyDelegate, + signatureHex: string, + config: Pick, +) { + const parsed = ownerPolicyDelegateSchema.parse(input); + if (parsed.receiverId !== config.receiverId) throw new Error("policy_receiver_mismatch"); + if ( + parsed.actions[0]?.methodName !== "store_wallet_policy" && + (parsed.actions.length !== 1 || parsed.actions[0]?.depositYocto !== "0") + ) + throw new Error("policy_action_invalid"); + if ( + parsed.actions + .slice(1) + .some((action) => action.methodName === "store_wallet_policy" || action.depositYocto !== "0") + ) + throw new Error("policy_action_invalid"); + const gas = parsed.actions.reduce((sum, action) => sum + BigInt(action.gas), 0n); + const deposit = parsed.actions.reduce((sum, action) => sum + BigInt(action.depositYocto), 0n); + if (gas <= 0n || gas > BigInt(config.gas) || gas > 100_000_000_000_000n) + throw new Error("policy_gas_limit"); + if (deposit > BigInt(config.storageDepositYocto) || deposit > 1_000_000_000_000_000_000_000_000n) + throw new Error("policy_deposit_limit"); + if ( + !/^[0-9a-f]{128}$/.test(signatureHex) || + !PublicKey.from(parsed.publicKey).verify( + ownerPolicySigningDigest(parsed), + Buffer.from(signatureHex, "hex"), + ) + ) + throw new Error("policy_delegate_signature_invalid"); + return parsed; +} + +/** Sponsor funds gas and bounded storage; the user's account remains predecessor/controller. */ +export function createOwnerPolicySponsor(config: OwnerPolicySponsorConfig) { + if ( + BigInt(config.gas) <= 0n || + BigInt(config.gas) > 100_000_000_000_000n || + BigInt(config.storageDepositYocto) < 0n || + BigInt(config.storageDepositYocto) > 10n ** 24n + ) + throw new Error("policy_sponsor_budget_invalid"); + const provider = createNearRpcProvider(config.rpcUrls); + return { + accountId: config.accountId, + receiverId: config.receiverId, + gas: config.gas, + storageDepositYocto: config.storageDepositYocto, + async prepare( + input: Pick, + ): Promise { + const preparedActions = []; + for (const action of input.actions) { + preparedActions.push({ + ...action, + depositYocto: + action.methodName === "store_wallet_policy" + ? await policyStorageDeposit( + provider, + config.receiverId, + input.senderId, + action.argsBase64, + config.storageDepositYocto, + true, + ) + : "0", + }); + } + const access = await provider.viewAccessKey({ + accountId: input.senderId, + publicKey: input.publicKey, + finalityQuery: { finality: "final" }, + }); + if (access.permission !== "FullAccess") throw new Error("owner_key_not_full_access"); + return ownerPolicyDelegateSchema.parse({ + ...input, + actions: preparedActions, + receiverId: config.receiverId, + nonce: (access.nonce + 1n).toString(), + maxBlockHeight: (BigInt(access.block_height) + 120n).toString(), + }); + }, + async blockHeight() { + return BigInt((await provider.viewBlock({ finality: "final" })).header.height); + }, + async status(transactionHash: string, accountId = config.accountId, witness?: NonceWitness) { + return sponsorTransactionStatus(provider, accountId, transactionHash, witness); + }, + /** + * Refuses before any effect when the sponsor cannot relay `input`. As the delegate's relayer it + * pays the inner storage deposit as well as the gas. + */ + async assertBalance(input: Pick) { + const cost = input.actions.reduce( + (total, action) => ({ + depositYocto: total.depositYocto + BigInt(action.depositYocto), + gas: total.gas + BigInt(action.gas), + }), + { depositYocto: 0n, gas: 0n }, + ); + await assertSponsorBalance(provider, config.accountId, cost); + }, + async submit( + input: OwnerPolicyDelegate, + signatureHex: string, + beforeBroadcast: (transactionHash: string, witness?: NonceWitness) => Promise, + beforeSigning?: () => Promise, + ): Promise { + validateOwnerPolicyDelegate(input, signatureHex, config); + for (const action of input.actions) { + if ( + action.methodName === "store_wallet_policy" && + action.depositYocto !== + (await policyStorageDeposit( + provider, + config.receiverId, + input.senderId, + action.argsBase64, + config.storageDepositYocto, + true, + )) + ) + throw new Error("policy_storage_changed"); + } + const access = await provider.viewAccessKey({ + accountId: input.senderId, + publicKey: input.publicKey, + finalityQuery: { finality: "final" }, + }); + if ( + access.permission !== "FullAccess" || + BigInt(input.nonce) <= access.nonce || + BigInt(input.maxBlockHeight) <= BigInt(access.block_height) + ) + throw new RelayError("policy_delegate_expired"); + // The server submits its persisted, bounded preparation. A later RPC can + // return an older final block; reapplying the 120-block horizon here rejects + // a valid freshly prepared delegate on load-balanced RPC endpoints. + await beforeSigning?.(); + // The owner's delegate carries its own nonce. The sponsor signs only one outer transaction + // here; `beforeBroadcast` records its hash before it reaches the network. + const { hash: transactionHash, result } = await sendOperationalTransaction({ + rpcUrls: config.rpcUrls, + accountId: config.accountId, + privateKey: config.privateKey, + beforeBroadcast, + prepare: async (sponsor) => { + const signed = await signOperationalTransaction(sponsor, { + receiverId: input.senderId, + actions: [ + actions.signedDelegate({ + delegateAction: ownerPolicyAction(input), + signature: new Signature({ keyType: 0, data: Buffer.from(signatureHex, "hex") }), + }), + ], + }); + return { + ...signed, + send: () => + awaitFinalTransaction({ + provider, + transactionHash: signed.hash, + senderId: config.accountId, + broadcast: () => provider.sendTransactionUntil(signed.signedTransaction, "NONE"), + }), + }; + }, + }); + if (!transactionSucceeded(result)) throw new Error("policy_transaction_failed"); + return { transactionHash, finalExecutionStatus: result.final_execution_status }; + }, + }; +} +export type OwnerPolicySponsor = ReturnType; diff --git a/packages/relayer/src/policy-storage.ts b/packages/relayer/src/policy-storage.ts new file mode 100644 index 000000000..7f5e2fc45 --- /dev/null +++ b/packages/relayer/src/policy-storage.ts @@ -0,0 +1,45 @@ +import type { JsonRpcProvider } from "near-api-js"; + +/** OutLayer refunds excess storage to the controller, not the transaction sponsor. */ +export async function policyStorageDeposit( + provider: Pick, + contractId: string, + senderId: string, + argsBase64: string, + maximum: string, + refundToController = false, +) { + const args = JSON.parse(Buffer.from(argsBase64, "base64").toString("utf8")) as { + wallet_pubkey: string; + encrypted_data: string; + }; + const previous = await provider.callFunction<{ + owner: string; + encrypted_data: string; + }>({ + contractId, + method: "get_wallet_policy", + args: { wallet_pubkey: args.wallet_pubkey }, + blockQuery: { finality: "final" }, + }); + if (previous && previous.owner !== senderId) throw new Error("policy_owner_mismatch"); + const estimate = async (encryptedData: string) => { + const amount = await provider.callFunction({ + contractId, + method: "estimate_wallet_policy_cost", + args: { wallet_pubkey: args.wallet_pubkey, encrypted_data: encryptedData }, + blockQuery: { finality: "final" }, + }); + if (typeof amount !== "string" || !/^[0-9]+$/.test(amount)) + throw new Error("policy_storage_estimate_invalid"); + return BigInt(amount); + }; + const required = await estimate(args.encrypted_data); + const existing = previous ? await estimate(previous.encrypted_data) : 0n; + // A shrinking policy refunds existing storage to the owner even with zero deposit. + // The deployed contract has no sponsor refund recipient. Do not fund the owner silently. + if (required < existing && !refundToController) throw new Error("policy_storage_refund_required"); + const deposit = required > existing ? required - existing : 0n; + if (deposit > BigInt(maximum)) throw new Error("policy_deposit_limit"); + return deposit.toString(); +} diff --git a/packages/relayer/src/schema.ts b/packages/relayer/src/schema.ts new file mode 100644 index 000000000..aef6e7a87 --- /dev/null +++ b/packages/relayer/src/schema.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +const amount = z.string().regex(/^(0|[1-9][0-9]{0,19})$/); + +export const relaySchema = z.strictObject({ + senderId: z.string().min(2).max(64), + receiverId: z.string().min(2).max(64), + publicKey: z.string().startsWith("ed25519:").max(80), + nonce: amount, + maxBlockHeight: amount, + signatureHex: z.string().regex(/^[0-9a-f]{128}$/), + actions: z + .array( + z.strictObject({ + methodName: z.string().min(1).max(128), + argsBase64: z + .string() + .max(32768) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/), + gas: amount, + depositYocto: z.literal("0"), + }), + ) + .min(1) + .max(8), + idempotencyKey: z.string().min(8).max(128), +}); + +export type RelayInput = z.infer; + +export type RelayResult = { transactionHash: string; finalExecutionStatus: string }; diff --git a/packages/relayer/src/signed-wallet.ts b/packages/relayer/src/signed-wallet.ts new file mode 100644 index 000000000..b52dd5264 --- /dev/null +++ b/packages/relayer/src/signed-wallet.ts @@ -0,0 +1,129 @@ +import type { SignedWalletRequest } from "@near-intents-agent-api/contracts"; +import { actions, type KeyPairString } from "near-api-js"; +import { + type RelayTransactionOutcome, + sponsorTransactionStatus, + transactionSucceeded, +} from "./confirmation.js"; +import { awaitFinalTransaction, type NonceWitness } from "./finality.js"; +import { createNearRpcProvider } from "./near-rpc.js"; +import { sendOperationalTransaction, signOperationalTransaction } from "./operational-account.js"; +import { addCosts, assertSponsorBalance, type SponsorCost } from "./sponsor-balance.js"; + +/** Prepaid gas for one relayed `w_execute_signed`. */ +export const SIGNED_WALLET_RELAY_GAS = 150_000_000_000_000n; + +/** Sponsor submits user-signed w_execute_signed; its key never authorizes wallet actions. */ +export function createSignedWalletRelayer(config: { + rpcUrls: string[]; + accountId: string; + privateKey: KeyPairString; +}) { + const provider = createNearRpcProvider(config.rpcUrls); + const relay = async ( + msg: SignedWalletRequest["msg"], + proof: string, + beforeBroadcast: (transactionHash: string, witness?: NonceWitness) => Promise, + ): Promise => { + const tx = { + receiverId: msg.signer_id, + actions: [ + actions.functionCall( + "w_execute_signed", + { + msg, + proof, + }, + SIGNED_WALLET_RELAY_GAS, + 0n, + ), + ], + }; + const { hash, result } = await sendOperationalTransaction({ + ...config, + beforeBroadcast, + prepare: async (account) => { + const signed = await signOperationalTransaction(account, tx); + return { + ...signed, + send: () => + awaitFinalTransaction({ + provider, + transactionHash: signed.hash, + senderId: config.accountId, + broadcast: () => provider.sendTransactionUntil(signed.signedTransaction, "NONE"), + }), + }; + }, + }); + if (!transactionSucceeded(result)) throw new Error("wallet_relay_transaction_failed"); + return { + transactionHash: hash, + finalExecutionStatus: result.final_execution_status, + status: result.status, + }; + }; + return Object.assign(relay, { + status: (transactionHash: string, accountId = config.accountId, witness?: NonceWitness) => + sponsorTransactionStatus(provider, accountId, transactionHash, witness), + /** Refuses before any effect when the sponsor cannot fund `extra` and then relay. */ + assertBalance: (extra: SponsorCost) => + assertSponsorBalance( + provider, + config.accountId, + addCosts(extra, { depositYocto: 0n, gas: SIGNED_WALLET_RELAY_GAS }), + ), + }); +} + +/** + * Exact policy-storage funding after owner signs, bounded by API configuration. `journal` receives + * the signed transfer's hash and nonce before it can be broadcast; if it throws, nothing is sent. + */ +export function createWalletPolicyFunder(config: { + rpcUrls: string[]; + accountId: string; + privateKey: KeyPairString; + beforeBroadcast?: (hash: string) => Promise; +}) { + const provider = createNearRpcProvider(config.rpcUrls); + return async ( + receiverId: string, + amount: bigint, + journal: (transactionHash: string, witness?: NonceWitness) => Promise, + ) => { + if ( + !/^(?:0s[0-9a-f]{40}|[a-z0-9]+(?:[._-][a-z0-9]+)*)$/.test(receiverId) || + receiverId.length > 64 || + amount <= 0n + ) + throw new Error("wallet_funding_invalid"); + // One signed transfer per call; an ambiguous status never authorizes another nonce. + const { hash, result } = await sendOperationalTransaction({ + ...config, + beforeBroadcast: async (transactionHash, witness) => { + await journal(transactionHash, witness); + // Re-check the sponsor lease after the awaited journal write, right before sending. + await config.beforeBroadcast?.(transactionHash); + }, + prepare: async (account) => { + const signed = await signOperationalTransaction(account, { + receiverId, + actions: [actions.transfer(amount)], + }); + return { + ...signed, + send: () => + awaitFinalTransaction({ + provider, + transactionHash: signed.hash, + senderId: config.accountId, + broadcast: () => provider.sendTransactionUntil(signed.signedTransaction, "NONE"), + }), + }; + }, + }); + if (!transactionSucceeded(result)) throw new Error("wallet_funding_failed"); + return hash; + }; +} diff --git a/packages/relayer/src/sponsor-balance.ts b/packages/relayer/src/sponsor-balance.ts new file mode 100644 index 000000000..18ab27732 --- /dev/null +++ b/packages/relayer/src/sponsor-balance.ts @@ -0,0 +1,100 @@ +import type { Action, Provider } from "near-api-js"; +import { RelayError } from "./errors.js"; + +/** Protocol storage staking price, yoctoNEAR per byte of account state. */ +const storagePricePerByte = 10n ** 19n; +/** Receipt-creation and signature fees are small next to prepaid gas; 0.001 NEAR covers them. */ +const feeMarginYocto = 10n ** 21n; +/** Used only when no endpoint reports the gas price: twice the current mainnet price. */ +const fallbackGasPrice = 2n * 10n ** 8n; +/** + * Prepaid gas is charged up front at the receipt gas price, ten times the block price (mainnet + * protocol 86: block 1e8, receipts 1e9), and the unused part is refunded after execution. A + * sponsor that holds enough for gas at the block price is still rejected by the chain, after the + * RPC answered `NONE` as accepted. + */ +const receiptGasPriceMultiplier = 10n; + +/** What one sponsored transaction can take from the sponsor: attached value and prepaid gas. */ +export type SponsorCost = { depositYocto: bigint; gas: bigint }; + +export type SponsorBalanceObservation = { + accountId: string; + availableYocto: bigint; + requiredYocto: bigint; +}; + +let observer: ((observation: SponsorBalanceObservation) => void) | undefined; + +/** Receives every balance read, so the server can export it and warn before it runs out. */ +export function observeSponsorBalance(next: typeof observer) { + observer = next; +} + +/** + * Value and prepaid gas the signer pays. The relayer of a signed delegate pays its inner actions' + * deposits as well as their gas: the chain charges them to the outer transaction's signer and + * refunds them to the delegate's sender only if the inner actions fail. + */ +export function transactionCost(actions: readonly Action[]): SponsorCost { + let depositYocto = 0n; + let gas = 0n; + for (const action of [ + ...actions, + ...actions.flatMap((action) => action.signedDelegate?.delegateAction.actions ?? []), + ]) { + if (action.transfer) depositYocto += action.transfer.deposit; + if (action.functionCall) { + depositYocto += action.functionCall.deposit; + gas += action.functionCall.gas; + } + } + return { depositYocto, gas }; +} + +export function addCosts(...costs: SponsorCost[]): SponsorCost { + return costs.reduce( + (total, cost) => ({ + depositYocto: total.depositYocto + cost.depositYocto, + gas: total.gas + cost.gas, + }), + { depositYocto: 0n, gas: 0n }, + ); +} + +type BalanceProvider = Pick; + +/** The final block header carries the gas price; some endpoints fail the `gas_price` method. */ +async function gasPrice(provider: BalanceProvider): Promise { + try { + const block = await provider.viewBlock({ finality: "final" }); + const price = BigInt(String(block.header.gas_price)); + return price > 0n ? price : fallbackGasPrice; + } catch { + return fallbackGasPrice; + } +} + +/** + * Refuses a sponsored transaction the sponsor cannot pay for, before anything is broadcast. + * + * Every public RPC answers `send_tx` with `wait_until: NONE` as accepted even when the chain will + * reject the transaction, so an underfunded sponsor otherwise shows up only as a hash that never + * appears and a finality timeout. Storage staking is locked and cannot pay for gas. + */ +export async function assertSponsorBalance( + provider: BalanceProvider, + accountId: string, + cost: SponsorCost, +): Promise { + const [account, price] = await Promise.all([ + provider.viewAccount({ accountId, blockQuery: { finality: "final" } }), + gasPrice(provider), + ]); + const availableYocto = + BigInt(account.amount) - BigInt(account.storage_usage) * storagePricePerByte; + const requiredYocto = + cost.depositYocto + cost.gas * price * receiptGasPriceMultiplier + feeMarginYocto; + observer?.({ accountId, availableYocto, requiredYocto }); + if (availableYocto < requiredYocto) throw new RelayError("sponsor_balance_insufficient"); +} diff --git a/packages/relayer/tests/owner-message.test.ts b/packages/relayer/tests/owner-message.test.ts new file mode 100644 index 000000000..ca5268486 --- /dev/null +++ b/packages/relayer/tests/owner-message.test.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { ownerNep413SigningDigest } from "@near-intents-agent-api/relayer/owner-message"; +import { describe, expect, it } from "vitest"; + +// The NEP-413 prehash: sha256(uint32le(2147484061) || len(msg) || msg || nonce || len(recipient) || recipient || 0x00) +const encodeString = (value: string) => { + const bytes = Buffer.from(value); + const length = Buffer.alloc(4); + length.writeUInt32LE(bytes.length); + return Buffer.concat([length, bytes]); +}; + +describe("vendored relayer — owner message digest", () => { + it("computes the NEP-413 digest per the reference construction", () => { + const nonce = new Uint8Array(32).fill(7); + const message = "hello agent"; + const recipient = "v1.citynode.near"; + + const expected = createHash("sha256") + .update( + Buffer.concat([ + (() => { + const prefix = Buffer.alloc(4); + prefix.writeUInt32LE(2147484061); + return prefix; + })(), + encodeString(message), + Buffer.from(nonce), + encodeString(recipient), + Buffer.from([0]), + ]), + ) + .digest(); + + const digest = ownerNep413SigningDigest({ message, recipient, nonce }); + expect(Buffer.compare(digest, expected)).toBe(0); + }); + + it("refuses a nonce that is not 32 bytes", () => { + expect(() => + ownerNep413SigningDigest({ message: "m", recipient: "r", nonce: new Uint8Array(31) }), + ).toThrow("32 bytes"); + }); +}); diff --git a/packages/relayer/tsconfig.json b/packages/relayer/tsconfig.json new file mode 100644 index 000000000..c1d8fd273 --- /dev/null +++ b/packages/relayer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"] +} diff --git a/plugins/agents/LLM.txt b/plugins/agents/LLM.txt new file mode 100644 index 000000000..40e253fe2 --- /dev/null +++ b/plugins/agents/LLM.txt @@ -0,0 +1,825 @@ +# every-plugin Template Guide + +This guide explains how to build plugins for the every-plugin framework. The template demonstrates a simple, clean pattern using plain TypeScript classes. Advanced patterns (background processing, queues, etc.) are covered at the end. + +## Architecture Overview + +Every plugin consists of three core files: + +1. **contract.ts** - Defines the plugin's public API using oRPC +2. **service.ts** - Plain class that wraps your external API +3. **index.ts** - Wires everything together with createPlugin + +``` +plugins/your-plugin/ +├── src/ +│ ├── contract.ts # API contract (procedures, schemas) +│ ├── service.ts # Service class with Effect error handling +│ ├── index.ts # Plugin implementation (createPlugin) +│ └── __tests__/ # Integration & unit tests +├── package.json +├── tsconfig.json +└── README.md +``` + +## Step-by-Step: Building a Plugin + +### Step 1: Define Your Contract (contract.ts) + +The contract defines what procedures your plugin exposes. + +```typescript +import { PluginErrors } from "every-plugin"; +import { eventIterator, oc } from "@orpc/contract"; +import { z } from "zod"; + +// Define schemas for your data types +export const ItemSchema = z.object({ + id: z.string(), + title: z.string(), + createdAt: z.string().datetime(), +}); + +// Create the contract +export const contract = oc.router({ + // Single item fetch + getById: oc + .route({ method: 'GET', path: '/items/{id}' }) + .input(z.object({ id: z.string() })) + .output(z.object({ item: ItemSchema })) + .errors(PluginErrors), + + // Server-side streaming + search: oc + .route({ method: 'GET', path: '/search' }) + .input(z.object({ + query: z.string(), + limit: z.number().default(10) + })) + .output(eventIterator(z.object({ + item: ItemSchema, + score: z.number() + }))) + .errors(PluginErrors), + + // Health check + ping: oc + .route({ method: 'GET', path: '/ping' }) + .output(z.object({ + status: z.literal('ok'), + timestamp: z.string() + })) + .errors(PluginErrors), +}); +``` + +**Key Concepts:** +- Use `z.object()` for complex types +- Use `eventIterator()` for streaming procedures +- Always include `.errors(PluginErrors)` for standard error handling +- Define meaningful HTTP methods and paths + +### Step 2: Create Your Service (service.ts) + +The service wraps your external API using a plain TypeScript class with Effect error handling: + +```typescript +import { Effect } from "effect"; +import { z } from "zod"; +import type { ItemSchema } from "./contract"; + +type Item = z.infer; + +/** + * Service that wraps your external API with Effect-based error handling. + */ +export class YourService { + constructor( + private readonly baseUrl: string, + private readonly apiKey: string, + private readonly timeout: number + ) {} + + getById(id: string) { + return Effect.tryPromise({ + try: async () => { + const response = await fetch(`${this.baseUrl}/items/${id}`, { + headers: { Authorization: `Bearer ${this.apiKey}` }, + signal: AbortSignal.timeout(this.timeout) + }); + + if (!response.ok) { + throw new Error(`Item ${id} not found`); + } + + const data = await response.json(); + return { + id: data.id, + title: data.title, + createdAt: new Date(data.created_at).toISOString(), + } satisfies Item; + }, + catch: (error: unknown) => + new Error(`Failed to fetch item: ${error instanceof Error ? error.message : String(error)}`) + }); + } + + search(query: string, limit: number) { + return Effect.gen(function* () { + // Mock streaming search results + const generator: AsyncGenerator<{ item: Item; score: number }> = (async function* () { + for (let i = 0; i < limit; i++) { + yield { + item: { + id: `${query}-${i}`, + title: `${query} result ${i + 1}`, + createdAt: new Date().toISOString(), + }, + score: Math.max(0.1, 1 - i * 0.1), + }; + } + })(); + + return generator; + }); + } + + ping() { + return Effect.tryPromise({ + try: async () => { + await fetch(`${this.baseUrl}/health`); + return { + status: "ok" as const, + timestamp: new Date().toISOString() + }; + }, + catch: (error: unknown) => + new Error(`Health check failed: ${error instanceof Error ? error.message : String(error)}`) + }); + } +} +``` + +**Key Concepts:** +- Plain class with constructor accepting config values +- Use `Effect.tryPromise` to wrap async operations +- Use `Effect.gen` for complex flows with multiple steps +- Return `AsyncGenerator` for streaming data +- Always type `catch` parameter as `unknown` and handle safely +- Use `satisfies` to ensure type safety + +### Step 3: Create the Plugin (index.ts) + +Wire everything together using `createPlugin`: + +```typescript +import { Effect } from "effect"; +import { createPlugin } from "every-plugin"; +import { z } from "zod"; +import { contract } from "./contract"; +import { YourService } from "./service"; + +export default createPlugin({ + variables: z.object({ + baseUrl: z.string().url(), + timeout: z.number().default(10000), + }), + + secrets: z.object({ + apiKey: z.string(), + }), + + contract, + + initialize: (config) => + Effect.gen(function* () { + // Create service instance with config + const service = new YourService( + config.variables.baseUrl, + config.secrets.apiKey, + config.variables.timeout + ); + + // Test the connection + yield* service.ping(); + + return { service }; + }), + + shutdown: () => Effect.void, + + createRouter: (context, builder) => { + const { service } = context; + // builder is already: implement(contract).$context() + + return { + getById: builder.getById.handler(async ({ input }) => { + const item = await Effect.runPromise(service.getById(input.id)); + return { item }; + }), + + search: builder.search.handler(async function* ({ input }) { + const generator = await Effect.runPromise( + service.search(input.query, input.limit) + ); + + for await (const result of generator) { + yield result; + } + }), + + ping: builder.ping.handler(async () => { + return await Effect.runPromise(service.ping()); + }), + }; + } +}); +``` + +**Key Concepts:** +- Use `Effect.gen` with `yield*` in initialize +- Create service with `new YourService(config...)` +- Return a context object from initialize +- Use `Effect.runPromise` to execute Effects in handlers +- Streaming handlers use `async function*` and `yield` items + +## Advanced Patterns + +The template keeps things simple. Here are patterns you can add when needed: + +### Background Processing Patterns + +Plugins often need to continuously process data or broadcast events to multiple clients. Here are the two main approaches: + +#### Event Broadcasting with MemoryPublisher (Default for Streaming) + +**When to Use**: Broadcasting events to multiple consumers (pub/sub), streaming to HTTP clients, serverless compatibility. + +MemoryPublisher provides **broadcast semantics** - all subscribers receive the same events. This is the correct pattern for plugins that mount onto HTTP servers where multiple clients stream data from the same plugin instance. + +```typescript +import { Effect } from "effect"; +import { MemoryPublisher } from "@orpc/publisher/memory"; + +// Define event channels and their payloads +type BackgroundEvents = { + 'background-updates': { + id: string; + index: number; + timestamp: number; + }; +}; + +// In initialize +initialize: (config) => + Effect.gen(function* () { + const service = new YourService(/* ... */); + + // Create publisher for broadcasting events (scoped resource) + const publisher = new MemoryPublisher({ + resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes to support resume + }); + + // Start background producer (auto-cleanup with forkScoped) + yield* Effect.forkScoped( + Effect.gen(function* () { + let i = 0; + while (true) { + i++; + // Poll external API or generate events + const event = { + id: `bg-${i}`, + index: i, + timestamp: Date.now(), + }; + + // Publish to all subscribers + yield* Effect.tryPromise(() => + publisher.publish('background-updates', event) + ).pipe( + Effect.catchAll((error) => { + console.log(`Publish failed for event ${i}:`, error); + return Effect.void; + }) + ); + + // Wait before next poll + yield* Effect.sleep("30 seconds"); + } + }) + ); + + return { service, publisher }; + }), + +// In createRouter - subscribe to broadcast stream +createRouter: (context, builder) => { + const { service, publisher } = context; + + return { + // ... other handlers + + // Background streaming with resume support and event metadata access + listenBackground: builder.listenBackground.handler(async function* ({ + input, + signal, + lastEventId + }) { + let count = 0; + const maxResults = input.maxResults; + const iterator = publisher.subscribe('background-updates', { + signal, + lastEventId + }); + + for await (const event of iterator) { + if (maxResults && count >= maxResults) break; + + // Optional: Access SSE metadata with getEventMeta(event) + // Provides: { id: string, retry: number, comments: string[] } + + yield event; + count++; + } + }), + + // Manual event publishing endpoint + enqueueBackground: builder.enqueueBackground.handler(async ({ input }) => { + const event = { + id: input.id || `manual-${Date.now()}`, + index: -1, + timestamp: Date.now(), + }; + + await publisher.publish('background-updates', event); + return { ok: true }; + }) + }; +} +``` + +**Key Benefits of MemoryPublisher:** +- **Broadcast Semantics**: All connected clients get the same events +- **Serverless Resume**: `lastEventId` + `resumeRetentionSeconds` support reconnection after timeouts +- **Effect Integration**: Uses `Effect.acquireRelease` for lifecycle management +- **Distributed Ready**: Drop-in replacement with Redis publishers for multi-instance scaling + +**Scaling to Redis:** +```typescript +// Development: MemoryPublisher (single instance) +import { MemoryPublisher } from '@orpc/publisher/memory'; +const publisher = new MemoryPublisher({ resumeRetentionSeconds: 120 }); + +// Production: IORedisPublisher (multi-instance) +import { IORedisPublisher } from '@orpc/publisher/redis'; +import Redis from 'ioredis'; +const redis = new Redis(process.env.REDIS_URL); +const publisher = new IORedisPublisher(redis, { + resumeRetentionSeconds: 120, + namespace: 'my-plugin' +}); +``` + +#### Work Distribution with Queues (Alternative Pattern) + +**When to Use**: Work distribution where each task is consumed by exactly one worker, job processing, scheduled tasks. + +Queue provides **work distribution semantics** - each item added to the queue gets consumed by one worker. Use this for background task processing, not for streaming to multiple clients. + +```typescript +import { Queue, Effect } from "effect"; + +// In initialize +initialize: (config) => + Effect.gen(function* () { + const service = new YourService(/* ... */); + + // Create queue for work distribution (scoped resource) + const queue = yield* Effect.acquireRelease( + Queue.bounded(1000), + (q) => Queue.shutdown(q) + ); + + // Start background worker (one consumer) + yield* Effect.forkScoped( + Effect.gen(function* () { + while (true) { + // Take one item from queue (work distribution) + const task = yield* Queue.take(queue); + + // Process task (exactly once semantics) + yield* service.processTask(task); + + yield* Effect.yieldNow(); // Allow interruption + } + }) + ); + + return { service, queue }; + }), + +// In createRouter - enqueue work, not streaming +const enqueueTask = builder.enqueueTask.handler(async ({ input }) => { + await Effect.runPromise(Queue.offer(context.queue, input.task)); + return { queued: true }; +}); +``` + +**Key Differences:** +- **MemoryPublisher**: Broadcast all events to all subscribers (streaming pub/sub) +- **Queue**: Distribute work - each item processed by one worker (job processing) + +**Important:** +- Use `Effect.acquireRelease` for queue cleanup +- Use `Effect.forkScoped` for background tasks (ensures cleanup) +- Scoped resources are automatically cleaned up on shutdown + +### Error Handling + +Transform external errors to PluginErrors: + +```typescript +import { PluginConfigurationError } from "every-plugin"; + +getById(id: string) { + return Effect.tryPromise({ + try: async () => { + const response = await fetch(`${this.baseUrl}/items/${id}`); + + if (response.status === 401) { + throw new PluginConfigurationError({ + message: "Invalid API credentials", + retryable: false + }); + } + + if (response.status === 429) { + throw new Error("Rate limited"); + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); + }, + catch: (error: unknown) => { + // Re-throw configuration errors + if (error instanceof PluginConfigurationError) throw error; + return new Error(`Fetch failed: ${error instanceof Error ? error.message : String(error)}`); + } + }); +} + +// In createRouter - map to PluginErrors +const getById = builder.getById.handler(async ({ input, errors }) => { + try { + const item = await Effect.runPromise(service.getById(input.id)); + return { item }; + } catch (error) { + if (error instanceof PluginConfigurationError) { + throw errors.UNAUTHORIZED({ + message: error.message, + data: { apiKeyProvided: true } + }); + } + + if (error instanceof Error && error.message.includes('Rate limited')) { + throw errors.RATE_LIMITED({ + message: "API rate limit exceeded", + data: { retryAfter: 60, limitType: 'requests' as const } + }); + } + + throw errors.SERVICE_UNAVAILABLE({ + message: error instanceof Error ? error.message : 'Unknown error', + data: { retryAfter: 30 } + }); + } +}); +``` + +### Pagination with Streaming + +For APIs with cursor-based pagination: + +```typescript +search(params: { query: string; limit: number; cursor?: string }) { + return Effect.gen(function* () { + const generator: AsyncGenerator = (async function* () { + let cursor = params.cursor; + let fetched = 0; + + while (fetched < params.limit) { + const response = await fetch( + `${this.baseUrl}/search?q=${params.query}&cursor=${cursor || ''}` + ); + const data = await response.json(); + + for (const item of data.items) { + if (fetched >= params.limit) break; + yield transformItem(item); + fetched++; + } + + if (!data.next_cursor) break; + cursor = data.next_cursor; + } + })(); + + return generator; + }); +} +``` + +### Webhook Mode + +For plugins that can operate via webhooks: + +```typescript +// In contract.ts +webhook: oc + .route({ method: 'POST', path: '/webhook' }) + .input(WebhookPayloadSchema) + .output(z.object({ processed: z.boolean() })) + .errors(PluginErrors), + +// In initialize - create queue for webhook events +const webhookQueue = yield* Effect.acquireRelease( + Queue.bounded(1000), + (q) => Queue.shutdown(q) +); + +// In createRouter +const webhook = builder.webhook.handler(async ({ input }) => { + await Effect.runPromise( + Queue.offer(context.webhookQueue, transformWebhookData(input)) + ); + return { processed: true }; +}); + +const stream = builder.stream.handler(async function* ({ input }) => { + while (true) { + const event = await Effect.runPromise(Queue.take(context.webhookQueue)); + yield event; + } +}); +``` + +## Best Practices + +### Type Safety +- Always use `z.infer` for types +- Use `satisfies` to check object literals match schemas +- Type `catch` parameters as `unknown` and handle safely + +### Resource Management +- Use `Effect.acquireRelease` for resources that need cleanup +- Use `Effect.forkScoped` for background tasks (auto-cleanup) +- Use `Queue.bounded` instead of unbounded queues +- Return context from initialize with all resources + +### Error Handling +- Always wrap external calls in `Effect.tryPromise` +- Provide meaningful error messages with context +- Use `PluginErrors` in handlers for standard errors +- Re-throw configuration errors after catching + +### Testing +- Use module entries in registry for testing (`{ module: Plugin }`) +- Mock external APIs with MSW or similar +- Test error cases and edge conditions +- Test streaming procedures thoroughly + +### Performance +- Use `Promise.all` for parallel operations +- Implement pagination for large result sets +- Add appropriate timeouts to external calls +- Use streaming for large datasets + +### Security +- Never log secrets or sensitive data +- Validate all external inputs with Zod schemas +- Use HTTPS for external API calls +- Sanitize error messages +- Use template injection for secrets ({{SECRET_NAME}}) + +## Quick Reference + +### Minimal Plugin Structure + +```typescript +// contract.ts +export const contract = oc.router({ + getData: oc.route({ method: 'GET', path: '/data' }) + .input(z.object({ id: z.string() })) + .output(z.object({ data: DataSchema })) + .errors(PluginErrors) +}); + +// service.ts +export class MyService { + constructor(private apiKey: string, private baseUrl: string) {} + + getData(id: string) { + return Effect.tryPromise({ + try: () => fetch(`${this.baseUrl}/data/${id}`).then(r => r.json()), + catch: (e: unknown) => new Error(`Failed: ${e}`) + }); + } +} + +// index.ts +export default createPlugin({ + variables: z.object({ apiUrl: z.string() }), + secrets: z.object({ apiKey: z.string() }), + contract, + initialize: (config) => Effect.gen(function* () { + const service = new MyService(config.secrets.apiKey, config.variables.apiUrl); + return { service }; + }), + shutdown: () => Effect.void, + createRouter: (ctx, builder) => { + // builder is already: implement(contract).$context() + return { + getData: builder.getData.handler(async ({ input }) => { + const data = await Effect.runPromise(ctx.service.getData(input.id)); + return { data }; + }) + }; + } +}); +``` + +## Documentation & Deployment + +After building your plugin, document it well and deploy it so others can use it. + +### Writing Good READMEs + +Your plugin should have a user-friendly README and the technical LLM.txt (this file) for builders. + +**README.md Structure:** +- **Title and Description** - What your plugin does, who it's for +- **Installation** - How to add to registry +- **Usage Examples** - Copy-paste ready code for common use cases +- **Configuration** - What variables/secrets are needed +- **Link to LLM.txt** - Point developers to this detailed guide + +Example README structure: +```markdown +# My Plugin + +A plugin for connecting to [External Service] APIs. + +## Installation + +Add to your registry: +```javascript +const registry = { + "my-org/my-plugin": { + remote: "https://cdn.example.com/plugins/my-plugin/remoteEntry.js" + } +}; +``` + +## Usage + +```typescript +const { client } = await runtime.usePlugin("my-org/my-plugin", { + variables: { apiUrl: "https://api.external.com" }, + secrets: { apiKey: "{{API_KEY}}" } +}); + +// Single query +const data = await client.getData({ id: "123" }); + +// Streaming +for await (const item of client.streamData({ query: "search" })) { + console.log(item); +} +``` + +## Configuration + +Required secrets: +- `API_KEY` - Your external service API key + +Optional variables: +- `timeout` - Request timeout in milliseconds (default: 10000) + +--- + +**For developers building integrations, see [LLM.txt](./LLM.txt).** +``` + +### Build Process + +Use the included rspack config for Module Federation builds: + +```bash +# Development server with hot reload +npm run dev # Serves at http://localhost:3001 + +# Production build +npm run build # Creates dist/ with remoteEntry.js +``` + +The build config handles: +- **Module Federation** - Shares dependencies to reduce bundle size +- **Zephyr Integration** - For easy CDN deployment +- **Source Maps** - For debugging remote plugins + +### Registry Configuration + +Deploy and register your plugin for others to use. + +**Local Development:** +For testing during development - no deployment needed: + +```typescript +import { createPluginRuntime } from "every-plugin/runtime"; +import MyPlugin from "./src/index"; + +const runtime = createPluginRuntime({ + registry: { + "my-plugin": { module: MyPlugin } // Direct import with automatic type inference + } +}); +``` + +**Remote Production:** +Deploy to a CDN/hosting service, then register: + +```javascript +// Method 1: Dev server (localhost testing) +const runtime = createPluginRuntime({ + registry: { + "my-org/my-plugin": { + remote: "http://localhost:3001/remoteEntry.js" + } + } +}); + +// Method 2: Remote CDN (after deployment) +const runtime = createPluginRuntime({ + registry: { + "my-org/my-plugin": { + remote: "https://cdn.zephyr.com/v123/plugins/my-plugin/remoteEntry.js" + } + } +}); + +// Method 3: Hybrid (module during dev, remote in production) +import MyPlugin from "./src/index"; + +const runtime = createPluginRuntime({ + registry: { + "my-org/my-plugin": { + module: process.env.NODE_ENV === "development" ? MyPlugin : undefined, + remote: "https://cdn.zephyr.com/v123/plugins/my-plugin/remoteEntry.js" + } + } +}); + +// Same usage API for all approaches! +const { client } = await runtime.usePlugin("my-org/my-plugin", { + variables: { timeout: 30000 }, + secrets: { apiKey: "{{PROD_API_KEY}}" } // Template injection +}); +``` + +**Deployment Options:** +1. **Zephyr** - Automatic CDN deployment (`npm run deploy`) +2. **Manual Upload** - Build then upload `dist/remoteEntry.js` to CDN +3. **Self-hosted** - Serve from your own infrastructure + +Once deployed, share the `remote` URL so others can add to their registries. + +## Common Pitfalls + +1. **Don't return raw Effects from handlers** - use `Effect.runPromise` +2. **Use async function* for streaming** - not generators that return Effects +3. **Type catch parameters as unknown** - then check instanceof Error +4. **Use yield* in Effect.gen** - not regular yield +5. **Return context from initialize** - don't store globally +6. **Use Effect.forkScoped for background tasks** - ensures cleanup +7. **Use Effect.acquireRelease for resources** - ensures cleanup on shutdown + +## Next Steps + +1. Copy the template to your new plugin directory +2. Update `contract.ts` with your API procedures +3. Implement `service.ts` with your external API client +4. Wire everything in `index.ts` +5. Add tests in `__tests__/` +6. Update `package.json` with dependencies +7. Build with `npm run build` +8. Test with module entries (`{ module: MyPlugin }`) + +## Related Examples + +- **[telegram](../telegram/)** - Webhook + polling modes, background processing +- **[gopher-ai](../gopher-ai/)** - LLM API integration +- **[test-plugin](../../packages/every-plugin/__tests__/test-plugin/)** - Testing patterns +- **[patterns.mdx](../../apps/docs/content/docs/plugins/patterns.mdx)** - More patterns and examples diff --git a/plugins/agents/README.md b/plugins/agents/README.md new file mode 100644 index 000000000..bc0323ab1 --- /dev/null +++ b/plugins/agents/README.md @@ -0,0 +1,117 @@ +# Template Plugin + +Minimal starter for an `every-plugin` plugin that works both on its own and inside `bos dev`. + +## Included + +```bash +src/ +├── contract.ts +├── service.ts +├── index.ts +└── LLM.txt +plugin.dev.ts +rspack.config.cjs +tests/ +``` + +## Standalone Dev + +```bash +cd plugins/my-plugin +bun install +bun run dev +``` + +The plugin dev server prefers `PORT` from the environment. If none is provided, `plugin.dev.ts` supplies a local default. + +## Composed Dev With `bos dev` + +Add the plugin to the root `bos.config.json`: + +```json +{ + "plugins": { + "my-plugin": { + "development": "local:plugins/my-plugin" + } + } +} +``` + +Then run: + +```bash +bos dev +``` + +`everything-dev` will: + +1. detect `local:plugins/my-plugin` +2. choose an available localhost port +3. spawn the plugin with `PORT=` +4. load it into the host and stitch it into `/api` +5. shut it down when the dev session ends + +If you already run the plugin yourself, use a URL instead: + +```json +{ + "plugins": { + "my-plugin": { + "development": "http://localhost:3021" + } + } +} +``` + +That tells the host to load the plugin from that URL without spawning it. + +## Root Local Targets + +The workspace now uses the same pattern for built-ins: + +```json +{ + "app": { + "ui": { "development": "local:ui" }, + "api": { "development": "local:api" } + } +} +``` + +## Runtime Example + +```ts +import { createPluginRuntime } from "every-plugin"; + +const runtime = createPluginRuntime({ + registry: { + "my-plugin": { + remote: "http://localhost:3021/remoteEntry.js", + }, + }, + secrets: {}, +}); + +const { createClient } = await runtime.usePlugin("my-plugin", { + variables: { baseUrl: "https://api.example.com", timeout: 10000 }, + secrets: { apiKey: "your-key" }, +}); + +const client = createClient(); +await client.ping(); +``` + +## Build Checklist + +1. Update `src/contract.ts` +2. Update `src/service.ts` +3. Update `src/index.ts` +4. Update `package.json` name +5. Update `plugin.dev.ts` config +6. Add the plugin to the root `bos.config.json` if you want composed dev + +## Docs + +See `LLM.txt` for the longer implementation guide. diff --git a/plugins/agents/drizzle.config.ts b/plugins/agents/drizzle.config.ts new file mode 100644 index 000000000..7c13ac9d5 --- /dev/null +++ b/plugins/agents/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/agents/package.json b/plugins/agents/package.json new file mode 100644 index 000000000..46a2373bf --- /dev/null +++ b/plugins/agents/package.json @@ -0,0 +1,83 @@ +{ + "name": "@everything-dev/agents-plugin", + "version": "0.1.0", + "description": "NEAR Agent API control plane \u2014 custody agents, policies, grants, intents", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./contract": { + "types": "./src/contract.ts", + "default": "./src/contract.ts" + } + }, + "peerDependencies": { + "typescript": "catalog:" + }, + "dependencies": { + "@near-intents-agent-api/agents-core": "workspace:*", + "@near-intents-agent-api/contracts": "workspace:*", + "@near-intents-agent-api/database": "workspace:*", + "@near-intents-agent-api/outlayer": "workspace:*", + "@orpc/contract": "catalog:", + "@orpc/server": "catalog:", + "drizzle-orm": "catalog:", + "pg": "catalog:", + "@electric-sql/pglite": "catalog:", + "effect": "catalog:", + "zod": "catalog:", + "@base-ui/react": "^1.8.0", + "@orpc/tanstack-query": "catalog:", + "@phosphor-icons/react": "^2.1.10", + "@tanstack/react-query": "catalog:", + "@tanstack/react-router": "catalog:", + "class-variance-authority": "^0.7.1", + "cn": "^0.4.0", + "every-plugin": "catalog:", + "everything-dev": "catalog:", + "framer-motion": "^12.37.0", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7", + "tw-animate-css": "^1.4.0" + }, + "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": "catalog:", + "every-plugin": "catalog:", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "catalog:", + "@module-federation/enhanced": "catalog:", + "@module-federation/rsbuild-plugin": "catalog:", + "@rsbuild/core": "catalog:", + "@rsbuild/plugin-react": "catalog:", + "@tailwindcss/postcss": "^4.1.17", + "@tanstack/router-plugin": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "tailwindcss": "^4.1.17", + "shadcn": "^4.21.0" + }, + "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", + "typecheck:ui": "tsc --noEmit -p ui/tsconfig.json" + } +} diff --git a/plugins/agents/plugin.dev.ts b/plugins/agents/plugin.dev.ts new file mode 100644 index 000000000..b84b3920a --- /dev/null +++ b/plugins/agents/plugin.dev.ts @@ -0,0 +1,35 @@ +import "dotenv/config"; +import type { PluginConfigInput } from "every-plugin"; +import packageJson from "./package.json" with { type: "json" }; +import type Plugin from "./src/index"; + +export default { + pluginId: packageJson.name, + port: Number(process.env.PORT) || 3016, + config: { + variables: { + agentsNearRpcUrls: + process.env.AGENTS_NEAR_RPC_URLS || "https://near.drpc.org,https://free.rpc.fastnear.com", + agentsTrustedOrigins: process.env.AGENTS_TRUSTED_ORIGINS || "https://citynode.app", + agentsServiceUrl: process.env.AGENTS_SERVICE_URL || "https://agents.local", + agentsSponsorDailyGlobalLimit: Number(process.env.AGENTS_SPONSOR_DAILY_GLOBAL_LIMIT) || 100, + agentsSponsorDailyTenantLimit: Number(process.env.AGENTS_SPONSOR_DAILY_TENANT_LIMIT) || 20, + agentsSponsorDailyAgentLimit: Number(process.env.AGENTS_SPONSOR_DAILY_AGENT_LIMIT) || 10, + }, + secrets: { + AGENTS_DATABASE_URL: process.env.AGENTS_DATABASE_URL || "pglite:.bos/agents/:memory:", + ...(process.env.AGENTS_SPONSOR_KEYS + ? { AGENTS_SPONSOR_KEYS: process.env.AGENTS_SPONSOR_KEYS } + : {}), + ...(process.env.AGENTS_SECRET_ENCRYPTION_KEYS + ? { AGENTS_SECRET_ENCRYPTION_KEYS: process.env.AGENTS_SECRET_ENCRYPTION_KEYS } + : {}), + ...(process.env.AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID + ? { + AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID: + process.env.AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID, + } + : {}), + }, + } satisfies PluginConfigInput, +}; diff --git a/plugins/agents/src/actor.ts b/plugins/agents/src/actor.ts new file mode 100644 index 000000000..2cb53a31d --- /dev/null +++ b/plugins/agents/src/actor.ts @@ -0,0 +1,35 @@ +import type { Actor } from "@near-intents-agent-api/agents-core"; +import { apiKeys, tenants, user } from "@near-intents-agent-api/database/schema"; +import { sql } from "drizzle-orm"; +import type { DatabaseDriver } from "./db"; + +export async function actorForSession( + database: DatabaseDriver, + userId: string, + keyName = "session", +): Promise { + const keyId = `${userId}:${keyName}`; + await database.db + .insert(user) + .values({ id: userId, name: userId, email: `${userId}@agents.local` }) + .onConflictDoNothing(); + await database.db + .insert(tenants) + .values({ id: userId, ownerUserId: userId }) + .onConflictDoNothing(); + await database.db + .insert(apiKeys) + .values({ + id: keyId, + tenantId: userId, + name: keyName, + tokenHash: keyId, + prefix: keyId.slice(0, 8), + expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000), + }) + .onConflictDoUpdate({ + target: apiKeys.id, + set: { expiresAt: sql`now() + interval '30 days'` }, + }); + return { tenantId: userId, keyId }; +} diff --git a/plugins/agents/src/context.ts b/plugins/agents/src/context.ts new file mode 100644 index 000000000..edb7eba44 --- /dev/null +++ b/plugins/agents/src/context.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const ContextSchema = z.custom<{ + userId?: string; + reqHeaders?: Headers; +}>(); diff --git a/plugins/agents/src/contract.ts b/plugins/agents/src/contract.ts new file mode 100644 index 000000000..031cda443 --- /dev/null +++ b/plugins/agents/src/contract.ts @@ -0,0 +1,290 @@ +import "@orpc/openapi/extensions/route"; +import type { + GenerateIntentRequest, + SubmitIntentRequest, +} from "@near-intents-agent-api/contracts/api"; +import { + acknowledgementSchema, + agentListQuerySchema, + agentPageSchema, + agentViewSchema, + balanceMoveRequestSchema, + balanceQuerySchema, + balancesViewSchema, + budgetViewSchema, + depositRequestSchema, + generateIntentRequestSchema, + generateIntentResponseSchema, + grantListViewSchema, + historyPageSchema, + historyQuerySchema, + limitsViewSchema, + policyHistoryQuerySchema, + policyHistoryViewSchema, + policyViewSchema, + quoteResponseSchema, + recoverRequestSchema, + scheduledPageSchema, + scheduledQuerySchema, + signatureDeliverySchema, + signMessageRequestSchema, + statusResponseSchema, + submitIntentRequestSchema, + swapRequestSchema, + timelockViewSchema, + tokenListViewSchema, + transferRequestSchema, + walletViewSchema, + withdrawRequestSchema, +} from "@near-intents-agent-api/contracts/api"; +import { oc } from "@orpc/contract"; +import { UNAUTHORIZED as UnauthorizedError } from "every-plugin/errors"; +import { z } from "zod"; + +const pingInputSchema = z.object({ + message: z.string().optional(), +}); + +const pingOutputSchema = z.object({ + message: z.string(), + timestamp: z.string(), +}); + +const submitIntentOutputSchema = statusResponseSchema; + +/** Upstream wire shape + the plugin's replay signal (strict superset). */ +const generateIntentOutputSchema = generateIntentResponseSchema.extend({ + replayed: z.boolean(), +}); + +export const contract = { + ping: oc + .route({ + method: "POST", + path: "/ping", + summary: "Health check", + }) + .input(pingInputSchema) + .output(pingOutputSchema), + + dbHealth: oc + .route({ + method: "POST", + path: "/db-health", + summary: "Database connectivity and migration health", + }) + .input(z.object({})) + .output( + z.object({ + ok: z.boolean(), + agentCount: z.number(), + }), + ), + + generateIntent: oc + .route({ + method: "POST", + path: "/generate-intent", + summary: + "Build the owner-signed intent for one owner action (agent_create, policy_update, grants, …)", + }) + .input(generateIntentRequestSchema) + .output(generateIntentOutputSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + submitIntent: oc + .route({ + method: "POST", + path: "/submit-intent", + summary: "Apply the wallet's signature to a generated intent", + }) + .input(submitIntentRequestSchema) + .output(submitIntentOutputSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + intentStatus: oc + .route({ + method: "POST", + path: "/status", + summary: "Status of an intent or execution, long-polling until terminal", + }) + .input( + z.object({ + correlationId: z.string(), + waitMs: z.number().int().min(0).max(30_000).default(0), + }), + ) + .output(statusResponseSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + listAgents: oc + .route({ method: "POST", path: "/agents", summary: "Agents bound to the session's owner" }) + .input(agentListQuerySchema) + .output(agentPageSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getAgent: oc + .route({ method: "POST", path: "/agents/get", summary: "One agent's view" }) + .input(z.object({ agentId: z.string() })) + .output(agentViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getWallet: oc + .route({ method: "POST", path: "/agents/wallet", summary: "Custody wallet identity" }) + .input(z.object({ agentId: z.string() })) + .output(walletViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getBalances: oc + .route({ method: "POST", path: "/agents/balances", summary: "Balances for one agent" }) + .input(balanceQuerySchema.and(z.object({ agentId: z.string() }))) + .output(balancesViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getPolicy: oc + .route({ method: "POST", path: "/agents/policy", summary: "Current policy state" }) + .input(z.object({ agentId: z.string() })) + .output(policyViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getPolicyHistory: oc + .route({ method: "POST", path: "/agents/policy-history", summary: "Policy revision history" }) + .input(policyHistoryQuerySchema.and(z.object({ agentId: z.string() }))) + .output(policyHistoryViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getLimits: oc + .route({ method: "POST", path: "/agents/limits", summary: "Sponsor and policy limits" }) + .input(z.object({ agentId: z.string() })) + .output(limitsViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getBudget: oc + .route({ method: "POST", path: "/agents/budget", summary: "USD budget window" }) + .input(z.object({ agentId: z.string() })) + .output(budgetViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getTimelock: oc + .route({ method: "POST", path: "/agents/timelock", summary: "Timelock configuration" }) + .input(z.object({ agentId: z.string() })) + .output(timelockViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + listScheduledExecutions: oc + .route({ method: "POST", path: "/agents/scheduled", summary: "Scheduled executions" }) + .input(scheduledQuerySchema.and(z.object({ agentId: z.string() }))) + .output(scheduledPageSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getTokenCatalog: oc + .route({ method: "POST", path: "/tokens", summary: "Provider token catalog" }) + .input(z.object({})) + .output(tokenListViewSchema), + + getHistory: oc + .route({ method: "POST", path: "/agents/history", summary: "Intent and operation history" }) + .input(historyQuerySchema.and(z.object({ agentId: z.string() }))) + .output(historyPageSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + listGrants: oc + .route({ method: "POST", path: "/agents/grants", summary: "Grants issued for one agent" }) + .input(z.object({ agentId: z.string() })) + .output(grantListViewSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + swap: oc + .route({ method: "POST", path: "/agents/swap", summary: "Swap under a grant (dry = quote)" }) + .input(swapRequestSchema.and(z.object({ agentId: z.string() }))) + .output(z.union([quoteResponseSchema, statusResponseSchema])) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + withdraw: oc + .route({ + method: "POST", + path: "/agents/withdraw", + summary: "Withdraw under a grant (dry = quote)", + }) + .input(withdrawRequestSchema.and(z.object({ agentId: z.string() }))) + .output(z.union([quoteResponseSchema, statusResponseSchema])) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + transfer: oc + .route({ method: "POST", path: "/agents/transfer", summary: "Transfer under a grant" }) + .input(transferRequestSchema.and(z.object({ agentId: z.string() }))) + .output(statusResponseSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + shield: oc + .route({ method: "POST", path: "/agents/shield", summary: "Shield into confidential balance" }) + .input(balanceMoveRequestSchema.and(z.object({ agentId: z.string() }))) + .output(statusResponseSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + unshield: oc + .route({ + method: "POST", + path: "/agents/unshield", + summary: "Unshield from confidential balance", + }) + .input(balanceMoveRequestSchema.and(z.object({ agentId: z.string() }))) + .output(statusResponseSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + confidentialDeposit: oc + .route({ + method: "POST", + path: "/agents/confidential-deposit", + summary: "Confidential deposit", + }) + .input(balanceMoveRequestSchema.and(z.object({ agentId: z.string() }))) + .output(statusResponseSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + deposit: oc + .route({ method: "POST", path: "/agents/deposit", summary: "Cross-chain deposit" }) + .input(depositRequestSchema.and(z.object({ agentId: z.string() }))) + .output(statusResponseSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + recover: oc + .route({ method: "POST", path: "/agents/recover", summary: "Recover an interrupted execution" }) + .input(recoverRequestSchema.and(z.object({ agentId: z.string() }))) + .output(statusResponseSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + signMessage: oc + .route({ + method: "POST", + path: "/agents/sign-message", + summary: "Detached signature under a sign-grant (NEP-413 / EIP-191 identity challenge)", + }) + .input(signMessageRequestSchema.and(z.object({ agentId: z.string() }))) + .output(statusResponseSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + getSignature: oc + .route({ + method: "POST", + path: "/agents/signatures", + summary: "Read a completed signature once, under the grant that authorized it", + }) + .input(z.object({ agentId: z.string(), correlationId: z.string() })) + .output(signatureDeliverySchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), + + acknowledgeSignature: oc + .route({ + method: "POST", + path: "/agents/signatures/ack", + summary: "Erase a delivered signature from the server", + }) + .input(z.object({ agentId: z.string(), correlationId: z.string() })) + .output(acknowledgementSchema) + .errors({ UNAUTHORIZED: UnauthorizedError }), +}; + +export type GenerateIntentInput = GenerateIntentRequest; +export type SubmitIntentInput = SubmitIntentRequest; +export type ContractType = typeof contract; diff --git a/plugins/agents/src/db/index.ts b/plugins/agents/src/db/index.ts new file mode 100644 index 000000000..898fe9e6d --- /dev/null +++ b/plugins/agents/src/db/index.ts @@ -0,0 +1,41 @@ +import { PGlite } from "@electric-sql/pglite"; +import type { DatabaseHandle } from "@near-intents-agent-api/database"; +import { createDatabase } from "@near-intents-agent-api/database"; +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { schema } from "./schema"; + +export type Database = DatabaseHandle["db"]; + +export { DatabaseError, unwrapDatabaseError } from "everything-dev/db"; + +export interface DatabaseDriver { + readonly db: Database; + readonly withAdvisoryLock: DatabaseHandle["withAdvisoryLock"]; + readonly tryAdvisoryLock?: DatabaseHandle["tryAdvisoryLock"]; + close(): Promise; +} + +export async function createDatabaseDriver(url: string): Promise { + if (url.startsWith("pglite:") || url.includes(":memory:")) { + const client = new PGlite(); + const db = drizzle(client, { schema }); + return { + db: db as unknown as DatabaseHandle["db"], + withAdvisoryLock: async (_key, run) => run(), + close: () => client.close(), + }; + } + + const handle = createDatabase(url, { max: 10, connectionTimeoutMillis: 10_000 }); + return { + db: handle.db, + withAdvisoryLock: handle.withAdvisoryLock, + tryAdvisoryLock: handle.tryAdvisoryLock, + close: () => handle.close(), + }; +} + +export async function ensureJournalSchema(driver: DatabaseDriver): Promise { + await driver.db.execute(sql`CREATE SCHEMA IF NOT EXISTS "drizzle"`); +} diff --git a/plugins/agents/src/db/layer.ts b/plugins/agents/src/db/layer.ts new file mode 100644 index 000000000..3b6b722a6 --- /dev/null +++ b/plugins/agents/src/db/layer.ts @@ -0,0 +1,70 @@ +import { Context, Effect, Layer } from "effect"; +import { PluginIdTag } from "every-plugin"; +import { getMigrationStorage, pluginMigrationSlug } from "everything-dev/db"; +import { + createDatabaseDriver, + type DatabaseDriver, + DatabaseError, + ensureJournalSchema, +} 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 storage = getMigrationStorage(slug); + + const driver = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => createDatabaseDriver(url), + catch: (cause) => new DatabaseError({ stage: "driver", cause }), + }), + (driver) => + Effect.tryPromise({ + try: () => driver.close(), + catch: (cause) => new DatabaseError({ stage: "close", cause }), + }).pipe(Effect.ignore), + ); + + yield* Effect.tryPromise({ + try: () => ensureJournalSchema(driver), + catch: (cause) => new DatabaseError({ stage: "migration", cause }), + }); + + 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); + + 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})`, + ); + } + + const drift = yield* detectDrift(driver.db, migrations, storage); + if (drift.status === "healthy" || drift.status === "untracked-existing-schema") { + yield* Effect.logInfo(`[Database] Ready`); + } else { + yield* Effect.logWarning( + `[Database] ⚠️ Migration drift detected (${drift.status}) — run \`bos db doctor ${storage.slug}\`.`, + ); + } + } + + return driver; + }), + ); diff --git a/plugins/agents/src/db/migrate.ts b/plugins/agents/src/db/migrate.ts new file mode 100644 index 000000000..b7b9a21c7 --- /dev/null +++ b/plugins/agents/src/db/migrate.ts @@ -0,0 +1,45 @@ +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 function loadMigrations(): Effect.Effect { + return 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/agents/src/db/migrations/0000_initial_schema.sql b/plugins/agents/src/db/migrations/0000_initial_schema.sql new file mode 100644 index 000000000..910b72d90 --- /dev/null +++ b/plugins/agents/src/db/migrations/0000_initial_schema.sql @@ -0,0 +1,400 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "accountId" text NOT NULL, + "providerId" text NOT NULL, + "userId" text NOT NULL, + "accessToken" text, + "refreshToken" text, + "idToken" text, + "accessTokenExpiresAt" timestamp with time zone, + "refreshTokenExpiresAt" timestamp with time zone, + "scope" text, + "password" text, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agent_grants" ( + "id" text NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "wallet_id" text NOT NULL, + "label" text NOT NULL, + "credential_hash" text NOT NULL, + "actions" text[] NOT NULL, + "recipients" text[] NOT NULL, + "owner_epoch" bigint NOT NULL, + "issued_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "revoked_reason" text, + "owner_message" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agent_grants_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id") +); +--> statement-breakpoint +CREATE TABLE "agents" ( + "id" text NOT NULL, + "tenant_id" text NOT NULL, + "name" text NOT NULL, + "external_user_id" text, + "owner_identity" jsonb, + "owner_near" jsonb, + "owner_counter" bigint DEFAULT 0 NOT NULL, + "owner_account_id" text, + "owner_public_key" text, + "owner_epoch" bigint DEFAULT 1 NOT NULL, + "timelock_delay_seconds" integer DEFAULT 0 NOT NULL, + "timelock_revision" integer DEFAULT 0 NOT NULL, + "budget_daily_cents" bigint, + "budget_weekly_cents" bigint, + "budget_monthly_cents" bigint, + "budget_revision" integer DEFAULT 0 NOT NULL, + "policy_epoch" bigint DEFAULT 1 NOT NULL, + "lifecycle_epoch" bigint DEFAULT 1 NOT NULL, + "lifecycle" text DEFAULT 'pending' NOT NULL, + "archived_at" timestamp with time zone, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agents_tenant_id_id_pk" PRIMARY KEY("tenant_id","id"), + CONSTRAINT "agents_lifecycle_check" CHECK ("agents"."lifecycle" IN ('pending', 'active', 'archived', 'deleted', 'abandoned')) +); +--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "name" text NOT NULL, + "token_hash" text NOT NULL, + "prefix" 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, + CONSTRAINT "api_keys_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "audit_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text, + "action" text NOT NULL, + "resource_id" text NOT NULL, + "actor_key_id" text, + "actor_owner_id" text, + "grant_id" text, + "owner_epoch" bigint, + "policy_epoch" bigint, + "lifecycle_epoch" bigint, + "request_hash" text, + "provider_reference" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "audit_retention_holds" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text, + "reason" text NOT NULL, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "custody_wallets" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "provider_wallet_id" text NOT NULL, + "near_account_id" text NOT NULL, + "evm_address" text NOT NULL, + "credential_ciphertext" text NOT NULL, + "credential_nonce" text NOT NULL, + "credential_key_id" text NOT NULL, + "status" text NOT NULL, + "failure_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "custody_wallets_agent" UNIQUE("tenant_id","agent_id"), + CONSTRAINT "custody_wallets_status_check" CHECK ("custody_wallets"."status" IN ('provisioning', 'active', 'failed', 'deleted')) +); +--> statement-breakpoint +CREATE TABLE "delayed_executions" ( + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "id" text NOT NULL, + "grant_id" text NOT NULL, + "owner_epoch" bigint NOT NULL, + "execute_after" timestamp with time zone NOT NULL, + "state" text DEFAULT 'waiting' NOT NULL, + "ciphertext" text, + "nonce" text, + "key_id" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "delayed_executions_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id"), + CONSTRAINT "delayed_executions_state" CHECK ("delayed_executions"."state" IN ('waiting', 'dispatching', 'finished', 'cancelled', 'uncertain')) +); +--> statement-breakpoint +CREATE TABLE "intent_generations" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "idempotency_key" text, + "request_hash" text NOT NULL, + "agent_id" text NOT NULL, + "state" text DEFAULT 'reserved' NOT NULL, + "draft" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "intent_generations_idempotency" UNIQUE("tenant_id","idempotency_key"), + CONSTRAINT "intent_generations_state_check" CHECK ("intent_generations"."state" IN ('reserved', 'building', 'ready')) +); +--> statement-breakpoint +CREATE TABLE "operation_artifacts" ( + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "operation_id" text NOT NULL, + "grant_id" text NOT NULL, + "owner_epoch" bigint NOT NULL, + "action" text NOT NULL, + "ciphertext" text, + "key_id" text, + "nonce" text, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "operation_artifacts_tenant_id_agent_id_operation_id_pk" PRIMARY KEY("tenant_id","agent_id","operation_id"), + CONSTRAINT "operation_artifacts_action_check" CHECK ("operation_artifacts"."action" IN ('near_message', 'evm_message', 'evm_typed_data', 'evm_transaction')), + CONSTRAINT "operation_artifacts_payload_state_check" CHECK (("operation_artifacts"."consumed_at" IS NULL AND "operation_artifacts"."ciphertext" IS NOT NULL AND "operation_artifacts"."key_id" IS NOT NULL AND "operation_artifacts"."nonce" IS NOT NULL) OR ("operation_artifacts"."consumed_at" IS NOT NULL AND "operation_artifacts"."ciphertext" IS NULL AND "operation_artifacts"."key_id" IS NULL AND "operation_artifacts"."nonce" IS NULL)) +); +--> statement-breakpoint +CREATE TABLE "operation_tombstones" ( + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "id" text NOT NULL, + "request_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "operation_tombstones_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id") +); +--> statement-breakpoint +CREATE TABLE "operations" ( + "id" text NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "actor_key_id" text, + "kind" text NOT NULL, + "action" text, + "request_hash" text NOT NULL, + "status" text NOT NULL, + "authorized_owner_epoch" bigint, + "authorized_policy_epoch" bigint, + "authorized_lifecycle_epoch" bigint, + "authorized_grant_id" text, + "authorized_grant_label" text, + "dispatch_claimed_at" timestamp with time zone, + "dispatch_committed_at" timestamp with time zone, + "result" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "operations_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id"), + CONSTRAINT "operations_status_check" CHECK ("operations"."status" IN ('pending', 'completed', 'uncertain', 'failed')), + CONSTRAINT "operations_kind_check" CHECK ("operations"."kind" IN ('sign', 'execute', 'relay', 'policy')), + CONSTRAINT "operations_sign_result_safe_check" CHECK ("operations"."kind" <> 'sign' OR "operations"."result" IS NULL OR ( + jsonb_typeof("operations"."result") = 'object' + AND "operations"."result"->'redacted' = 'true'::jsonb + AND "operations"."result"->'artifact_redacted' = 'true'::jsonb + AND jsonb_typeof("operations"."result"->'status') = 'string' + AND "operations"."result"->>'status' IN ( + 'pending', 'pending_approval', 'pending_deposit', + 'pending_wallet_signature', 'processing', 'approved', 'success', 'partially_failed', + 'failed', 'refunded', 'rejected', 'expired', 'cancelled', 'needs_review', 'unknown', + 'completed', 'uncertain', 'dispatching', 'submitted', 'applied' + ) + AND ("operations"."result" - ARRAY['redacted', 'artifact_redacted', 'status', 'failure_code', 'provider_request_id']) = '{}'::jsonb + AND (NOT ("operations"."result" ? 'failure_code') OR ( + jsonb_typeof("operations"."result"->'failure_code') = 'string' + AND char_length("operations"."result"->>'failure_code') <= 256 + )) + AND (NOT ("operations"."result" ? 'provider_request_id') OR ( + "operations"."result"->'provider_request_id' = 'null'::jsonb OR ( + jsonb_typeof("operations"."result"->'provider_request_id') = 'string' + AND char_length("operations"."result"->>'provider_request_id') <= 128 + ) + )) + )) +); +--> statement-breakpoint +CREATE TABLE "owner_intents" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "actor_key_id" text NOT NULL, + "type" text NOT NULL, + "standard" text NOT NULL, + "signer" jsonb NOT NULL, + "intent" jsonb NOT NULL, + "context" jsonb NOT NULL, + "preview" jsonb NOT NULL, + "state" text DEFAULT 'pending_signature' NOT NULL, + "operation_id" text, + "result" jsonb, + "failure_code" text, + "idempotency_key" text, + "request_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "owner_intents_idempotency" UNIQUE("tenant_id","idempotency_key"), + CONSTRAINT "owner_intents_state_check" CHECK ("owner_intents"."state" IN ('pending_signature', 'submitted', 'completed', 'failed')), + CONSTRAINT "owner_intents_standard_check" CHECK ("owner_intents"."standard" IN ('nep413', 'nep366', 'eip712', 'webauthn')) +); +--> statement-breakpoint +CREATE TABLE "owner_nonces" ( + "nonce" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rateLimit" ( + "id" text PRIMARY KEY NOT NULL, + "key" text NOT NULL, + "count" integer NOT NULL, + "lastRequest" bigint NOT NULL, + CONSTRAINT "rateLimit_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "request_buckets" ( + "key" text PRIMARY KEY NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "count" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "token" text NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL, + "ipAddress" text, + "userAgent" text, + "userId" text NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "spend_charges" ( + "operation_id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "action" text NOT NULL, + "asset" text NOT NULL, + "amount" text NOT NULL, + "usd_micros" bigint NOT NULL, + "decimals" integer NOT NULL, + "price_coefficient" text NOT NULL, + "price_scale" integer NOT NULL, + "price_updated_at" timestamp with time zone NOT NULL, + "budget_revision" integer NOT NULL, + "dispatch_token" text NOT NULL, + "state" text NOT NULL, + "charged_at" timestamp with time zone NOT NULL, + "released_at" timestamp with time zone, + CONSTRAINT "spend_charges_state_check" CHECK ("spend_charges"."state" IN ('committed', 'released')), + CONSTRAINT "spend_charges_release_check" CHECK (("spend_charges"."state" = 'released') = ("spend_charges"."released_at" IS NOT NULL)), + CONSTRAINT "spend_charges_usd_check" CHECK ("spend_charges"."usd_micros" >= 0) +); +--> statement-breakpoint +CREATE TABLE "sponsor_reservations" ( + "operation_id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "state" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "released_at" timestamp with time zone, + CONSTRAINT "sponsor_reservations_state_check" CHECK ("sponsor_reservations"."state" IN ('reserved', 'committed', 'released')), + CONSTRAINT "sponsor_reservations_release_check" CHECK (("sponsor_reservations"."state" = 'released') = ("sponsor_reservations"."released_at" IS NOT NULL)) +); +--> statement-breakpoint +CREATE TABLE "tenants" ( + "id" text PRIMARY KEY NOT NULL, + "owner_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tenants_owner_user_id_unique" UNIQUE("owner_user_id") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "emailVerified" boolean DEFAULT false NOT NULL, + "image" text, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "wallet_policies" ( + "id" text NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "wallet_id" text NOT NULL, + "version" integer NOT NULL, + "policy_hash" text NOT NULL, + "encrypted_data" text NOT NULL, + "signature_hex" text NOT NULL, + "public_key_hex" text NOT NULL, + "rules" jsonb NOT NULL, + "status" text NOT NULL, + "transaction_hash" text, + "applied_at" timestamp with time zone, + "failure_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "wallet_policies_tenant_id_agent_id_id_pk" PRIMARY KEY("tenant_id","agent_id","id"), + CONSTRAINT "wallet_policies_status_check" CHECK ("wallet_policies"."status" IN ('draft', 'discarded', 'signed', 'applied', 'failed')) +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_grants" ADD CONSTRAINT "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agents" ADD CONSTRAINT "agents_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_retention_holds" ADD CONSTRAINT "audit_retention_holds_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "custody_wallets" ADD CONSTRAINT "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "delayed_executions" ADD CONSTRAINT "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk" FOREIGN KEY ("tenant_id","agent_id","id") REFERENCES "public"."operations"("tenant_id","agent_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "intent_generations" ADD CONSTRAINT "intent_generations_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "operation_artifacts" ADD CONSTRAINT "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk" FOREIGN KEY ("tenant_id","agent_id","operation_id") REFERENCES "public"."operations"("tenant_id","agent_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "operation_artifacts" ADD CONSTRAINT "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk" FOREIGN KEY ("tenant_id","agent_id","grant_id") REFERENCES "public"."agent_grants"("tenant_id","agent_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "operations" ADD CONSTRAINT "operations_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "owner_intents" ADD CONSTRAINT "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tenants" ADD CONSTRAINT "tenants_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_policies" ADD CONSTRAINT "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_user" ON "account" USING btree ("userId");--> statement-breakpoint +CREATE UNIQUE INDEX "agent_grants_credential" ON "agent_grants" USING btree ("credential_hash");--> statement-breakpoint +CREATE INDEX "agent_grants_agent" ON "agent_grants" USING btree ("tenant_id","agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "agents_tenant_created" ON "agents" USING btree ("tenant_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "agents_external_user" ON "agents" USING btree ("tenant_id","external_user_id");--> statement-breakpoint +CREATE INDEX "api_keys_tenant" ON "api_keys" USING btree ("tenant_id");--> statement-breakpoint +CREATE INDEX "audit_events_tenant" ON "audit_events" USING btree ("tenant_id","id");--> statement-breakpoint +CREATE INDEX "audit_events_actor" ON "audit_events" USING btree ("tenant_id","actor_key_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "audit_events_created_at" ON "audit_events" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "audit_retention_holds_active" ON "audit_retention_holds" USING btree ("tenant_id","agent_id","released_at");--> statement-breakpoint +CREATE INDEX "delayed_executions_due" ON "delayed_executions" USING btree ("state","execute_after");--> statement-breakpoint +CREATE INDEX "operation_artifacts_expires" ON "operation_artifacts" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "operations_tenant_status" ON "operations" USING btree ("tenant_id","status","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "operations_agent_created" ON "operations" USING btree ("tenant_id","agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "operations_actor_created" ON "operations" USING btree ("tenant_id","actor_key_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "operations_tenant_id" ON "operations" USING btree ("tenant_id","id");--> statement-breakpoint +CREATE INDEX "owner_intents_agent_created" ON "owner_intents" USING btree ("tenant_id","agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "owner_intents_operation" ON "owner_intents" USING btree ("tenant_id","agent_id","operation_id");--> statement-breakpoint +CREATE INDEX "owner_nonces_agent" ON "owner_nonces" USING btree ("tenant_id","agent_id");--> statement-breakpoint +CREATE INDEX "owner_nonces_expires" ON "owner_nonces" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "spend_charges_agent_window" ON "spend_charges" USING btree ("tenant_id","agent_id","charged_at");--> statement-breakpoint +CREATE INDEX "sponsor_reservations_window" ON "sponsor_reservations" USING btree ("created_at","tenant_id","agent_id");--> statement-breakpoint +CREATE INDEX "sponsor_reservations_agent" ON "sponsor_reservations" USING btree ("tenant_id","agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_policies_version" ON "wallet_policies" USING btree ("wallet_id","version") WHERE "wallet_policies"."status" NOT IN ('draft', 'discarded');--> statement-breakpoint +CREATE INDEX "wallet_policies_agent_version" ON "wallet_policies" USING btree ("tenant_id","agent_id","version" DESC NULLS LAST); \ No newline at end of file diff --git a/plugins/agents/src/db/migrations/0001_mixed_namora.sql b/plugins/agents/src/db/migrations/0001_mixed_namora.sql new file mode 100644 index 000000000..d8970e6ce --- /dev/null +++ b/plugins/agents/src/db/migrations/0001_mixed_namora.sql @@ -0,0 +1,37 @@ +CREATE TABLE "owner_receipts" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "agent_id" text NOT NULL, + "action" text NOT NULL, + "target_id" text NOT NULL, + "owner_epoch" bigint NOT NULL, + "nonce" text NOT NULL, + "payload_hash" text NOT NULL, + "message" jsonb NOT NULL, + "proof" jsonb NOT NULL, + "original" jsonb, + "verification" jsonb NOT NULL, + "decided_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "quota_changes" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "revision" integer NOT NULL, + "operator" text NOT NULL, + "reason" text NOT NULL, + "previous" jsonb NOT NULL, + "profile" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "agent_grants" ALTER COLUMN "recipients" SET DATA TYPE jsonb USING to_jsonb("recipients");--> statement-breakpoint +ALTER TABLE "agent_grants" ADD COLUMN "signing_audiences" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "tenants" ADD COLUMN "quota_profile" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "tenants" ADD COLUMN "quota_revision" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "owner_receipts" ADD CONSTRAINT "owner_receipts_tenant_id_agent_id_agents_tenant_id_id_fk" FOREIGN KEY ("tenant_id","agent_id") REFERENCES "public"."agents"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "quota_changes" ADD CONSTRAINT "quota_changes_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "owner_receipts_nonce" ON "owner_receipts" USING btree ("tenant_id","agent_id","nonce");--> statement-breakpoint +CREATE UNIQUE INDEX "quota_changes_revision" ON "quota_changes" USING btree ("tenant_id","revision"); +--> statement-breakpoint +UPDATE "agent_grants" SET "revoked_at" = COALESCE("revoked_at", clock_timestamp()), "revoked_reason" = COALESCE("revoked_reason", 'typed_destinations_reauthorization_required') WHERE "owner_message"->>'domain' IS DISTINCT FROM 'near-intents-agent-api.agent-grant.v4'; diff --git a/plugins/agents/src/db/migrations/meta/0000_snapshot.json b/plugins/agents/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..0a0432447 --- /dev/null +++ b/plugins/agents/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,2674 @@ +{ + "id": "2e1bc90d-4a28-4e85-84ee-005613f1c51b", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_grants": { + "name": "agent_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "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 + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_message": { + "name": "owner_message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_grants_credential": { + "name": "agent_grants_credential", + "columns": [ + { + "expression": "credential_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_grants_agent": { + "name": "agent_grants_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "agent_grants", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_grants_tenant_id_agent_id_id_pk": { + "name": "agent_grants_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_identity": { + "name": "owner_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_near": { + "name": "owner_near", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_counter": { + "name": "owner_counter", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_public_key": { + "name": "owner_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timelock_delay_seconds": { + "name": "timelock_delay_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timelock_revision": { + "name": "timelock_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_daily_cents": { + "name": "budget_daily_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_weekly_cents": { + "name": "budget_weekly_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_revision": { + "name": "budget_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "policy_epoch": { + "name": "policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "lifecycle_epoch": { + "name": "lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "agents_tenant_created": { + "name": "agents_tenant_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_external_user": { + "name": "agents_external_user", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_tenant_id_tenants_id_fk": { + "name": "agents_tenant_id_tenants_id_fk", + "tableFrom": "agents", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_tenant_id_id_pk": { + "name": "agents_tenant_id_id_pk", + "columns": ["tenant_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_lifecycle_check": { + "name": "agents_lifecycle_check", + "value": "\"agents\".\"lifecycle\" IN ('pending', 'active', 'archived', 'deleted', 'abandoned')" + } + }, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "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": { + "api_keys_tenant": { + "name": "api_keys_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_tenant_id_tenants_id_fk": { + "name": "api_keys_tenant_id_tenants_id_fk", + "tableFrom": "api_keys", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_token_hash_unique": { + "name": "api_keys_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_owner_id": { + "name": "actor_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "policy_epoch": { + "name": "policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "lifecycle_epoch": { + "name": "lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_tenant": { + "name": "audit_events_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor": { + "name": "audit_events_actor", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_created_at": { + "name": "audit_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_tenant_id_tenants_id_fk": { + "name": "audit_events_tenant_id_tenants_id_fk", + "tableFrom": "audit_events", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_retention_holds": { + "name": "audit_retention_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_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": { + "audit_retention_holds_active": { + "name": "audit_retention_holds_active", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "released_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_retention_holds_tenant_id_tenants_id_fk": { + "name": "audit_retention_holds_tenant_id_tenants_id_fk", + "tableFrom": "audit_retention_holds", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custody_wallets": { + "name": "custody_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_wallet_id": { + "name": "provider_wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evm_address": { + "name": "evm_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_ciphertext": { + "name": "credential_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_nonce": { + "name": "credential_nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key_id": { + "name": "credential_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "custody_wallets", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custody_wallets_agent": { + "name": "custody_wallets_agent", + "nullsNotDistinct": false, + "columns": ["tenant_id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "custody_wallets_status_check": { + "name": "custody_wallets_status_check", + "value": "\"custody_wallets\".\"status\" IN ('provisioning', 'active', 'failed', 'deleted')" + } + }, + "isRLSEnabled": false + }, + "public.delayed_executions": { + "name": "delayed_executions", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "execute_after": { + "name": "execute_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delayed_executions_due": { + "name": "delayed_executions_due", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execute_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk": { + "name": "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk", + "tableFrom": "delayed_executions", + "tableTo": "operations", + "columnsFrom": ["tenant_id", "agent_id", "id"], + "columnsTo": ["tenant_id", "agent_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "delayed_executions_tenant_id_agent_id_id_pk": { + "name": "delayed_executions_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "delayed_executions_state": { + "name": "delayed_executions_state", + "value": "\"delayed_executions\".\"state\" IN ('waiting', 'dispatching', 'finished', 'cancelled', 'uncertain')" + } + }, + "isRLSEnabled": false + }, + "public.intent_generations": { + "name": "intent_generations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "intent_generations_tenant_id_tenants_id_fk": { + "name": "intent_generations_tenant_id_tenants_id_fk", + "tableFrom": "intent_generations", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "intent_generations_idempotency": { + "name": "intent_generations_idempotency", + "nullsNotDistinct": false, + "columns": ["tenant_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "intent_generations_state_check": { + "name": "intent_generations_state_check", + "value": "\"intent_generations\".\"state\" IN ('reserved', 'building', 'ready')" + } + }, + "isRLSEnabled": false + }, + "public.operation_artifacts": { + "name": "operation_artifacts", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_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": { + "operation_artifacts_expires": { + "name": "operation_artifacts_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk": { + "name": "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk", + "tableFrom": "operation_artifacts", + "tableTo": "operations", + "columnsFrom": ["tenant_id", "agent_id", "operation_id"], + "columnsTo": ["tenant_id", "agent_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk": { + "name": "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk", + "tableFrom": "operation_artifacts", + "tableTo": "agent_grants", + "columnsFrom": ["tenant_id", "agent_id", "grant_id"], + "columnsTo": ["tenant_id", "agent_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operation_artifacts_tenant_id_agent_id_operation_id_pk": { + "name": "operation_artifacts_tenant_id_agent_id_operation_id_pk", + "columns": ["tenant_id", "agent_id", "operation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operation_artifacts_action_check": { + "name": "operation_artifacts_action_check", + "value": "\"operation_artifacts\".\"action\" IN ('near_message', 'evm_message', 'evm_typed_data', 'evm_transaction')" + }, + "operation_artifacts_payload_state_check": { + "name": "operation_artifacts_payload_state_check", + "value": "(\"operation_artifacts\".\"consumed_at\" IS NULL AND \"operation_artifacts\".\"ciphertext\" IS NOT NULL AND \"operation_artifacts\".\"key_id\" IS NOT NULL AND \"operation_artifacts\".\"nonce\" IS NOT NULL) OR (\"operation_artifacts\".\"consumed_at\" IS NOT NULL AND \"operation_artifacts\".\"ciphertext\" IS NULL AND \"operation_artifacts\".\"key_id\" IS NULL AND \"operation_artifacts\".\"nonce\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.operation_tombstones": { + "name": "operation_tombstones", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "operation_tombstones_tenant_id_agent_id_id_pk": { + "name": "operation_tombstones_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operations": { + "name": "operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorized_owner_epoch": { + "name": "authorized_owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_policy_epoch": { + "name": "authorized_policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_lifecycle_epoch": { + "name": "authorized_lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_grant_id": { + "name": "authorized_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_grant_label": { + "name": "authorized_grant_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatch_claimed_at": { + "name": "dispatch_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dispatch_committed_at": { + "name": "dispatch_committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operations_tenant_status": { + "name": "operations_tenant_status", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_agent_created": { + "name": "operations_agent_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_actor_created": { + "name": "operations_actor_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_tenant_id": { + "name": "operations_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operations_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "operations_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "operations", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operations_tenant_id_agent_id_id_pk": { + "name": "operations_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operations_status_check": { + "name": "operations_status_check", + "value": "\"operations\".\"status\" IN ('pending', 'completed', 'uncertain', 'failed')" + }, + "operations_kind_check": { + "name": "operations_kind_check", + "value": "\"operations\".\"kind\" IN ('sign', 'execute', 'relay', 'policy')" + }, + "operations_sign_result_safe_check": { + "name": "operations_sign_result_safe_check", + "value": "\"operations\".\"kind\" <> 'sign' OR \"operations\".\"result\" IS NULL OR (\n jsonb_typeof(\"operations\".\"result\") = 'object'\n AND \"operations\".\"result\"->'redacted' = 'true'::jsonb\n AND \"operations\".\"result\"->'artifact_redacted' = 'true'::jsonb\n AND jsonb_typeof(\"operations\".\"result\"->'status') = 'string'\n AND \"operations\".\"result\"->>'status' IN (\n 'pending', 'pending_approval', 'pending_deposit',\n 'pending_wallet_signature', 'processing', 'approved', 'success', 'partially_failed',\n 'failed', 'refunded', 'rejected', 'expired', 'cancelled', 'needs_review', 'unknown',\n 'completed', 'uncertain', 'dispatching', 'submitted', 'applied'\n )\n AND (\"operations\".\"result\" - ARRAY['redacted', 'artifact_redacted', 'status', 'failure_code', 'provider_request_id']) = '{}'::jsonb\n AND (NOT (\"operations\".\"result\" ? 'failure_code') OR (\n jsonb_typeof(\"operations\".\"result\"->'failure_code') = 'string'\n AND char_length(\"operations\".\"result\"->>'failure_code') <= 256\n ))\n AND (NOT (\"operations\".\"result\" ? 'provider_request_id') OR (\n \"operations\".\"result\"->'provider_request_id' = 'null'::jsonb OR (\n jsonb_typeof(\"operations\".\"result\"->'provider_request_id') = 'string'\n AND char_length(\"operations\".\"result\"->>'provider_request_id') <= 128\n )\n ))\n )" + } + }, + "isRLSEnabled": false + }, + "public.owner_intents": { + "name": "owner_intents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "standard": { + "name": "standard", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer": { + "name": "signer", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_signature'" + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_intents_agent_created": { + "name": "owner_intents_agent_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "owner_intents_operation": { + "name": "owner_intents_operation", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "owner_intents", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "owner_intents_idempotency": { + "name": "owner_intents_idempotency", + "nullsNotDistinct": false, + "columns": ["tenant_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "owner_intents_state_check": { + "name": "owner_intents_state_check", + "value": "\"owner_intents\".\"state\" IN ('pending_signature', 'submitted', 'completed', 'failed')" + }, + "owner_intents_standard_check": { + "name": "owner_intents_standard_check", + "value": "\"owner_intents\".\"standard\" IN ('nep413', 'nep366', 'eip712', 'webauthn')" + } + }, + "isRLSEnabled": false + }, + "public.owner_nonces": { + "name": "owner_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_nonces_agent": { + "name": "owner_nonces_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "owner_nonces_expires": { + "name": "owner_nonces_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rateLimit": { + "name": "rateLimit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lastRequest": { + "name": "lastRequest", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rateLimit_key_unique": { + "name": "rateLimit_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_buckets": { + "name": "request_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spend_charges": { + "name": "spend_charges", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usd_micros": { + "name": "usd_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_coefficient": { + "name": "price_coefficient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_scale": { + "name": "price_scale", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_updated_at": { + "name": "price_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "budget_revision": { + "name": "budget_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dispatch_token": { + "name": "dispatch_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "charged_at": { + "name": "charged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spend_charges_agent_window": { + "name": "spend_charges_agent_window", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "charged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "spend_charges_state_check": { + "name": "spend_charges_state_check", + "value": "\"spend_charges\".\"state\" IN ('committed', 'released')" + }, + "spend_charges_release_check": { + "name": "spend_charges_release_check", + "value": "(\"spend_charges\".\"state\" = 'released') = (\"spend_charges\".\"released_at\" IS NOT NULL)" + }, + "spend_charges_usd_check": { + "name": "spend_charges_usd_check", + "value": "\"spend_charges\".\"usd_micros\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.sponsor_reservations": { + "name": "sponsor_reservations", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sponsor_reservations_window": { + "name": "sponsor_reservations_window", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsor_reservations_agent": { + "name": "sponsor_reservations_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sponsor_reservations_state_check": { + "name": "sponsor_reservations_state_check", + "value": "\"sponsor_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + }, + "sponsor_reservations_release_check": { + "name": "sponsor_reservations_release_check", + "value": "(\"sponsor_reservations\".\"state\" = 'released') = (\"sponsor_reservations\".\"released_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenants_owner_user_id_user_id_fk": { + "name": "tenants_owner_user_id_user_id_fk", + "tableFrom": "tenants", + "tableTo": "user", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_owner_user_id_unique": { + "name": "tenants_owner_user_id_unique", + "nullsNotDistinct": false, + "columns": ["owner_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_policies": { + "name": "wallet_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "policy_hash": { + "name": "policy_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature_hex": { + "name": "signature_hex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hex": { + "name": "public_key_hex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_policies_version": { + "name": "wallet_policies_version", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_policies\".\"status\" NOT IN ('draft', 'discarded')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_policies_agent_version": { + "name": "wallet_policies_agent_version", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "wallet_policies", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wallet_policies_tenant_id_agent_id_id_pk": { + "name": "wallet_policies_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "wallet_policies_status_check": { + "name": "wallet_policies_status_check", + "value": "\"wallet_policies\".\"status\" IN ('draft', 'discarded', 'signed', 'applied', 'failed')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/plugins/agents/src/db/migrations/meta/0001_snapshot.json b/plugins/agents/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..926ba9bd3 --- /dev/null +++ b/plugins/agents/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,2919 @@ +{ + "id": "a85312f8-708e-490a-bf6a-cb3da57aa914", + "prevId": "2e1bc90d-4a28-4e85-84ee-005613f1c51b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_grants": { + "name": "agent_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "signing_audiences": { + "name": "signing_audiences", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "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 + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_message": { + "name": "owner_message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_grants_credential": { + "name": "agent_grants_credential", + "columns": [ + { + "expression": "credential_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_grants_agent": { + "name": "agent_grants_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "agent_grants_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "agent_grants", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_grants_tenant_id_agent_id_id_pk": { + "name": "agent_grants_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_identity": { + "name": "owner_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_near": { + "name": "owner_near", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_counter": { + "name": "owner_counter", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_public_key": { + "name": "owner_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timelock_delay_seconds": { + "name": "timelock_delay_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timelock_revision": { + "name": "timelock_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_daily_cents": { + "name": "budget_daily_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_weekly_cents": { + "name": "budget_weekly_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "budget_revision": { + "name": "budget_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "policy_epoch": { + "name": "policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "lifecycle_epoch": { + "name": "lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "agents_tenant_created": { + "name": "agents_tenant_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_external_user": { + "name": "agents_external_user", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_tenant_id_tenants_id_fk": { + "name": "agents_tenant_id_tenants_id_fk", + "tableFrom": "agents", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_tenant_id_id_pk": { + "name": "agents_tenant_id_id_pk", + "columns": ["tenant_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_lifecycle_check": { + "name": "agents_lifecycle_check", + "value": "\"agents\".\"lifecycle\" IN ('pending', 'active', 'archived', 'deleted', 'abandoned')" + } + }, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "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": { + "api_keys_tenant": { + "name": "api_keys_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_tenant_id_tenants_id_fk": { + "name": "api_keys_tenant_id_tenants_id_fk", + "tableFrom": "api_keys", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_token_hash_unique": { + "name": "api_keys_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_owner_id": { + "name": "actor_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "policy_epoch": { + "name": "policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "lifecycle_epoch": { + "name": "lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_tenant": { + "name": "audit_events_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor": { + "name": "audit_events_actor", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_created_at": { + "name": "audit_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_tenant_id_tenants_id_fk": { + "name": "audit_events_tenant_id_tenants_id_fk", + "tableFrom": "audit_events", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_retention_holds": { + "name": "audit_retention_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_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": { + "audit_retention_holds_active": { + "name": "audit_retention_holds_active", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "released_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_retention_holds_tenant_id_tenants_id_fk": { + "name": "audit_retention_holds_tenant_id_tenants_id_fk", + "tableFrom": "audit_retention_holds", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custody_wallets": { + "name": "custody_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_wallet_id": { + "name": "provider_wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evm_address": { + "name": "evm_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_ciphertext": { + "name": "credential_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_nonce": { + "name": "credential_nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key_id": { + "name": "credential_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "custody_wallets_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "custody_wallets", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custody_wallets_agent": { + "name": "custody_wallets_agent", + "nullsNotDistinct": false, + "columns": ["tenant_id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "custody_wallets_status_check": { + "name": "custody_wallets_status_check", + "value": "\"custody_wallets\".\"status\" IN ('provisioning', 'active', 'failed', 'deleted')" + } + }, + "isRLSEnabled": false + }, + "public.delayed_executions": { + "name": "delayed_executions", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "execute_after": { + "name": "execute_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delayed_executions_due": { + "name": "delayed_executions_due", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execute_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk": { + "name": "delayed_executions_tenant_id_agent_id_id_operations_tenant_id_agent_id_id_fk", + "tableFrom": "delayed_executions", + "tableTo": "operations", + "columnsFrom": ["tenant_id", "agent_id", "id"], + "columnsTo": ["tenant_id", "agent_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "delayed_executions_tenant_id_agent_id_id_pk": { + "name": "delayed_executions_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "delayed_executions_state": { + "name": "delayed_executions_state", + "value": "\"delayed_executions\".\"state\" IN ('waiting', 'dispatching', 'finished', 'cancelled', 'uncertain')" + } + }, + "isRLSEnabled": false + }, + "public.intent_generations": { + "name": "intent_generations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "intent_generations_tenant_id_tenants_id_fk": { + "name": "intent_generations_tenant_id_tenants_id_fk", + "tableFrom": "intent_generations", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "intent_generations_idempotency": { + "name": "intent_generations_idempotency", + "nullsNotDistinct": false, + "columns": ["tenant_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "intent_generations_state_check": { + "name": "intent_generations_state_check", + "value": "\"intent_generations\".\"state\" IN ('reserved', 'building', 'ready')" + } + }, + "isRLSEnabled": false + }, + "public.operation_artifacts": { + "name": "operation_artifacts", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_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": { + "operation_artifacts_expires": { + "name": "operation_artifacts_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk": { + "name": "operation_artifacts_tenant_id_agent_id_operation_id_operations_tenant_id_agent_id_id_fk", + "tableFrom": "operation_artifacts", + "tableTo": "operations", + "columnsFrom": ["tenant_id", "agent_id", "operation_id"], + "columnsTo": ["tenant_id", "agent_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk": { + "name": "operation_artifacts_tenant_id_agent_id_grant_id_agent_grants_tenant_id_agent_id_id_fk", + "tableFrom": "operation_artifacts", + "tableTo": "agent_grants", + "columnsFrom": ["tenant_id", "agent_id", "grant_id"], + "columnsTo": ["tenant_id", "agent_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operation_artifacts_tenant_id_agent_id_operation_id_pk": { + "name": "operation_artifacts_tenant_id_agent_id_operation_id_pk", + "columns": ["tenant_id", "agent_id", "operation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operation_artifacts_action_check": { + "name": "operation_artifacts_action_check", + "value": "\"operation_artifacts\".\"action\" IN ('near_message', 'evm_message', 'evm_typed_data', 'evm_transaction')" + }, + "operation_artifacts_payload_state_check": { + "name": "operation_artifacts_payload_state_check", + "value": "(\"operation_artifacts\".\"consumed_at\" IS NULL AND \"operation_artifacts\".\"ciphertext\" IS NOT NULL AND \"operation_artifacts\".\"key_id\" IS NOT NULL AND \"operation_artifacts\".\"nonce\" IS NOT NULL) OR (\"operation_artifacts\".\"consumed_at\" IS NOT NULL AND \"operation_artifacts\".\"ciphertext\" IS NULL AND \"operation_artifacts\".\"key_id\" IS NULL AND \"operation_artifacts\".\"nonce\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.operation_tombstones": { + "name": "operation_tombstones", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "operation_tombstones_tenant_id_agent_id_id_pk": { + "name": "operation_tombstones_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operations": { + "name": "operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorized_owner_epoch": { + "name": "authorized_owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_policy_epoch": { + "name": "authorized_policy_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_lifecycle_epoch": { + "name": "authorized_lifecycle_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "authorized_grant_id": { + "name": "authorized_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_grant_label": { + "name": "authorized_grant_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatch_claimed_at": { + "name": "dispatch_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dispatch_committed_at": { + "name": "dispatch_committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operations_tenant_status": { + "name": "operations_tenant_status", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_agent_created": { + "name": "operations_agent_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_actor_created": { + "name": "operations_actor_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operations_tenant_id": { + "name": "operations_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operations_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "operations_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "operations", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operations_tenant_id_agent_id_id_pk": { + "name": "operations_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operations_status_check": { + "name": "operations_status_check", + "value": "\"operations\".\"status\" IN ('pending', 'completed', 'uncertain', 'failed')" + }, + "operations_kind_check": { + "name": "operations_kind_check", + "value": "\"operations\".\"kind\" IN ('sign', 'execute', 'relay', 'policy')" + }, + "operations_sign_result_safe_check": { + "name": "operations_sign_result_safe_check", + "value": "\"operations\".\"kind\" <> 'sign' OR \"operations\".\"result\" IS NULL OR (\n jsonb_typeof(\"operations\".\"result\") = 'object'\n AND \"operations\".\"result\"->'redacted' = 'true'::jsonb\n AND \"operations\".\"result\"->'artifact_redacted' = 'true'::jsonb\n AND jsonb_typeof(\"operations\".\"result\"->'status') = 'string'\n AND \"operations\".\"result\"->>'status' IN (\n 'pending', 'pending_approval', 'pending_deposit',\n 'pending_wallet_signature', 'processing', 'approved', 'success', 'partially_failed',\n 'failed', 'refunded', 'rejected', 'expired', 'cancelled', 'needs_review', 'unknown',\n 'completed', 'uncertain', 'dispatching', 'submitted', 'applied'\n )\n AND (\"operations\".\"result\" - ARRAY['redacted', 'artifact_redacted', 'status', 'failure_code', 'provider_request_id']) = '{}'::jsonb\n AND (NOT (\"operations\".\"result\" ? 'failure_code') OR (\n jsonb_typeof(\"operations\".\"result\"->'failure_code') = 'string'\n AND char_length(\"operations\".\"result\"->>'failure_code') <= 256\n ))\n AND (NOT (\"operations\".\"result\" ? 'provider_request_id') OR (\n \"operations\".\"result\"->'provider_request_id' = 'null'::jsonb OR (\n jsonb_typeof(\"operations\".\"result\"->'provider_request_id') = 'string'\n AND char_length(\"operations\".\"result\"->>'provider_request_id') <= 128\n )\n ))\n )" + } + }, + "isRLSEnabled": false + }, + "public.owner_intents": { + "name": "owner_intents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_key_id": { + "name": "actor_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "standard": { + "name": "standard", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer": { + "name": "signer", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_signature'" + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_intents_agent_created": { + "name": "owner_intents_agent_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "owner_intents_operation": { + "name": "owner_intents_operation", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "owner_intents_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "owner_intents", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "owner_intents_idempotency": { + "name": "owner_intents_idempotency", + "nullsNotDistinct": false, + "columns": ["tenant_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "owner_intents_state_check": { + "name": "owner_intents_state_check", + "value": "\"owner_intents\".\"state\" IN ('pending_signature', 'submitted', 'completed', 'failed')" + }, + "owner_intents_standard_check": { + "name": "owner_intents_standard_check", + "value": "\"owner_intents\".\"standard\" IN ('nep413', 'nep366', 'eip712', 'webauthn')" + } + }, + "isRLSEnabled": false + }, + "public.owner_nonces": { + "name": "owner_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_nonces_agent": { + "name": "owner_nonces_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "owner_nonces_expires": { + "name": "owner_nonces_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.owner_receipts": { + "name": "owner_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_epoch": { + "name": "owner_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "proof": { + "name": "proof", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "original": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verification": { + "name": "verification", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "owner_receipts_nonce": { + "name": "owner_receipts_nonce", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_receipts_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "owner_receipts_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "owner_receipts", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quota_changes": { + "name": "quota_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous": { + "name": "previous", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quota_changes_revision": { + "name": "quota_changes_revision", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quota_changes_tenant_id_tenants_id_fk": { + "name": "quota_changes_tenant_id_tenants_id_fk", + "tableFrom": "quota_changes", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rateLimit": { + "name": "rateLimit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lastRequest": { + "name": "lastRequest", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rateLimit_key_unique": { + "name": "rateLimit_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_buckets": { + "name": "request_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spend_charges": { + "name": "spend_charges", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usd_micros": { + "name": "usd_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_coefficient": { + "name": "price_coefficient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_scale": { + "name": "price_scale", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_updated_at": { + "name": "price_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "budget_revision": { + "name": "budget_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dispatch_token": { + "name": "dispatch_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "charged_at": { + "name": "charged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spend_charges_agent_window": { + "name": "spend_charges_agent_window", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "charged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "spend_charges_state_check": { + "name": "spend_charges_state_check", + "value": "\"spend_charges\".\"state\" IN ('committed', 'released')" + }, + "spend_charges_release_check": { + "name": "spend_charges_release_check", + "value": "(\"spend_charges\".\"state\" = 'released') = (\"spend_charges\".\"released_at\" IS NOT NULL)" + }, + "spend_charges_usd_check": { + "name": "spend_charges_usd_check", + "value": "\"spend_charges\".\"usd_micros\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.sponsor_reservations": { + "name": "sponsor_reservations", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sponsor_reservations_window": { + "name": "sponsor_reservations_window", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsor_reservations_agent": { + "name": "sponsor_reservations_agent", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sponsor_reservations_state_check": { + "name": "sponsor_reservations_state_check", + "value": "\"sponsor_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + }, + "sponsor_reservations_release_check": { + "name": "sponsor_reservations_release_check", + "value": "(\"sponsor_reservations\".\"state\" = 'released') = (\"sponsor_reservations\".\"released_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "quota_profile": { + "name": "quota_profile", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quota_revision": { + "name": "quota_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenants_owner_user_id_user_id_fk": { + "name": "tenants_owner_user_id_user_id_fk", + "tableFrom": "tenants", + "tableTo": "user", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_owner_user_id_unique": { + "name": "tenants_owner_user_id_unique", + "nullsNotDistinct": false, + "columns": ["owner_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_policies": { + "name": "wallet_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "policy_hash": { + "name": "policy_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature_hex": { + "name": "signature_hex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hex": { + "name": "public_key_hex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transaction_hash": { + "name": "transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_policies_version": { + "name": "wallet_policies_version", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_policies\".\"status\" NOT IN ('draft', 'discarded')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_policies_agent_version": { + "name": "wallet_policies_agent_version", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk": { + "name": "wallet_policies_tenant_id_agent_id_agents_tenant_id_id_fk", + "tableFrom": "wallet_policies", + "tableTo": "agents", + "columnsFrom": ["tenant_id", "agent_id"], + "columnsTo": ["tenant_id", "id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wallet_policies_tenant_id_agent_id_id_pk": { + "name": "wallet_policies_tenant_id_agent_id_id_pk", + "columns": ["tenant_id", "agent_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "wallet_policies_status_check": { + "name": "wallet_policies_status_check", + "value": "\"wallet_policies\".\"status\" IN ('draft', 'discarded', 'signed', 'applied', 'failed')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/plugins/agents/src/db/migrations/meta/_journal.json b/plugins/agents/src/db/migrations/meta/_journal.json new file mode 100644 index 000000000..bea39da2e --- /dev/null +++ b/plugins/agents/src/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1790753318965, + "tag": "0000_initial_schema", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1790756660354, + "tag": "0001_mixed_namora", + "breakpoints": true + } + ] +} diff --git a/plugins/agents/src/db/schema.ts b/plugins/agents/src/db/schema.ts new file mode 100644 index 000000000..2a723fe6a --- /dev/null +++ b/plugins/agents/src/db/schema.ts @@ -0,0 +1 @@ +export * from "@near-intents-agent-api/database/schema"; diff --git a/plugins/agents/src/global.d.ts b/plugins/agents/src/global.d.ts new file mode 100644 index 000000000..aa4d71ce6 --- /dev/null +++ b/plugins/agents/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/agents/src/idempotency.ts b/plugins/agents/src/idempotency.ts new file mode 100644 index 000000000..ebab21ed4 --- /dev/null +++ b/plugins/agents/src/idempotency.ts @@ -0,0 +1,6 @@ +import { ApiError } from "@near-intents-agent-api/agents-core"; + +export function requireIdempotencyKey(key: string | undefined): string { + if (!key) throw new ApiError("idempotency_key_required", 400); + return key; +} diff --git a/plugins/agents/src/index.ts b/plugins/agents/src/index.ts new file mode 100644 index 000000000..99fb4008c --- /dev/null +++ b/plugins/agents/src/index.ts @@ -0,0 +1,507 @@ +import { + ApiError, + acknowledgeSigningArtifact, + balanceList, + balanceMoveExecution, + configureDatabase, + configureOutlayer, + configureRuntime, + configureSponsorClients, + depositExecution, + findOperation, + generateIntent, + generateResponse, + getAgentView, + getTokenCatalog, + listAgentGrants, + listAgents, + listScheduledExecutions, + operationStatus, + readBudget, + readHistory, + readLimits, + readPolicy, + readPolicyHistory, + readSigningArtifact, + readStatus, + readTimelock, + recoverExecutionRequest, + runExecution, + runRecovery, + signEvmMessage, + signMessage, + submitIntent, + swapExecution, + swapQuote, + transferExecution, + walletView, + withdrawExecution, + withdrawQuote, +} from "@near-intents-agent-api/agents-core"; +import { envSchema } from "@near-intents-agent-api/agents-core/config"; +import * as views from "@near-intents-agent-api/agents-core/views"; +import { agents } from "@near-intents-agent-api/database/schema"; +import { createOutlayerClient } from "@near-intents-agent-api/outlayer"; +import { sql } from "drizzle-orm"; +import { Effect, Layer } from "effect"; +import { createPlugin } from "every-plugin"; +import { z } from "zod"; +import { actorForSession } from "./actor"; +import { ContextSchema } from "./context"; +import { contract } from "./contract"; +import { DatabaseLive, DatabaseTag } from "./db/layer"; +import { requireIdempotencyKey } from "./idempotency"; + +export default createPlugin({ + variables: z.object({ + agentsNearRpcUrls: z + .string() + .default("https://near.drpc.org,https://free.rpc.fastnear.com") + .describe("Comma-separated NEAR RPC endpoints used by the sponsor and verification paths"), + agentsTrustedOrigins: z + .string() + .describe( + "Comma-separated trusted origins for the runtime (e.g. the opencrosspost relying-party origin)", + ), + agentsServiceUrl: z + .string() + .url() + .default("https://agents.local") + .describe( + "This service's public origin — its hostname is the NEP-413 recipient owners sign into every owner message", + ), + agentsSponsorDailyGlobalLimit: z.number().min(1).default(100), + agentsSponsorDailyTenantLimit: z.number().min(1).default(20), + agentsSponsorDailyAgentLimit: z.number().min(1).default(10), + }), + + secrets: z.object({ + AGENTS_DATABASE_URL: z + .string() + .default("pglite:.bos/agents/:memory:") + .describe( + "Dedicated database connection string. Use pglite: for local dev/tests, postgres:// for production.", + ), + AGENTS_SPONSOR_KEYS: z + .string() + .optional() + .describe( + "JSON array of { accountId, privateKey } sponsor FullAccess keys (NEAR_SPONSOR_KEYS format). Sponsor features are disabled when absent.", + ), + AGENTS_SECRET_ENCRYPTION_KEYS: z + .string() + .optional() + .describe("JSON map of env:vN → 32+ byte encryption keys for grant tokens at rest"), + AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID: z.string().optional(), + }), + + context: ContextSchema, + + contract, + + initialize: (config) => { + const coreWiring = Layer.effectDiscard( + Effect.gen(function* () { + const database = yield* DatabaseTag; + + const envConfig = envSchema.parse({ + DATABASE_URL: config.secrets.AGENTS_DATABASE_URL, + BETTER_AUTH_URL: config.variables.agentsServiceUrl, + TRUSTED_ORIGINS: config.variables.agentsTrustedOrigins, + NEAR_RPC_URLS: config.variables.agentsNearRpcUrls, + ...(config.secrets.AGENTS_SPONSOR_KEYS + ? { NEAR_SPONSOR_KEYS: config.secrets.AGENTS_SPONSOR_KEYS } + : {}), + ...(config.secrets.AGENTS_SECRET_ENCRYPTION_KEYS + ? { SECRET_ENCRYPTION_KEYS: config.secrets.AGENTS_SECRET_ENCRYPTION_KEYS } + : {}), + ...(config.secrets.AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID + ? { + SECRET_ENCRYPTION_ACTIVE_KEY_ID: + config.secrets.AGENTS_SECRET_ENCRYPTION_ACTIVE_KEY_ID, + } + : {}), + SPONSOR_DAILY_GLOBAL_LIMIT: config.variables.agentsSponsorDailyGlobalLimit, + SPONSOR_DAILY_TENANT_LIMIT: config.variables.agentsSponsorDailyTenantLimit, + SPONSOR_DAILY_AGENT_LIMIT: config.variables.agentsSponsorDailyAgentLimit, + }); + + configureDatabase(database); + configureRuntime({ + trustedOrigins: envConfig.TRUSTED_ORIGINS, + secretEncryptionKeys: envConfig.SECRET_ENCRYPTION_KEYS, + secretEncryptionActiveKeyId: envConfig.SECRET_ENCRYPTION_ACTIVE_KEY_ID, + nearRpcUrls: envConfig.NEAR_RPC_URLS, + network: "mainnet", + serviceUrl: envConfig.BETTER_AUTH_URL, + sponsorDailyGlobalLimit: envConfig.SPONSOR_DAILY_GLOBAL_LIMIT, + sponsorDailyTenantLimit: envConfig.SPONSOR_DAILY_TENANT_LIMIT, + sponsorDailyAgentLimit: envConfig.SPONSOR_DAILY_AGENT_LIMIT, + walletPolicyStorageLimitYocto: envConfig.NEAR_POLICY_STORAGE_DEPOSIT_YOCTO, + sponsorAccountId: envConfig.NEAR_SPONSOR_KEYS[0]?.accountId, + logLevel: envConfig.LOG_LEVEL, + }); + configureOutlayer(createOutlayerClient()); + + if (envConfig.NEAR_SPONSOR_KEYS.length > 0) { + const lock = database.tryAdvisoryLock; + if (!lock) { + yield* Effect.logWarning( + "[Agents] Sponsor keys provided but the pglite engine has no advisory locks — sponsor features disabled (use postgres for full sponsor support)", + ); + } else { + yield* Effect.tryPromise({ + try: () => configureSponsorClients(envConfig, lock), + catch: (cause) => new Error(`sponsor_configuration_failed: ${String(cause)}`), + }); + } + } else { + yield* Effect.logWarning( + "[Agents] No AGENTS_SPONSOR_KEYS configured — sponsor features (wallet init, policy writes, relay) are disabled", + ); + } + }), + ); + + return Effect.succeed( + DatabaseLive(config.secrets.AGENTS_DATABASE_URL).pipe(Layer.provide(coreWiring)), + ); + }, + + createRouter: (builder) => { + const requireActor = ( + context: { userId?: string }, + errors: { UNAUTHORIZED(options: { data: { apiKeyProvided: boolean } }): unknown }, + ) => + Effect.gen(function* () { + if (!context.userId) + return yield* Effect.fail(errors.UNAUTHORIZED({ data: { apiKeyProvided: false } })); + const database = yield* DatabaseTag; + return yield* Effect.tryPromise(() => actorForSession(database, context.userId!)); + }); + + function grantAndKeyHeaders(context: { reqHeaders?: Headers }) { + return { + grantToken: context.reqHeaders?.get("x-grant-token") ?? undefined, + idempotencyKey: context.reqHeaders?.get("idempotency-key") ?? undefined, + }; + } + + return { + ping: builder.ping.handler(async ({ input }) => { + return { + message: input.message ? `pong: ${input.message}` : "pong", + timestamp: new Date().toISOString(), + }; + }), + + dbHealth: builder.dbHealth.effect(function* () { + const database = yield* DatabaseTag; + const rows = yield* Effect.tryPromise(() => + database.db.select({ count: sql`count(*)::int` }).from(agents), + ).pipe(Effect.catch(() => Effect.succeed([{ count: -1 }]))); + const count = rows[0]?.count ?? -1; + return { ok: count >= 0, agentCount: Math.max(count, 0) }; + }), + + generateIntent: builder.generateIntent.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const idempotencyKey = context.reqHeaders?.get("idempotency-key") ?? undefined; + const { row, replayed } = yield* Effect.tryPromise(() => + generateIntent(actor, input, idempotencyKey), + ); + return { ...generateResponse(row), replayed }; + }), + + submitIntent: builder.submitIntent.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(() => submitIntent(actor, input)); + }), + + intentStatus: builder.intentStatus.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(() => readStatus(actor, input.correlationId, input.waitMs)); + }), + + listAgents: builder.listAgents.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const page = yield* Effect.tryPromise(() => + listAgents(actor, { + ...(input.externalUserId ? { externalUserId: input.externalUserId } : {}), + ...(input.cursor ? { after: input.cursor } : {}), + }), + ); + return { data: page.agents.map(views.agentView), nextCursor: page.next_cursor }; + }), + + getAgent: builder.getAgent.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(async () => + views.agentView(await getAgentView(actor, input.agentId)), + ); + }), + + getWallet: builder.getWallet.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(async () => + views.walletView(await walletView(actor, input.agentId)), + ); + }), + + getBalances: builder.getBalances.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const list = yield* Effect.tryPromise(() => + balanceList(actor, input.agentId, { source: input.source }), + ); + return { + nearAccountId: list.near_account_id, + source: input.source, + balances: list.balances + .filter((entry) => !input.asset || entry.assetId === input.asset) + .map(views.balanceEntry), + }; + }), + + getPolicy: builder.getPolicy.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(async () => + views.policyView(await readPolicy(actor, input.agentId)), + ); + }), + + getPolicyHistory: builder.getPolicyHistory.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const history = yield* Effect.tryPromise(() => + readPolicyHistory(actor, input.agentId, { + limit: input.limit, + ...(input.cursor ? { beforeRevision: input.cursor } : {}), + }), + ); + return views.policyHistoryView(history); + }), + + getLimits: builder.getLimits.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(async () => + views.limitsView(await readLimits(actor, input.agentId)), + ); + }), + + getBudget: builder.getBudget.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(async () => + views.budgetView(await readBudget(actor, input.agentId)), + ); + }), + + getTimelock: builder.getTimelock.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(async () => + views.timelockView(await readTimelock(actor, input.agentId)), + ); + }), + + listScheduledExecutions: builder.listScheduledExecutions.effect(function* ({ + input, + context, + errors, + }) { + const actor = yield* requireActor(context, errors); + const page = yield* Effect.tryPromise(() => + listScheduledExecutions(actor, input.agentId, input), + ); + return views.scheduledPage(page); + }), + + getTokenCatalog: builder.getTokenCatalog.effect(function* () { + const catalog = getTokenCatalog(); + return { data: yield* Effect.tryPromise(() => catalog.list()) }; + }), + + getHistory: builder.getHistory.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + return yield* Effect.tryPromise(() => readHistory(actor, input.agentId, input)); + }), + + listGrants: builder.listGrants.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const grants = yield* Effect.tryPromise(() => listAgentGrants(actor, input.agentId)); + return { data: grants.map(views.grantView) }; + }), + + swap: builder.swap.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + if (input.dry) + return yield* Effect.tryPromise(() => swapQuote(actor, input.agentId, input)); + return yield* Effect.tryPromise(() => + runExecution( + actor, + input.agentId, + swapExecution(input, requireIdempotencyKey(headers.idempotencyKey)), + headers.grantToken, + ), + ); + }), + + withdraw: builder.withdraw.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + if (input.dry) + return yield* Effect.tryPromise(() => withdrawQuote(actor, input.agentId, input)); + return yield* Effect.tryPromise(() => + runExecution( + actor, + input.agentId, + withdrawExecution(input, requireIdempotencyKey(headers.idempotencyKey)), + headers.grantToken, + ), + ); + }), + + transfer: builder.transfer.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + return yield* Effect.tryPromise(() => + runExecution( + actor, + input.agentId, + transferExecution(input, requireIdempotencyKey(headers.idempotencyKey)), + headers.grantToken, + ), + ); + }), + + shield: builder.shield.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + return yield* Effect.tryPromise(() => + runExecution( + actor, + input.agentId, + balanceMoveExecution("shield", input, requireIdempotencyKey(headers.idempotencyKey)), + headers.grantToken, + ), + ); + }), + + unshield: builder.unshield.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + return yield* Effect.tryPromise(() => + runExecution( + actor, + input.agentId, + balanceMoveExecution("unshield", input, requireIdempotencyKey(headers.idempotencyKey)), + headers.grantToken, + ), + ); + }), + + confidentialDeposit: builder.confidentialDeposit.effect(function* ({ + input, + context, + errors, + }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + return yield* Effect.tryPromise(() => + runExecution( + actor, + input.agentId, + balanceMoveExecution( + "confidential_deposit", + input, + requireIdempotencyKey(headers.idempotencyKey), + ), + headers.grantToken, + ), + ); + }), + + deposit: builder.deposit.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + return yield* Effect.tryPromise(() => + runExecution( + actor, + input.agentId, + depositExecution(input, requireIdempotencyKey(headers.idempotencyKey)), + headers.grantToken, + ), + ); + }), + + recover: builder.recover.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + return yield* Effect.tryPromise(() => + runRecovery( + actor, + input.agentId, + input.correlationId, + recoverExecutionRequest(input, requireIdempotencyKey(headers.idempotencyKey)), + headers.grantToken, + ), + ); + }), + + signMessage: builder.signMessage.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + const idempotencyKey = requireIdempotencyKey(headers.idempotencyKey); + const operation = yield* Effect.tryPromise(() => + input.chain === "near" + ? signMessage( + actor, + input.agentId, + { + message: input.message, + encoding: input.encoding, + ...(input.recipient ? { recipient: input.recipient } : {}), + idempotencyKey, + }, + headers.grantToken, + ) + : signEvmMessage( + actor, + input.agentId, + { + message: input.message, + encoding: input.encoding, + chain: input.chain, + idempotencyKey, + }, + headers.grantToken, + ), + ); + const record = yield* Effect.tryPromise(() => + findOperation(actor.tenantId, input.agentId, operation.id), + ); + if (!record) throw new ApiError("operation_not_found", 404); + return operationStatus(record); + }), + + getSignature: builder.getSignature.effect(function* ({ input, context, errors }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + const delivery = yield* Effect.tryPromise(() => + readSigningArtifact(actor, input.agentId, input.correlationId, headers.grantToken), + ); + return views.signatureDelivery(delivery); + }), + + acknowledgeSignature: builder.acknowledgeSignature.effect(function* ({ + input, + context, + errors, + }) { + const actor = yield* requireActor(context, errors); + const headers = grantAndKeyHeaders(context); + return yield* Effect.tryPromise(() => + acknowledgeSigningArtifact(actor, input.agentId, input.correlationId, headers.grantToken), + ); + }), + }; + }, +}); diff --git a/plugins/agents/src/lib/context.ts b/plugins/agents/src/lib/context.ts new file mode 100644 index 000000000..38f603e00 --- /dev/null +++ b/plugins/agents/src/lib/context.ts @@ -0,0 +1,13 @@ +/** + * Request context schema — the auth context the host injects per request. + * + * BE CAREFUL MODIFYING THIS FILE — changes will be overwritten by `bos sync` / `bos upgrade`. + * 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/agents/tests/agent-reads.test.ts b/plugins/agents/tests/agent-reads.test.ts new file mode 100644 index 000000000..dca013e1b --- /dev/null +++ b/plugins/agents/tests/agent-reads.test.ts @@ -0,0 +1,106 @@ +import type { Actor } from "@near-intents-agent-api/agents-core"; +import { + generateIntent, + generateResponse, + listAgents, + readPolicy, + readStatus, + submitIntent, + walletView, +} from "@near-intents-agent-api/agents-core"; +import * as views from "@near-intents-agent-api/agents-core/views"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: true, requires_approval: false }, + }, + rules: { allowed_tokens: ["native"], transaction_types: ["transfer", "swap"] }, +}); + +async function onboard(_env: Awaited>, actor: Actor, name: string) { + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { type: "agent_create", name, owner, policy }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + const status = await readStatus(actor, generated.correlationId, 5000); + expect(status.status).toBe("SUCCESS"); + return { agentId: generated.agentId, owner, signer }; +} + +describe("agent reads (ticket 10)", () => { + it("scopes list, agent, wallet, and policy reads to the session's owner", async () => { + const env = await setupCore("read-scope"); + try { + const actor = await env.actor(); + const { agentId } = await onboard(env, actor, "Scoped"); + const page = await listAgents(actor, {}); + expect(page.agents).toHaveLength(1); + expect(page.agents[0]?.id).toBe(agentId); + expect(page.agents[0]?.status).toBe("active"); + + const wallet = views.walletView(await walletView(actor, agentId)); + expect(wallet.nearAccountId).toBeTruthy(); + + const view = await readPolicy(actor, agentId); + expect(view.revision).toBe(1); + expect(view.status).toBe("applied"); + + const stranger = await env.actorAs("someone-else"); + const strangerPage = await listAgents(stranger, {}); + expect(strangerPage.agents).toHaveLength(0); + await expect(walletView(stranger, agentId)).rejects.toMatchObject({ + code: "agent_not_found", + status: 404, + }); + await expect(readPolicy(stranger, agentId)).rejects.toMatchObject({ + code: "agent_not_found", + status: 404, + }); + } finally { + await env.close(); + } + }); + + it("completes a policy_update lifecycle intent with one wallet signature", async () => { + const env = await setupCore("policy-update"); + try { + const actor = await env.actor(); + const { agentId, owner, signer } = await onboard(env, actor, "Updater"); + const created = await generateIntent(actor, { + type: "policy_update", + agentId, + policy: { ...policy, frozen: true }, + expectedRevision: 1, + }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "policy_update", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + const final = await readStatus(actor, generated.correlationId, 5000); + expect(final.status).toBe("SUCCESS"); + + const view = await readPolicy(actor, agentId); + expect(view.revision).toBe(2); + expect(view.policy?.frozen).toBe(true); + } finally { + await env.close(); + } + }); +}); diff --git a/plugins/agents/tests/db-layer.test.ts b/plugins/agents/tests/db-layer.test.ts new file mode 100644 index 000000000..6cdb0902f --- /dev/null +++ b/plugins/agents/tests/db-layer.test.ts @@ -0,0 +1,49 @@ +import { sql } from "drizzle-orm"; +import { Context, Effect, Layer } from "effect"; +import { PluginIdTag } from "every-plugin"; +import { describe, expect, it } from "vitest"; +import type { DatabaseDriver } from "../src/db"; +import { DatabaseLive, DatabaseTag } from "../src/db/layer"; + +function withDatabase(url: string, run: (driver: DatabaseDriver) => Promise): Promise { + const layer = DatabaseLive(url).pipe(Layer.provide(Layer.succeed(PluginIdTag, "agents"))); + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const scope = yield* Effect.scope; + const context = yield* Layer.buildWithScope(layer, scope); + const driver = Context.get(context, DatabaseTag); + yield* Effect.tryPromise(() => run(driver)); + }), + ), + ); +} + +describe("agents plugin database layer", () => { + it("applies the vendored migration history on a fresh pglite database", async () => { + await withDatabase( + `pglite:.bos/agents/test-${Math.random().toString(36).slice(2)}:memory:`, + async (driver) => { + const agentsRows = await driver.db + .select({ count: sql`count(*)::int` }) + .from((await import("@near-intents-agent-api/database/schema")).agents); + expect(Number(agentsRows[0]?.count)).toBe(0); + + const ownerNonces = await driver.db.execute( + sql`select count(*)::int as count from information_schema.tables where table_name = 'owner_nonces'`, + ); + expect(Number((ownerNonces.rows[0] as { count: number } | undefined)?.count)).toBe(1); + }, + ); + }); + + it("serializes work through the advisory-lock seam", async () => { + await withDatabase( + `pglite:.bos/agents/test-${Math.random().toString(36).slice(2)}:memory:`, + async (driver) => { + const result = await driver.withAdvisoryLock("test/lock", async () => "held"); + expect(result).toBe("held"); + }, + ); + }); +}); diff --git a/plugins/agents/tests/delegated-execution.test.ts b/plugins/agents/tests/delegated-execution.test.ts new file mode 100644 index 000000000..cbcc5aae1 --- /dev/null +++ b/plugins/agents/tests/delegated-execution.test.ts @@ -0,0 +1,188 @@ +import type { Actor } from "@near-intents-agent-api/agents-core"; +import { + generateIntent, + generateResponse, + listAgentGrants, + readStatus, + runExecution, + submitIntent, + transferExecution, +} from "@near-intents-agent-api/agents-core"; +import * as views from "@near-intents-agent-api/agents-core/views"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { createGrantCredential } from "./support/grant-credential"; +import { testDestinations } from "./support/grant-destinations"; +import { nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: false, requires_approval: false }, + }, + rules: { allowed_tokens: ["nep141:wrap.near"], transaction_types: ["transfer", "call"] }, +}); + +async function onboard(_env: Awaited>, actor: Actor, name: string) { + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { type: "agent_create", name, owner, policy }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + return { agentId: generated.agentId, signer }; +} + +async function issueGrant( + _env: Awaited>, + actor: Actor, + signer: ReturnType["signer"], + agentId: string, + actions: string[], + recipients: string[] = ["recipient.near"], +) { + const credential = createGrantCredential(); + const created = await generateIntent(actor, { + type: "grant_issue", + agentId, + label: "test grant", + credential: credential.commitment, + actions, + recipients: testDestinations(recipients), + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "grant_issue", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + const status = await readStatus(actor, generated.correlationId, 5000); + expect(status.status).toBe("SUCCESS"); + return credential.token; +} + +describe("delegated execution (ticket 11)", () => { + it("executes a grant-scoped transfer within policy and dispatches once", async () => { + const env = await setupCore("delegated-transfer"); + try { + const actor = await env.actor(); + const { agentId, signer } = await onboard(env, actor, "Executor"); + const token = await issueGrant(env, actor, signer, agentId, ["intents_transfer"]); + const before = env.provider.state.submissions; + const status = await runExecution( + actor, + agentId, + transferExecution( + { + asset: "nep141:wrap.near", + amount: "1", + recipient: "recipient.near", + confidential: false, + }, + "delegate-happy-0001", + ), + token, + ); + expect(status.status).toBe("SUCCESS"); + expect(env.provider.state.submissions).toBe(before + 1); + } finally { + await env.close(); + } + }); + + it("refuses an action outside the grant with the per-layer error", async () => { + const env = await setupCore("delegated-scope"); + try { + const actor = await env.actor(); + const { agentId, signer } = await onboard(env, actor, "Narrow grant"); + const token = await issueGrant(env, actor, signer, agentId, ["intents_transfer"]); + await expect( + runExecution(actor, agentId, swapExecutionFixture("delegate-swap-0001"), token), + ).rejects.toMatchObject({ code: "grant_action_denied", status: 403 }); + } finally { + await env.close(); + } + }); + + it("refuses work under a revoked grant", async () => { + const env = await setupCore("delegated-revoke"); + try { + const actor = await env.actor(); + const { agentId, signer } = await onboard(env, actor, "Revoked"); + const token = await issueGrant(env, actor, signer, agentId, ["intents_transfer"]); + const grants = (await listAgentGrants(actor, agentId)).map(views.grantView); + const revoke = await generateIntent(actor, { + type: "grant_revoke", + agentId, + grantId: grants[0]!.grantId, + }); + const revokeGenerated = generateResponse(revoke.row); + await submitIntent(actor, { + type: "grant_revoke", + correlationId: revokeGenerated.correlationId, + signedData: signer.sign(revokeGenerated.intent), + }); + await expect( + runExecution( + actor, + agentId, + transferExecution( + { + asset: "nep141:wrap.near", + amount: "1", + recipient: "recipient.near", + confidential: false, + }, + "delegate-revoked-0001", + ), + token, + ), + ).rejects.toMatchObject({ code: "agent_grant_required", status: 403 }); + } finally { + await env.close(); + } + }); + + it("refuses the same idempotency key with a different payload", async () => { + const env = await setupCore("delegated-idempotency"); + try { + const actor = await env.actor(); + const { agentId, signer } = await onboard(env, actor, "Idempotent"); + const token = await issueGrant(env, actor, signer, agentId, ["intents_transfer"]); + const transfer = (amount: string) => + transferExecution( + { asset: "nep141:wrap.near", amount, recipient: "recipient.near", confidential: false }, + "delegate-conflict-0001", + ); + await runExecution(actor, agentId, transfer("1"), token); + await expect(runExecution(actor, agentId, transfer("2"), token)).rejects.toMatchObject({ + code: "idempotency_conflict", + status: 409, + }); + } finally { + await env.close(); + } + }); +}); + +function swapExecutionFixture(idempotencyKey: string) { + return { + action: "swap" as const, + request: { + token_in: "nep141:wrap.near", + token_out: "nep141:usdc.token.near", + amount_in: "1", + idempotencyKey, + }, + }; +} diff --git a/plugins/agents/tests/grant-revoke.test.ts b/plugins/agents/tests/grant-revoke.test.ts new file mode 100644 index 000000000..0422b485f --- /dev/null +++ b/plugins/agents/tests/grant-revoke.test.ts @@ -0,0 +1,113 @@ +import { + generateIntent, + generateResponse, + listAgentGrants, + readStatus, + runExecution, + submitIntent, + transferExecution, +} from "@near-intents-agent-api/agents-core"; +import * as views from "@near-intents-agent-api/agents-core/views"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { createGrantCredential } from "./support/grant-credential"; +import { testDestinations } from "./support/grant-destinations"; +import { nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: true, requires_approval: false }, + }, + rules: { allowed_tokens: ["nep141:wrap.near"], transaction_types: ["transfer", "swap"] }, +}); + +describe("grant revocation (ticket 11 tail)", () => { + it("surfaces the committed correlation ids of work already dispatched", async () => { + const env = await setupCore("revoke-reporting"); + try { + const actor = await env.actor(); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name: "Revoker", + owner, + policy, + }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + await readStatus(actor, generated.correlationId, 5000); + const agentId = generated.agentId; + + const credential = createGrantCredential(); + const grantIntent = await generateIntent(actor, { + type: "grant_issue", + agentId, + label: "Committed work", + credential: credential.commitment, + actions: ["intents_transfer"], + recipients: testDestinations(["recipient.near"]), + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }); + const grantGenerated = generateResponse(grantIntent.row); + await submitIntent(actor, { + type: "grant_issue", + correlationId: grantGenerated.correlationId, + signedData: signer.sign(grantGenerated.intent), + }); + await readStatus(actor, grantGenerated.correlationId, 5000); + + env.provider.state.policyStatus = "pending"; + const executed = await runExecution( + actor, + agentId, + transferExecution( + { + asset: "nep141:wrap.near", + amount: "1", + recipient: "recipient.near", + confidential: false, + }, + "revoke-committed-0001", + ), + credential.token, + ); + expect(["PENDING", "UNCERTAIN", "PROCESSING"]).toContain(executed.status); + env.provider.state.policyStatus = "success"; + + const grants = (await listAgentGrants(actor, agentId)).map(views.grantView); + const revoke = await generateIntent(actor, { + type: "grant_revoke", + agentId, + grantId: grants[0]!.grantId, + }); + const revokeGenerated = generateResponse(revoke.row); + await submitIntent(actor, { + type: "grant_revoke", + correlationId: revokeGenerated.correlationId, + signedData: signer.sign(revokeGenerated.intent), + }); + const revokeStatus = await readStatus(actor, revokeGenerated.correlationId, 5000); + expect(revokeStatus.status).toBe("SUCCESS"); + const details = revokeStatus.details as { + committedCorrelationIds: string[] | null; + committedTruncated: boolean | null; + }; + expect(details.committedCorrelationIds).toContain(executed.correlationId); + expect(details.committedTruncated).toBe(false); + } finally { + await env.close(); + } + }); +}); diff --git a/plugins/agents/tests/grants.test.ts b/plugins/agents/tests/grants.test.ts new file mode 100644 index 000000000..d9e73e7e3 --- /dev/null +++ b/plugins/agents/tests/grants.test.ts @@ -0,0 +1,95 @@ +import type { Actor } from "@near-intents-agent-api/agents-core"; +import { + generateIntent, + generateResponse, + listAgentGrants, + readStatus, + submitIntent, +} from "@near-intents-agent-api/agents-core"; +import * as views from "@near-intents-agent-api/agents-core/views"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { createGrantCredential } from "./support/grant-credential"; +import { nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: true, requires_approval: false }, + }, + rules: { allowed_tokens: ["native"], transaction_types: ["transfer", "swap"] }, +}); + +async function onboard(_env: Awaited>, actor: Actor, name: string) { + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { type: "agent_create", name, owner, policy }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + return { agentId: generated.agentId, signer }; +} + +describe("grants (ticket 11)", () => { + it("issues a grant with one owner signature, keeps the token at rest, and revokes", async () => { + const env = await setupCore("grants"); + try { + const actor = await env.actor(); + const { agentId, signer } = await onboard(env, actor, "Grantor"); + const credential = createGrantCredential(); + const expiresAt = new Date(Date.now() + 3_600_000).toISOString(); + + const created = await generateIntent(actor, { + type: "grant_issue", + agentId, + label: "Trading assistant", + credential: credential.commitment, + actions: ["swap", "intents_transfer"], + expiresAt, + }); + const generated = generateResponse(created.row); + expect(generated.intent.standard).toBe("nep413"); + + await submitIntent(actor, { + type: "grant_issue", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + const status = await readStatus(actor, generated.correlationId, 5000); + expect(status.status).toBe("SUCCESS"); + + const grants = (await listAgentGrants(actor, agentId)).map(views.grantView); + expect(grants).toHaveLength(1); + expect(grants[0]?.label).toBe("Trading assistant"); + expect(grants[0]?.actions).toEqual(["swap", "intents_transfer"]); + expect(JSON.stringify(grants)).not.toContain(credential.token); + + const revoke = await generateIntent(actor, { + type: "grant_revoke", + agentId, + grantId: grants[0]!.grantId, + }); + const revokeGenerated = generateResponse(revoke.row); + await submitIntent(actor, { + type: "grant_revoke", + correlationId: revokeGenerated.correlationId, + signedData: signer.sign(revokeGenerated.intent), + }); + expect((await readStatus(actor, revokeGenerated.correlationId, 5000)).status).toBe("SUCCESS"); + const after = (await listAgentGrants(actor, agentId)).map(views.grantView); + expect(after).toHaveLength(1); + expect(after[0]?.revokedAt).toBeTruthy(); + } finally { + await env.close(); + } + }); +}); diff --git a/plugins/agents/tests/intent-flow.test.ts b/plugins/agents/tests/intent-flow.test.ts new file mode 100644 index 000000000..3541e057a --- /dev/null +++ b/plugins/agents/tests/intent-flow.test.ts @@ -0,0 +1,147 @@ +import { + type Actor, + configureDatabase, + configureNearAccessKeyVerifier, + configureNearAccountReader, + configureNearPolicyOwnerVerifier, + configureOutlayer, + configurePolicyStorageEstimator, + configureRuntime, + generateIntent, + generateResponse, +} from "@near-intents-agent-api/agents-core"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import type { OutlayerWalletClient } from "@near-intents-agent-api/outlayer"; +import { Context, Effect, Layer } from "effect"; +import { PluginIdTag } from "every-plugin"; +import { describe, expect, it } from "vitest"; +import { actorForSession } from "../src/actor"; +import type { DatabaseDriver } from "../src/db"; +import { DatabaseLive, DatabaseTag } from "../src/db/layer"; +import { closeServer, startNearRpc } from "./support/near-rpc-double"; + +const _sponsorPrivateKey = + "ed25519:51wkXZuAj4mUpd8GskACyNj5omyifyUEKGECqiVRviBzT4gTFAFAVD5jYcmMdFEHRcDLt2iktJ6irQtzpa8PBmso"; + +function fakeOutlayerClient(): OutlayerWalletClient { + return { + contractId: "intents.near", + async registerWallet() { + return { + api_key: "wk_test", + near_account_id: "a".repeat(64), + wallet_id: "wallet-test-1", + }; + }, + async address() { + return { address: `0x${"c".repeat(40)}` }; + }, + async encryptPolicy() { + return { encrypted_base64: "ZmFrZS1lbmNyeXB0ZWQ=" }; + }, + async signPolicy() { + return { + public_key_hex: "a".repeat(64), + signature_hex: "b".repeat(128), + }; + }, + } as unknown as OutlayerWalletClient; +} + +function configureTestInfra(nearRpcUrl: string) { + configureOutlayer(fakeOutlayerClient()); + configurePolicyStorageEstimator(async () => "0"); + configureNearAccessKeyVerifier({ async verifyFullAccess() {} }); + configureNearAccountReader({ + async exists() { + return false; + }, + }); + configureNearPolicyOwnerVerifier(undefined); + configureRuntime({ + trustedOrigins: ["https://citynode.app"], + secretEncryptionKeys: { "env:v1": "t".repeat(32) }, + secretEncryptionActiveKeyId: "env:v1", + nearRpcUrls: [nearRpcUrl], + network: "mainnet", + serviceUrl: "https://citynode.app", + sponsorDailyGlobalLimit: 100, + sponsorDailyTenantLimit: 20, + sponsorDailyAgentLimit: 10, + logLevel: "error", + }); +} + +function withDriver(url: string, run: (driver: DatabaseDriver) => Promise): Promise { + const layer = DatabaseLive(url).pipe(Layer.provide(Layer.succeed(PluginIdTag, "agents"))); + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const scope = yield* Effect.scope; + const context = yield* Layer.buildWithScope(layer, scope); + const driver = Context.get(context, DatabaseTag); + yield* Effect.tryPromise(() => run(driver)); + }), + ), + ); +} + +describe("owner intent flow (tracer bullet)", () => { + it("generates an agent_create intent end-to-end and reports it PENDING_SIGNATURE", async () => { + const rpc = await startNearRpc(); + try { + configureTestInfra(rpc.url); + await withDriver( + `pglite:.bos/agents/flow-${Math.random().toString(36).slice(2)}:memory:`, + async (driver) => { + configureDatabase(driver); + const actor: Actor = await actorForSession(driver, "user-1"); + const { row, replayed } = await generateIntent(actor, { + type: "agent_create", + name: "Chicago treasury agent", + owner: { + type: "near", + accountId: "owner.citynode.near", + publicKey: "ed25519:8hmo5Y36doLTzDnSUjR3bXntLW6VFmPrHLbnL5hMwwCp", + }, + policy: policySchema.parse({ + version: 1, + frozen: false, + capabilities: minimalCapabilities(), + rules: minimalRules(), + }), + }); + + expect(replayed).toBe(false); + const response = generateResponse(row); + expect(response.type).toBe("agent_create"); + expect(response.status).toBe("PENDING_SIGNATURE"); + expect(response.agentId).toBeTruthy(); + expect(response.correlationId).toBe(row.id); + expect((response.intent as { standard: string }).standard).toBe("nep366"); + expect(response.expiresAt > new Date().toISOString()).toBe(true); + }, + ); + } finally { + await closeServer(rpc.server); + } + }); +}); + +function minimalCapabilities() { + return { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: false }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: false }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: true, requires_approval: false }, + }; +} + +function minimalRules() { + return { + allowed_tokens: ["nep141:usdc.token.near"], + transaction_types: ["swap", "intents_transfer"], + }; +} diff --git a/plugins/agents/tests/intent-invariants.test.ts b/plugins/agents/tests/intent-invariants.test.ts new file mode 100644 index 000000000..f4611c040 --- /dev/null +++ b/plugins/agents/tests/intent-invariants.test.ts @@ -0,0 +1,155 @@ +import { + configureOutlayer, + generateIntent, + generateResponse, + readPolicy, + readStatus, + requirePrivilegedArtifactDeliveryAllowed, + requireReadyPolicy, + submitIntent, + updateOwnerIntent, +} from "@near-intents-agent-api/agents-core"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import { agents, auditEvents, ownerNonces } from "@near-intents-agent-api/database"; +import { eq, sql } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: false, requires_approval: false }, + }, + rules: { allowed_tokens: ["native"], transaction_types: ["transfer"] }, +}); + +describe("intent invariants (ticket 12)", () => { + it("an unsigned freeze proposal blocks nothing; an accepted freeze blocks privileged delivery", async () => { + const env = await setupCore("draft-authority"); + try { + const { actor, signer, agentId } = await onboard(env); + const encrypted = env.provider.encryptedPolicies.at(-1); + expect(encrypted).toBeTruthy(); + const providerPolicy = JSON.parse(Buffer.from(encrypted ?? "", "base64").toString()); + const provider = env.provider; + configureOutlayer({ + ...provider.client, + async policy() { + return providerPolicy; + }, + } as never); + await generateIntent(actor, { type: "agent_freeze", agentId }); + await generateIntent(actor, { type: "policy_update", agentId, policy, expectedRevision: 1 }); + await requireReadyPolicy(actor.tenantId, agentId); + await env.database.db.transaction((tx) => + requirePrivilegedArtifactDeliveryAllowed(tx, actor.tenantId, agentId, "signing"), + ); + expect((await readPolicy(actor, agentId)).revision).toBe(1); + + const freeze = await generateIntent(actor, { type: "agent_freeze", agentId }); + const freezeGenerated = generateResponse(freeze.row); + env.ownerSponsor.setStatus("pending"); + const accepted = await submitIntent(actor, { + type: "agent_freeze", + correlationId: freezeGenerated.correlationId, + signedData: signer.sign(freezeGenerated.intent), + }); + expect(accepted.status).toBe("PROCESSING"); + await expect(requireReadyPolicy(actor.tenantId, agentId)).rejects.toThrow(/policy_not_ready/); + await expect( + env.database.db.transaction((tx) => + requirePrivilegedArtifactDeliveryAllowed(tx, actor.tenantId, agentId, "signing"), + ), + ).rejects.toThrow(); + } finally { + await env.close(); + } + }); +}); + +describe("intent atomicity (ticket 12)", () => { + it("rolls back a failed completion atomically and commits once on retry", async () => { + const env = await setupCore("intent-atomic"); + try { + const { actor, signer, agentId } = await onboard(env); + const generated0 = await generateIntent(actor, { + type: "timelock_set", + agentId, + delaySeconds: 3600, + }); + const generated = generateResponse(generated0.row); + const [before] = await env.database.db.select().from(agents).where(eq(agents.id, agentId)); + const noncesBefore = await env.database.db.select().from(ownerNonces); + const submit = () => + submitIntent(actor, { + type: "timelock_set", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + await env.database.db.execute( + sql`CREATE FUNCTION reject_intent_completion() RETURNS trigger LANGUAGE plpgsql AS $atomic$ BEGIN IF NEW.state = 'completed' THEN RAISE EXCEPTION 'injected_completion_failure'; END IF; RETURN NEW; END $atomic$`, + ); + await env.database.db.execute( + sql`CREATE TRIGGER reject_completion BEFORE UPDATE ON owner_intents FOR EACH ROW EXECUTE FUNCTION reject_intent_completion()`, + ); + await expect(submit()).rejects.toThrow(); + const [rolledBack] = await env.database.db + .select() + .from(agents) + .where(eq(agents.id, agentId)); + expect(rolledBack?.timelockRevision).toBe(before?.timelockRevision ?? null); + expect(rolledBack?.policyEpoch).toBe(before?.policyEpoch); + expect(await env.database.db.select().from(ownerNonces)).toEqual(noncesBefore); + expect((await readStatus(actor, generated.correlationId)).status).toBe("PENDING_SIGNATURE"); + await env.database.db.execute(sql`DROP TRIGGER reject_completion ON owner_intents`); + await env.database.db.execute(sql`DROP FUNCTION reject_intent_completion`); + const completed = await submit(); + expect(completed.status).toBe("SUCCESS"); + expect(await submit()).toEqual(completed); + const [after] = await env.database.db.select().from(agents).where(eq(agents.id, agentId)); + expect(after?.timelockRevision).toBe((before?.timelockRevision ?? 0) + 1); + const audits = await env.database.db + .select() + .from(auditEvents) + .where(eq(auditEvents.action, "timelock.updated")); + expect(audits).toHaveLength(1); + await updateOwnerIntent( + actor.tenantId, + generated.correlationId, + { state: "failed", failureCode: "intent-expired" }, + "pending_signature", + ); + await updateOwnerIntent(actor.tenantId, generated.correlationId, { state: "submitted" }); + expect(await readStatus(actor, generated.correlationId)).toEqual(completed); + } finally { + await env.close(); + } + }); +}); + +async function onboard(env: Awaited>) { + const actor = await env.actor(); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name: "Invariant", + owner, + policy, + }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + const status = await readStatus(actor, generated.correlationId, 5000); + expect(status.status).toBe("SUCCESS"); + return { actor, signer, agentId: generated.agentId }; +} diff --git a/plugins/agents/tests/intent-submit.test.ts b/plugins/agents/tests/intent-submit.test.ts new file mode 100644 index 000000000..78fb25489 --- /dev/null +++ b/plugins/agents/tests/intent-submit.test.ts @@ -0,0 +1,137 @@ +import { + generateIntent, + generateResponse, + readStatus, + submitIntent, +} from "@near-intents-agent-api/agents-core"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import { sql } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: false, requires_approval: false }, + }, + rules: { allowed_tokens: ["native"], transaction_types: ["transfer"] }, +}); + +describe("owner intent submit (sponsor relay leg)", () => { + it("binds the owner only once the onboarding transaction finalizes", async () => { + const env = await setupCore("onboarding-final"); + try { + const actor = await env.actor(); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name: "Finality", + owner, + policy, + }); + const generated = generateResponse(created.row); + env.ownerSponsor.setStatus("pending"); + const submitted = await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + expect(submitted.status).toBe("PROCESSING"); + expect(env.ownerSponsor.calls.length).toBe(1); + + env.ownerSponsor.setStatus("succeeded"); + env.ownerSponsor.setStatus("succeeded"); + const live = await readStatus(actor, generated.correlationId, 5000); + expect(live.status).toBe("SUCCESS"); + } finally { + await env.close(); + } + }); + + it("dedupes an interrupted generation by idempotency key", async () => { + const env = await setupCore("idempotent-generate"); + try { + const actor = await env.actor(); + const { owner } = nearOwnerFixture(); + const first = await generateIntent( + actor, + { type: "agent_create", name: "Recoverable", owner, policy }, + "intent-key-1", + ); + const second = await generateIntent( + actor, + { type: "agent_create", name: "Recoverable", owner, policy }, + "intent-key-1", + ); + expect(second.replayed).toBe(true); + expect(second.row.id).toBe(first.row.id); + expect(second.row.intent).toEqual(first.row.intent); + } finally { + await env.close(); + } + }); + + it("fails an expired unsigned intent per the wire contract", async () => { + const env = await setupCore("expired-intent"); + try { + const actor = await env.actor(); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name: "Late", + owner, + policy, + }); + const generated = generateResponse(created.row); + await env.database.db.execute( + sql`update owner_intents set expires_at = now() - interval '1 hour' where id = ${generated.correlationId}`, + ); + await expect( + submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }), + ).rejects.toMatchObject({ code: "intent_expired", status: 409 }); + } finally { + await env.close(); + } + }); + + it("replays the settled status instead of rebroadcasting on a second submission", async () => { + const env = await setupCore("double-submit"); + try { + const actor = await env.actor(); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name: "Once", + owner, + policy, + }); + const generated = generateResponse(created.row); + const signedData = signer.sign(generated.intent); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData, + }); + const second = await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData, + }); + expect(second.status).toBe("SUCCESS"); + expect(env.ownerSponsor.calls.length).toBe(1); + } finally { + await env.close(); + } + }); +}); diff --git a/plugins/agents/tests/pg-agent-races.test.ts b/plugins/agents/tests/pg-agent-races.test.ts new file mode 100644 index 000000000..8693e3234 --- /dev/null +++ b/plugins/agents/tests/pg-agent-races.test.ts @@ -0,0 +1,135 @@ +import { + configureDatabase, + configureOutlayer, + configureOwnerPolicySponsor, + configurePolicyStorageEstimator, + configureRuntime, + configureWalletSponsor, + generateIntent, + generateResponse, + readStatus, + runExecution, + submitIntent, + transferExecution, +} from "@near-intents-agent-api/agents-core"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import { afterAll, describe, expect, it } from "vitest"; +import { actorForSession } from "../src/actor"; +import { stubOwnerSponsor, stubProvider, stubWalletSponsor } from "./support/agent-harness"; +import { createGrantCredential } from "./support/grant-credential"; +import { testDestinations } from "./support/grant-destinations"; +import { nearOwnerFixture } from "./support/intent-signers"; +import { closeServer, startNearRpc } from "./support/near-rpc-double"; +import { + dropRaceDatabase, + raceDatabaseUrl, + raceSuitesEnabled, + resetRaceDatabase, + withDatabase, +} from "./support/race-db"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: false, requires_approval: false }, + }, + rules: { allowed_tokens: ["nep141:wrap.near"], transaction_types: ["transfer", "swap"] }, +}); + +afterAll(() => dropRaceDatabase("agents_race_test")); + +describe.skipIf(!raceSuitesEnabled())("concurrent admission against postgres (ticket 12)", () => { + it("concurrent grant dispatches of one request commit the provider write exactly once", async () => { + await resetRaceDatabase("agents_race_test"); + const rpc = await startNearRpc(); + try { + await withDatabase(raceDatabaseUrl("agents_race_test"), async (driver) => { + configureDatabase(driver); + const provider = stubProvider(); + configureOutlayer(provider.client); + configureOwnerPolicySponsor(stubOwnerSponsor().sponsor as never); + configureWalletSponsor(stubWalletSponsor().sponsor as never); + configurePolicyStorageEstimator(async () => "0"); + configureRuntime({ + trustedOrigins: ["https://citynode.app"], + secretEncryptionKeys: { "env:v1": "t".repeat(32) }, + secretEncryptionActiveKeyId: "env:v1", + nearRpcUrls: [rpc.url], + network: "mainnet", + serviceUrl: "https://citynode.app", + sponsorDailyGlobalLimit: 100, + sponsorDailyTenantLimit: 20, + sponsorDailyAgentLimit: 10, + walletPolicyStorageLimitYocto: "11900000000000000000000", + sponsorAccountId: "sponsor.near", + logLevel: "error", + }); + const actor = await actorForSession(driver, "race-user"); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name: "Racer", + owner, + policy, + }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + await readStatus(actor, generated.correlationId, 5000); + + const credential = createGrantCredential(); + const grantIntent = await generateIntent(actor, { + type: "grant_issue", + agentId: generated.agentId, + label: "Racing delegate", + credential: credential.commitment, + actions: ["intents_transfer"], + recipients: testDestinations(["recipient.near"]), + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }); + const grantGenerated = generateResponse(grantIntent.row); + await submitIntent(actor, { + type: "grant_issue", + correlationId: grantGenerated.correlationId, + signedData: signer.sign(grantGenerated.intent), + }); + await readStatus(actor, grantGenerated.correlationId, 5000); + + const before = provider.state.submissions; + const attempt = () => + runExecution( + actor, + generated.agentId, + transferExecution( + { + asset: "nep141:wrap.near", + amount: "1", + recipient: "recipient.near", + confidential: false, + }, + "race-once-0001", + ), + credential.token, + ); + const results = await Promise.all(Array.from({ length: 8 }, attempt)); + for (const result of results) { + expect(result.correlationId).toBe(results[0]?.correlationId); + } + expect(provider.state.submissions).toBe(before + 1); + const status = await readStatus(actor, results[0]?.correlationId ?? "", 5000); + expect(status.status).toBe("SUCCESS"); + }); + } finally { + await closeServer(rpc.server); + } + }); +}); diff --git a/plugins/agents/tests/pg-maintenance-races.test.ts b/plugins/agents/tests/pg-maintenance-races.test.ts new file mode 100644 index 000000000..c94304a1a --- /dev/null +++ b/plugins/agents/tests/pg-maintenance-races.test.ts @@ -0,0 +1,197 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { sweepUnsettledPolicies } from "@near-intents-agent-api/agents-core"; +import { + agents, + auditEvents, + operations, + operationTombstones, + tenants, + user, +} from "@near-intents-agent-api/database"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, describe, expect, it } from "vitest"; +import type { DatabaseDriver } from "../src/db"; +import { + dropRaceDatabase, + raceDatabaseUrl, + raceSuitesEnabled, + resetRaceDatabase, + withDatabase, +} from "./support/race-db"; + +const twoHoursAgo = () => new Date(Date.now() - 2 * 60 * 60_000); + +async function seed(database: DatabaseDriver) { + const id = crypto.randomUUID(); + await database.db.insert(user).values({ id, name: "maintenance", email: `${id}@test.invalid` }); + await database.db.insert(tenants).values({ id, ownerUserId: id }); + const agent = async (name: string) => { + const agentId = `${id}-${name}`; + await database.db + .insert(agents) + .values({ id: agentId, tenantId: id, name, createdAt: twoHoursAgo() }); + return agentId; + }; + const interrupted = async (agentId: string) => { + const operationId = `${agentId}-preparation`; + await database.db.insert(operations).values({ + id: operationId, + tenantId: id, + agentId, + kind: "policy", + action: "onboarding", + requestHash: operationId, + status: "uncertain", + result: {}, + updatedAt: twoHoursAgo(), + }); + return operationId; + }; + const state = async (agentId: string, operationId?: string) => { + const [agent] = await database.db.select().from(agents).where(eq(agents.id, agentId)); + const [operation] = operationId + ? await database.db.select().from(operations).where(eq(operations.id, operationId)) + : []; + const events = await database.db + .select({ action: auditEvents.action }) + .from(auditEvents) + .where(eq(auditEvents.agentId, agentId)); + return { lifecycle: agent?.lifecycle, operation, events: events.map((e) => e.action) }; + }; + const cleanup = async () => { + await database.db.delete(auditEvents).where(eq(auditEvents.tenantId, id)); + await database.db.delete(operations).where(eq(operations.tenantId, id)); + await database.db.delete(operationTombstones).where(eq(operationTombstones.tenantId, id)); + await database.db.delete(agents).where(eq(agents.tenantId, id)); + await database.db.delete(tenants).where(eq(tenants.id, id)); + await database.db.delete(user).where(eq(user.id, id)); + }; + return { agent, interrupted, state, cleanup }; +} + +type Tx = Parameters[0] extends ( + tx: infer T, +) => Promise + ? T + : never; + +/** + * Runs `work` in its own connection and holds the transaction open until released, so a sweep + * sampled meanwhile sees the state before `work` and has to decide against the state after it. + */ +async function hold(database: DatabaseDriver, work: (tx: Tx) => Promise) { + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + let ready!: () => void; + const started = new Promise((resolve) => { + ready = resolve; + }); + const done = database.db.transaction(async (tx) => { + await work(tx as unknown as Tx); + ready(); + await released; + }); + await Promise.race([started, done]); + return { release, done }; +} + +/** Resolves once the sweep blocks on a row lock, or finishes without needing one. */ +async function blockedOrDone(database: DatabaseDriver, sweep: Promise) { + let finished = false; + void sweep.finally(() => { + finished = true; + }); + for (let attempt = 0; attempt < 400 && !finished; attempt += 1) { + const { rows } = await database.db.execute<{ waiting: number }>( + sql`SELECT count(*)::int AS waiting FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock'`, + ); + if ((rows[0]?.waiting ?? 0) > 0) return; + await sleep(10); + } +} + +afterAll(() => dropRaceDatabase("agents_maintenance_race_test")); + +describe.skipIf(!raceSuitesEnabled())("maintenance races against postgres (ticket 12)", () => { + it("a stale interrupted-preparation sweep never fails work that progressed", { + timeout: 30_000, + }, async () => { + await resetRaceDatabase("agents_maintenance_race_test"); + await withDatabase(raceDatabaseUrl("agents_maintenance_race_test"), async (database) => { + const { agent, interrupted, state, cleanup } = await seed(database); + try { + const progress = { + "preparation stored": { + status: "pending" as const, + result: { + status: "pending_wallet_signature", + policy_id: "policy-1", + onboarding: true, + }, + }, + "relay hash journaled": { + status: "uncertain" as const, + result: { + status: "dispatching", + policy_id: "policy-1", + onboarding: true, + transaction_hash: "tx-1", + }, + }, + "already settled": { + status: "completed" as const, + result: { status: "applied", policy_id: "policy-1", transaction_hash: "tx-1" }, + }, + }; + for (const [name, next] of Object.entries(progress)) { + const agentId = await agent(name.replaceAll(" ", "-")); + const operationId = await interrupted(agentId); + // A resumed worker writes its progress; the sweep samples the row before that commits. + const worker = await hold(database, async (tx) => { + await tx + .update(operations) + .set({ ...next, updatedAt: sql`clock_timestamp()` }) + .where(eq(operations.id, operationId)); + }); + const sweep = sweepUnsettledPolicies(); + await blockedOrDone(database, sweep); + worker.release(); + await worker.done; + await sweep; + const after = await state(agentId, operationId); + expect(after.operation?.status, name).toBe(next.status); + expect(after.operation?.result, name).toEqual(next.result); + expect(after.lifecycle, name).toBe("pending"); + expect(after.events, name).toEqual([]); + } + } finally { + await cleanup(); + } + }); + }); + + it("concurrent sweeps fail a truly interrupted preparation once", async () => { + await resetRaceDatabase("agents_maintenance_race_test"); + await withDatabase(raceDatabaseUrl("agents_maintenance_race_test"), async (database) => { + const { agent, interrupted, state, cleanup } = await seed(database); + try { + const agentId = await agent("interrupted"); + const operationId = await interrupted(agentId); + await Promise.all([sweepUnsettledPolicies(), sweepUnsettledPolicies()]); + const after = await state(agentId, operationId); + expect(after.operation?.status).toBe("failed"); + expect(after.operation?.result).toEqual({ + status: "failed", + failure_code: "preparation_interrupted", + }); + expect(after.lifecycle).toBe("abandoned"); + expect(after.events.sort()).toEqual(["agent.abandoned", "policy.failed"]); + } finally { + await cleanup(); + } + }); + }); +}); diff --git a/plugins/agents/tests/pg-policy-finalization.test.ts b/plugins/agents/tests/pg-policy-finalization.test.ts new file mode 100644 index 000000000..a4d35d533 --- /dev/null +++ b/plugins/agents/tests/pg-policy-finalization.test.ts @@ -0,0 +1,167 @@ +import { finalizePolicyOperation } from "@near-intents-agent-api/agents-core"; +import { + agents, + auditEvents, + operations, + tenants, + user, + walletPolicies, +} from "@near-intents-agent-api/database"; +import { and, eq } from "drizzle-orm"; +import { afterAll, describe, expect, it } from "vitest"; +import type { DatabaseDriver } from "../src/db"; +import { + dropRaceDatabase, + raceDatabaseUrl, + raceSuitesEnabled, + resetRaceDatabase, + withDatabase, +} from "./support/race-db"; + +async function seed(database: DatabaseDriver) { + const id = crypto.randomUUID(); + await database.db.insert(user).values({ id, name: "finalize", email: `${id}@test.invalid` }); + await database.db.insert(tenants).values({ id, ownerUserId: id }); + await database.db.insert(agents).values({ id, tenantId: id, name: "finalize" }); + let version = 0; + const revision = async (transactionHash: string | null) => { + version += 1; + const policyId = `${id}-policy-${version}`; + const operationId = `${id}-operation-${version}`; + await database.db.insert(walletPolicies).values({ + id: policyId, + tenantId: id, + agentId: id, + walletId: id, + version, + policyHash: policyId, + encryptedData: "sealed", + signatureHex: "0".repeat(128), + publicKeyHex: "0".repeat(64), + rules: { frozen: false }, + status: "signed", + }); + const [agent] = await database.db.select().from(agents).where(eq(agents.id, id)); + expect(agent).toBeTruthy(); + const [inserted] = await database.db + .insert(operations) + .values({ + id: operationId, + tenantId: id, + agentId: id, + kind: "policy", + requestHash: operationId, + status: "uncertain", + result: { status: "submitted", policy_id: policyId, transaction_hash: transactionHash }, + authorizedOwnerEpoch: agent?.ownerEpoch, + authorizedPolicyEpoch: agent?.policyEpoch, + authorizedLifecycleEpoch: agent?.lifecycleEpoch, + }) + .returning(); + const operation = inserted!; + expect(operation).toBeTruthy(); + return { policyId, operation }; + }; + const state = async (policyId: string, operationId: string) => { + const [agent] = await database.db.select().from(agents).where(eq(agents.id, id)); + const [operation] = await database.db + .select() + .from(operations) + .where(eq(operations.id, operationId)); + const [policy] = await database.db + .select() + .from(walletPolicies) + .where(eq(walletPolicies.id, policyId)); + const applied = await database.db + .select() + .from(auditEvents) + .where(and(eq(auditEvents.action, "policy.applied"), eq(auditEvents.resourceId, policyId))); + expect(agent && operation && policy).toBeTruthy(); + return { epoch: agent?.policyEpoch, operation, policy, appliedEvents: applied.length }; + }; + const cleanup = async () => { + await database.db.delete(auditEvents).where(eq(auditEvents.tenantId, id)); + await database.db.delete(operations).where(eq(operations.tenantId, id)); + await database.db.delete(walletPolicies).where(eq(walletPolicies.tenantId, id)); + await database.db.delete(agents).where(eq(agents.tenantId, id)); + await database.db.delete(tenants).where(eq(tenants.id, id)); + await database.db.delete(user).where(eq(user.id, id)); + }; + return { id, revision, state, cleanup }; +} + +afterAll(() => dropRaceDatabase("agents_finalize_race_test")); + +describe.skipIf(!raceSuitesEnabled())( + "policy finalization races against postgres (ticket 12)", + () => { + it("concurrent policy confirmations apply a revision and advance its epoch once", async () => { + await resetRaceDatabase("agents_finalize_race_test"); + await withDatabase(raceDatabaseUrl("agents_finalize_race_test"), async (database) => { + const { id, revision, state, cleanup } = await seed(database); + try { + const { policyId, operation } = await revision("tx-applied"); + const before = await state(policyId, operation.id); + const results = await Promise.all( + Array.from({ length: 5 }, () => + finalizePolicyOperation({ + tenantId: id, + agentId: id, + operationId: operation.id, + policyId, + observed: operation, + outcome: { status: "applied", transactionHash: "tx-applied" }, + result: { status: "applied", transaction_hash: "tx-applied" }, + }), + ), + ); + for (const result of results) expect(result?.status).toBe("completed"); + const after = await state(policyId, operation.id); + expect(after.epoch).toBe((before.epoch ?? 0) + 1); + expect(after.appliedEvents).toBe(1); + expect(after.policy?.status).toBe("applied"); + } finally { + await cleanup(); + } + }); + }); + + it("racing success and failure settle policy and operation consistently", async () => { + await resetRaceDatabase("agents_finalize_race_test"); + await withDatabase(raceDatabaseUrl("agents_finalize_race_test"), async (database) => { + const { id, revision, state, cleanup } = await seed(database); + try { + for (const order of ["applied-first", "failed-first"] as const) { + const { policyId, operation } = await revision(`tx-${order}`); + const settle = (status: "applied" | "failed") => + finalizePolicyOperation({ + tenantId: id, + agentId: id, + operationId: operation.id, + policyId, + observed: operation, + outcome: + status === "applied" + ? { status, transactionHash: `tx-${order}` } + : { status, failureCode: "policy_transaction_dropped" }, + result: { status, transaction_hash: `tx-${order}` }, + }); + await Promise.allSettled( + order === "applied-first" + ? [settle("applied"), settle("failed")] + : [settle("failed"), settle("applied")], + ); + const { operation: settled, policy } = await state(policyId, operation.id); + expect( + (settled?.status === "completed" && policy?.status === "applied") || + (settled?.status === "failed" && policy?.status === "failed"), + `${order}: operation ${settled?.status} with policy ${policy?.status}`, + ).toBe(true); + } + } finally { + await cleanup(); + } + }); + }); + }, +); diff --git a/plugins/agents/tests/pg-spend-budget-races.test.ts b/plugins/agents/tests/pg-spend-budget-races.test.ts new file mode 100644 index 000000000..f0b843551 --- /dev/null +++ b/plugins/agents/tests/pg-spend-budget-races.test.ts @@ -0,0 +1,292 @@ +import { + claimOperationDispatch, + commitExecutionDispatch, + configurePrices, + createPendingOperation, + readBudgetFor, +} from "@near-intents-agent-api/agents-core"; +import { + agents, + auditEvents, + operations, + operationTombstones, + spendCharges, + tenants, + user, +} from "@near-intents-agent-api/database"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, describe, expect, it } from "vitest"; +import type { DatabaseDriver } from "../src/db"; +import { + dropRaceDatabase, + raceDatabaseUrl, + raceSuitesEnabled, + resetRaceDatabase, + withDatabase, +} from "./support/race-db"; + +const token = "nep141:wrap.near"; +const transfer = (idempotencyKey: string) => ({ + action: "intents_transfer" as const, + request: { token, amount: "1000000", to: "r.near", idempotencyKey }, +}); +const oneDollar = () => ({ decimals: 6, coefficient: 1n, scale: 0, updatedAtMs: Date.now() }); +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const codeOf = (error: unknown) => (error as { code?: string }).code; +const settle = (attempt: Promise) => + attempt.then( + () => "committed", + (error) => codeOf(error) ?? "error", + ); + +type Caps = { daily?: number; weekly?: number; monthly?: number }; + +/** A tenant and agent under `caps` (cents), and helpers to admit and commit operations for it. */ +async function fixture(database: DatabaseDriver, caps: Caps) { + const id = crypto.randomUUID(); + await database.db.insert(user).values({ id, name: "spend", email: `${id}@test.invalid` }); + await database.db.insert(tenants).values({ id, ownerUserId: id }); + await database.db.insert(agents).values({ + id, + tenantId: id, + name: "spend", + budgetDailyCents: caps.daily ?? null, + budgetWeeklyCents: caps.weekly ?? null, + budgetMonthlyCents: caps.monthly ?? null, + budgetRevision: 1, + }); + const actor = { tenantId: id, keyId: "key" }; + return { + id, + /** An admitted, claimed execution: what a worker holds just before its commitment. */ + async admit(name: string) { + const operationId = `${id}-${name}`; + await createPendingOperation({ + id: operationId, + tenantId: id, + agentId: id, + kind: "execute", + actorKeyId: "key", + requestHash: operationId, + authorizationEpochs: { ownerEpoch: 1, policyEpoch: 1, lifecycleEpoch: 1 }, + }); + const claimed = await claimOperationDispatch(id, id, operationId, "pending", operationId); + expect(claimed).toBe(true); + return operationId; + }, + commit: (operationId: string) => + commitExecutionDispatch(actor, id, operationId, transfer(operationId.slice(-30))), + setCaps: (tx: Pick, next: Caps) => + tx + .update(agents) + .set({ + budgetDailyCents: next.daily ?? null, + budgetWeeklyCents: next.weekly ?? null, + budgetMonthlyCents: next.monthly ?? null, + budgetRevision: sql`${agents.budgetRevision} + 1`, + }) + .where(eq(agents.id, id)), + async committedAt(operationId: string) { + const [row] = await database.db + .select({ at: operations.dispatchCommittedAt }) + .from(operations) + .where(eq(operations.id, operationId)); + return row?.at ?? null; + }, + charges: () => database.db.select().from(spendCharges).where(eq(spendCharges.agentId, id)), + spent: async () => (await readBudgetFor(id, id)).daily.spent_usd, + async cleanup() { + await database.db.delete(spendCharges).where(eq(spendCharges.agentId, id)); + await database.db.delete(operationTombstones).where(eq(operationTombstones.agentId, id)); + await database.db.delete(auditEvents).where(eq(auditEvents.tenantId, id)); + await database.db.delete(operations).where(eq(operations.agentId, id)); + await database.db.delete(agents).where(eq(agents.tenantId, id)); + await database.db.delete(tenants).where(eq(tenants.id, id)); + await database.db.delete(user).where(eq(user.id, id)); + }, + }; +} + +/** Holds the agent row exclusively, as a budget change does, until `release` is called. */ +async function holdAgent( + database: DatabaseDriver, + apply: (tx: DatabaseDriver["db"]) => Promise, +) { + let release!: () => void; + let locked!: () => void; + const ready = new Promise((resolve) => { + locked = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const held = database.db.transaction(async (tx) => { + await apply(tx as unknown as DatabaseDriver["db"]); + locked(); + await gate; + }); + await ready; + return { release, done: held }; +} + +afterAll(() => dropRaceDatabase("agents_budget_race_test")); + +describe.skipIf(!raceSuitesEnabled())("spend-budget races against postgres (ticket 12)", () => { + it("concurrent commitments never charge past the cap, and only the ones that fit dispatch", { + timeout: 30_000, + }, async () => { + await resetRaceDatabase("agents_budget_race_test"); + configurePrices({ price: async () => oneDollar() }); + await withDatabase(raceDatabaseUrl("agents_budget_race_test"), async (database) => { + // $3.50 a day fits three $1 transfers and not a fourth. + const f = await fixture(database, { daily: 350 }); + try { + const ids = await Promise.all( + Array.from({ length: 24 }, (_, index) => f.admit(`op-${index}`)), + ); + const outcomes = await Promise.all(ids.map((operationId) => settle(f.commit(operationId)))); + expect(outcomes.filter((outcome) => outcome === "committed")).toHaveLength(3); + for (const outcome of outcomes) + if (outcome !== "committed") expect(outcome).toBe("spend_budget_exceeded"); + expect(await f.spent()).toBe("3.000000"); + + const charged = new Set((await f.charges()).map((row) => row.operationId)); + for (const [index, operationId] of ids.entries()) + expect( + (await f.committedAt(operationId)) !== null, + `${operationId} (${outcomes[index]})`, + ).toBe(charged.has(operationId)); + expect(charged.size).toBe(3); + } finally { + await f.cleanup(); + } + }); + configurePrices(undefined); + }); + + it("a cap that commits before an uncapped dispatch commits governs it", async () => { + await resetRaceDatabase("agents_budget_race_test"); + await withDatabase(raceDatabaseUrl("agents_budget_race_test"), async (database) => { + let lookups = 0; + configurePrices({ + price: async () => { + lookups += 1; + return oneDollar(); + }, + }); + const f = await fixture(database, {}); + try { + const operationId = await f.admit("first-cap"); + const owner = await holdAgent(database, (tx) => f.setCaps(tx, { daily: 50 })); + const dispatch = settle(f.commit(operationId)); + await wait(150); + owner.release(); + await owner.done; + expect(await dispatch).toBe("spend_budget_exceeded"); + expect(lookups >= 1, "the cap that appeared is priced, outside any lock").toBe(true); + expect(await f.committedAt(operationId)).toBeNull(); + expect(await f.charges()).toEqual([]); + } finally { + await f.cleanup(); + } + }); + configurePrices(undefined); + }); + + it("a cap lowered before a dispatch commits is applied to it", async () => { + await resetRaceDatabase("agents_budget_race_test"); + await withDatabase(raceDatabaseUrl("agents_budget_race_test"), async (database) => { + configurePrices({ price: async () => oneDollar() }); + const f = await fixture(database, { daily: 200 }); + try { + const operationId = await f.admit("lowered"); + const owner = await holdAgent(database, (tx) => f.setCaps(tx, { daily: 50 })); + const dispatch = settle(f.commit(operationId)); + await wait(150); + owner.release(); + await owner.done; + expect(await dispatch).toBe("spend_budget_exceeded"); + expect(await f.committedAt(operationId)).toBeNull(); + expect(await f.charges()).toEqual([]); + } finally { + await f.cleanup(); + } + }); + configurePrices(undefined); + }); + + it("a dispatch that commits first is charged under the old caps and cannot be retracted", async () => { + await resetRaceDatabase("agents_budget_race_test"); + await withDatabase(raceDatabaseUrl("agents_budget_race_test"), async (database) => { + configurePrices({ price: async () => oneDollar() }); + const f = await fixture(database, { daily: 200 }); + try { + const winner = await f.admit("winner"); + const later = await f.admit("later"); + await f.commit(winner); + await f.setCaps(database.db, { daily: 50 }); + expect(await f.spent()).toBe("1.000000"); + expect(await f.committedAt(winner)).toBeTruthy(); + expect((await f.charges())[0]?.state).toBe("committed"); + expect(await settle(f.commit(later))).toBe("spend_budget_exceeded"); + expect(await f.spent()).toBe("1.000000"); + } finally { + await f.cleanup(); + } + }); + configurePrices(undefined); + }); + + it("clearing configured caps keeps accounting so restoring caps cannot reset usage", async () => { + await resetRaceDatabase("agents_budget_race_test"); + await withDatabase(raceDatabaseUrl("agents_budget_race_test"), async (database) => { + let lookups = 0; + configurePrices({ + price: async () => { + lookups += 1; + return oneDollar(); + }, + }); + const f = await fixture(database, { daily: 100 }); + try { + await f.commit(await f.admit("capped")); + await f.setCaps(database.db, {}); + const priced = lookups; + await f.commit(await f.admit("uncapped")); + expect(lookups, "configured budgets continue pricing while uncapped").toBe(priced + 1); + expect(await f.spent(), "uncapped spending remains in the shared ledger").toBe("2.000000"); + await f.setCaps(database.db, { daily: 150 }); + expect(await settle(f.commit(await f.admit("again")))).toBe("spend_budget_exceeded"); + } finally { + await f.cleanup(); + } + }); + configurePrices(undefined); + }); + + it("a stalled worker that loses the commitment neither dispatches nor refunds the winner", async () => { + await resetRaceDatabase("agents_budget_race_test"); + await withDatabase(raceDatabaseUrl("agents_budget_race_test"), async (database) => { + configurePrices({ price: async () => oneDollar() }); + const f = await fixture(database, { daily: 100 }); + try { + const operationId = await f.admit("shared"); + const outcomes = await Promise.all([ + settle(f.commit(operationId)), + settle(f.commit(operationId)), + settle(f.commit(operationId)), + ]); + expect(outcomes.filter((outcome) => outcome === "committed")).toHaveLength(1); + for (const outcome of outcomes) + if (outcome !== "committed") expect(outcome).toBe("operation_not_pending"); + const [charge] = await f.charges(); + expect(charge?.state).toBe("committed"); + expect(await f.spent()).toBe("1.000000"); + expect(await settle(f.commit(await f.admit("next")))).toBe("spend_budget_exceeded"); + } finally { + await f.cleanup(); + } + }); + configurePrices(undefined); + }); +}); diff --git a/plugins/agents/tests/sign-message.test.ts b/plugins/agents/tests/sign-message.test.ts new file mode 100644 index 000000000..9d2d5652d --- /dev/null +++ b/plugins/agents/tests/sign-message.test.ts @@ -0,0 +1,145 @@ +import { randomBytes } from "node:crypto"; +import { + generateIntent, + generateResponse, + readStatus, + signMessage, + submitIntent, +} from "@near-intents-agent-api/agents-core"; +import { + canonical, + identitySigningChallengeSchema, + policySchema, +} from "@near-intents-agent-api/contracts"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { createGrantCredential } from "./support/grant-credential"; +import { testDestinations } from "./support/grant-destinations"; +import { nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { + allowed: true, + requires_approval: false, + allowed_recipients: ["recipient.near"], + }, + swap: { allowed: false, requires_approval: false }, + }, + rules: { allowed_tokens: ["native"], transaction_types: ["transfer"] }, +}); + +function identityMessage(audience: string) { + const issuedAtMs = Date.now(); + return canonical( + identitySigningChallengeSchema.parse({ + domain: "near-intents-agent-api.identity.v1", + purpose: "identity", + chain: "near", + audience, + challenge: randomBytes(32).toString("hex"), + issued_at_ms: issuedAtMs, + expires_at_ms: issuedAtMs + 60_000, + }), + ); +} + +async function onboardAndGrant(env: Awaited>) { + const actor = await env.actor(); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name: "Signer", + owner, + policy, + }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + await readStatus(actor, generated.correlationId, 5000); + + const credential = createGrantCredential(); + const grantIntent = await generateIntent(actor, { + type: "grant_issue", + agentId: generated.agentId, + label: "Signing assistant", + credential: credential.commitment, + actions: ["sign:near_message"], + signingAudiences: ["recipient.near"], + recipients: testDestinations(["recipient.near"]), + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }); + const grantGenerated = generateResponse(grantIntent.row); + await submitIntent(actor, { + type: "grant_issue", + correlationId: grantGenerated.correlationId, + signedData: signer.sign(grantGenerated.intent), + }); + const status = await readStatus(actor, grantGenerated.correlationId, 5000); + expect(status.status).toBe("SUCCESS"); + return { actor, agentId: generated.agentId, token: credential.token }; +} + +describe("grant-scoped message signing (ticket 11 tail)", () => { + it("signs an identity challenge under a sign-grant and refuses unlisted audiences", async () => { + const env = await setupCore("grant-sign"); + try { + const { actor, agentId, token } = await onboardAndGrant(env); + const callsBefore = env.provider.state.nearMessageCalls; + const signed = await signMessage( + actor, + agentId, + { + message: identityMessage("recipient.near"), + encoding: "utf8", + recipient: "recipient.near", + idempotencyKey: "grant-sign-ok-0001", + }, + token, + ); + expect(signed.status).toBe("completed"); + expect(env.provider.state.nearMessageCalls).toBe(callsBefore + 1); + + await expect( + signMessage( + actor, + agentId, + { + message: identityMessage("untrusted.near"), + encoding: "utf8", + recipient: "untrusted.near", + idempotencyKey: "grant-sign-audience-0001", + }, + token, + ), + ).rejects.toMatchObject({ code: "grant_recipient_denied", status: 403 }); + + await expect( + signMessage( + actor, + agentId, + { + message: "approve transfer", + encoding: "utf8", + recipient: "recipient.near", + idempotencyKey: "grant-sign-arbitrary-0001", + }, + token, + ), + ).rejects.toMatchObject({ code: "signing_identity_challenge_invalid" }); + + expect(env.provider.state.nearMessageCalls).toBe(callsBefore + 1); + } finally { + await env.close(); + } + }); +}); diff --git a/plugins/agents/tests/signature-delivery.test.ts b/plugins/agents/tests/signature-delivery.test.ts new file mode 100644 index 000000000..f02cf4376 --- /dev/null +++ b/plugins/agents/tests/signature-delivery.test.ts @@ -0,0 +1,232 @@ +import { randomBytes } from "node:crypto"; +import { + acknowledgeSigningArtifact, + generateIntent, + generateResponse, + readSigningArtifact, + readStatus, + signMessage, + submitIntent, +} from "@near-intents-agent-api/agents-core"; +import { + canonical, + identitySigningChallengeSchema, + policySchema, +} from "@near-intents-agent-api/contracts"; +import { operationArtifacts, operations } from "@near-intents-agent-api/database"; +import { and, eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { createGrantCredential } from "./support/grant-credential"; +import { testDestinations } from "./support/grant-destinations"; +import { type IntentSigner, nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { + allowed: true, + requires_approval: false, + allowed_recipients: ["recipient.near"], + }, + swap: { allowed: true, requires_approval: false }, + }, + rules: { allowed_tokens: ["native"], transaction_types: ["transfer", "swap"] }, +}); + +function identityMessage(audience: string) { + const issuedAtMs = Date.now(); + return canonical( + identitySigningChallengeSchema.parse({ + domain: "near-intents-agent-api.identity.v1", + purpose: "identity", + chain: "near", + audience, + challenge: randomBytes(32).toString("hex"), + issued_at_ms: issuedAtMs, + expires_at_ms: issuedAtMs + 60_000, + }), + ); +} + +type Env = Awaited>; + +async function onboard(env: Env, name: string) { + const actor = await env.actor(); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name, + owner, + policy, + }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + await readStatus(actor, generated.correlationId, 5000); + return { actor, agentId: generated.agentId, signer }; +} + +async function issueGrant( + actor: Awaited>, + agentId: string, + signer: IntentSigner, + actions: string[], + label: string, +): Promise { + const credential = createGrantCredential(); + const intent = await generateIntent(actor, { + type: "grant_issue", + agentId, + label, + credential: credential.commitment, + actions, + signingAudiences: actions.includes("sign:near_message") ? ["recipient.near"] : [], + recipients: testDestinations(["recipient.near"]), + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }); + const generated = generateResponse(intent.row); + await submitIntent(actor, { + type: "grant_issue", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + const status = await readStatus(actor, generated.correlationId, 5000); + expect(status.status).toBe("SUCCESS"); + return credential.token; +} + +describe("signature delivery (getSignature / acknowledgeSignature)", () => { + it("delivers the artifact once to the authorizing grant, erases on acknowledge", async () => { + const env = await setupCore("signature-delivery"); + try { + const { actor, agentId, signer } = await onboard(env, "Signer"); + const signingToken = await issueGrant( + actor, + agentId, + signer, + ["sign:near_message"], + "Signer grant", + ); + + const signed = await signMessage( + actor, + agentId, + { + message: identityMessage("recipient.near"), + encoding: "utf8", + recipient: "recipient.near", + idempotencyKey: "delivery-ok-0001", + }, + signingToken, + ); + expect(signed.status).toBe("completed"); + const correlationId = signed.id; + + const delivery = await readSigningArtifact(actor, agentId, correlationId, signingToken); + expect(delivery.operation_id).toBe(correlationId); + expect(delivery.artifact).toMatchObject({ + near_account_id: expect.any(String), + recipient: "recipient.near", + public_key: expect.stringMatching(/^ed25519:/), + signature: expect.stringMatching(/^[0-9a-f]{128}$/), + }); + + // The signature lives only in the encrypted artifact: the stored + // operation result is redacted and the ciphertext excludes the secret. + const [storedOperation] = await env.database.db + .select({ result: operations.result }) + .from(operations) + .where(and(eq(operations.agentId, agentId), eq(operations.id, correlationId))); + expect(storedOperation?.result).toEqual({ + redacted: true, + artifact_redacted: true, + status: "completed", + }); + const [storedArtifact] = await env.database.db + .select() + .from(operationArtifacts) + .where(eq(operationArtifacts.operationId, correlationId)); + expect(storedArtifact?.ciphertext).toBeTruthy(); + expect(storedArtifact?.ciphertext).not.toContain(delivery.artifact.signature); + expect(storedArtifact?.grantId).toBeTruthy(); + + // A different sign-grant's token cannot collect it, a non-signing + // grant cannot either, and neither can an absent token. + const siblingToken = await issueGrant( + actor, + agentId, + signer, + ["sign:near_message"], + "Sibling signer grant", + ); + await expect( + readSigningArtifact(actor, agentId, correlationId, siblingToken), + ).rejects.toMatchObject({ code: "signing_artifact_not_found", status: 404 }); + const swapToken = await issueGrant(actor, agentId, signer, ["swap"], "Swap grant"); + await expect( + readSigningArtifact(actor, agentId, correlationId, swapToken), + ).rejects.toMatchObject({ code: "signing_artifact_not_found", status: 404 }); + await expect( + readSigningArtifact(actor, agentId, correlationId, undefined), + ).rejects.toMatchObject({ code: "signing_artifact_not_found", status: 404 }); + + // The authorizing token reads twice (delivery is one-time per READ of + // the live artifact; the ERASE is the acknowledge step). + const again = await readSigningArtifact(actor, agentId, correlationId, signingToken); + expect(again.artifact.signature).toBe(delivery.artifact.signature); + + // Acknowledge erases: a further read is consumed (410), the ack stays + // idempotent. + await expect( + acknowledgeSigningArtifact(actor, agentId, correlationId, signingToken), + ).resolves.toEqual({ acknowledged: true }); + await expect( + readSigningArtifact(actor, agentId, correlationId, signingToken), + ).rejects.toMatchObject({ code: "signing_artifact_consumed", status: 410 }); + await expect( + acknowledgeSigningArtifact(actor, agentId, correlationId, signingToken), + ).resolves.toEqual({ acknowledged: true }); + + const [erased] = await env.database.db + .select() + .from(operationArtifacts) + .where(eq(operationArtifacts.operationId, correlationId)); + expect(erased?.ciphertext).toBeNull(); + expect(erased?.consumedAt).not.toBeNull(); + } finally { + await env.close(); + } + }); + + it("answers not-found for unknown operations and refuses foreign tenants", async () => { + const env = await setupCore("signature-delivery-missing"); + try { + const { actor, agentId, signer } = await onboard(env, "Missing"); + const signingToken = await issueGrant( + actor, + agentId, + signer, + ["sign:near_message"], + "Signer grant", + ); + + await expect( + readSigningArtifact(actor, agentId, "0".repeat(64), signingToken), + ).rejects.toMatchObject({ code: "signing_artifact_not_found", status: 404 }); + await expect( + acknowledgeSigningArtifact(actor, agentId, "0".repeat(64), signingToken), + ).rejects.toMatchObject({ code: "signing_artifact_not_found", status: 404 }); + } finally { + await env.close(); + } + }); +}); diff --git a/plugins/agents/tests/spend-budget.test.ts b/plugins/agents/tests/spend-budget.test.ts new file mode 100644 index 000000000..6d4cec6f1 --- /dev/null +++ b/plugins/agents/tests/spend-budget.test.ts @@ -0,0 +1,160 @@ +import { + configurePrices, + generateIntent, + generateResponse, + readBudget, + readStatus, + runExecution, + submitIntent, + transferExecution, +} from "@near-intents-agent-api/agents-core"; +import * as views from "@near-intents-agent-api/agents-core/views"; +import { policySchema } from "@near-intents-agent-api/contracts"; +import { describe, expect, it } from "vitest"; +import { setupCore } from "./support/core-setup"; +import { createGrantCredential } from "./support/grant-credential"; +import { testDestinations } from "./support/grant-destinations"; +import { nearOwnerFixture } from "./support/intent-signers"; + +const policy = policySchema.parse({ + version: 1, + frozen: false, + capabilities: { + confidential: { allowed: false, requires_approval: false }, + cross_chain_withdraw: { allowed: false, requires_approval: true }, + evm_sign: { allowed: false, raw_tx: false }, + raw_sign: { allowed: false, chains: [], requires_approval: true }, + sign_message: { allowed: false, requires_approval: false, allowed_recipients: [] }, + swap: { allowed: true, requires_approval: false }, + }, + rules: { allowed_tokens: ["nep141:wrap.near"], transaction_types: ["transfer", "swap"] }, +}); + +const wnear = 10n ** 24n; +const tokens = (whole: number) => ((BigInt(Math.round(whole * 100)) * wnear) / 100n).toString(); +const oneDollarPerToken = () => ({ + decimals: 24, + coefficient: 1n, + scale: 0, + updatedAtMs: Date.now(), +}); + +describe("USD budget charges at dispatch (ticket 11 tail)", () => { + it("charges within the owner's cap, refuses the spend that would exceed it, and frees on reset", async () => { + const env = await setupCore("budget-daily"); + try { + const actor = await env.actor(); + const { owner, signer } = nearOwnerFixture(); + const created = await generateIntent(actor, { + type: "agent_create", + name: "Budgeted", + owner, + policy, + }); + const generated = generateResponse(created.row); + await submitIntent(actor, { + type: "agent_create", + correlationId: generated.correlationId, + signedData: signer.sign(generated.intent), + }); + await readStatus(actor, generated.correlationId, 5000); + const agentId = generated.agentId; + + const credential = createGrantCredential(); + const grantIntent = await generateIntent(actor, { + type: "grant_issue", + agentId, + label: "Budgeted delegate", + credential: credential.commitment, + actions: ["intents_transfer"], + recipients: testDestinations(["recipient.near"]), + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }); + const grantGenerated = generateResponse(grantIntent.row); + await submitIntent(actor, { + type: "grant_issue", + correlationId: grantGenerated.correlationId, + signedData: signer.sign(grantGenerated.intent), + }); + await readStatus(actor, grantGenerated.correlationId, 5000); + + configurePrices({ price: async () => oneDollarPerToken() }); + try { + const budgetIntent = await generateIntent(actor, { + type: "budget_set", + agentId, + dailyUsd: "2", + weeklyUsd: null, + monthlyUsd: null, + }); + const budgetGenerated = generateResponse(budgetIntent.row); + await submitIntent(actor, { + type: "budget_set", + correlationId: budgetGenerated.correlationId, + signedData: signer.sign(budgetGenerated.intent), + }); + const budgetStatus = await readStatus(actor, budgetGenerated.correlationId, 5000); + expect(budgetStatus.status).toBe("SUCCESS"); + + const transfer = (amount: string, key: string) => + transferExecution( + { asset: "nep141:wrap.near", amount, recipient: "recipient.near", confidential: false }, + key, + ); + await runExecution( + actor, + agentId, + transfer(tokens(1.5), "budget-fit-0001"), + credential.token, + ); + const used = views.budgetView(await readBudget(actor, agentId)); + expect(used.daily?.limitUsd).toBe("2.00"); + expect(used.daily?.spentUsd).toBe("1.500000"); + expect(used.daily?.remainingUsd).toBe("0.500000"); + expect(used.daily?.resetsAt).toBeTruthy(); + + const before = env.provider.state.submissions; + await expect( + runExecution(actor, agentId, transfer(tokens(0.6), "budget-over-0001"), credential.token), + ).rejects.toMatchObject({ code: "spend_budget_exceeded", status: 403 }); + + // The grant layer binds before the budget layer: a request that violates both names the + // grant, and no spend is charged either way. + await expect( + runExecution( + actor, + agentId, + transferExecution( + { + asset: "nep141:wrap.near", + amount: "5", + recipient: "untrusted.near", + confidential: false, + }, + "budget-order-0001", + ), + credential.token, + ), + ).rejects.toMatchObject({ code: "grant_recipient_denied", status: 403 }); + expect(env.provider.state.submissions).toBe(before); + expect(views.budgetView(await readBudget(actor, agentId)).daily?.spentUsd).toBe("1.500000"); + expect(env.provider.state.submissions).toBe(before); + expect(views.budgetView(await readBudget(actor, agentId)).daily?.spentUsd).toBe("1.500000"); + + await runExecution( + actor, + agentId, + transfer(tokens(0.5), "budget-exact-0001"), + credential.token, + ); + expect(views.budgetView(await readBudget(actor, agentId)).daily?.remainingUsd).toBe( + "0.000000", + ); + } finally { + configurePrices(undefined); + } + } finally { + await env.close(); + } + }); +}); diff --git a/plugins/agents/tests/support/agent-harness.ts b/plugins/agents/tests/support/agent-harness.ts new file mode 100644 index 000000000..ceec4ac18 --- /dev/null +++ b/plugins/agents/tests/support/agent-harness.ts @@ -0,0 +1,328 @@ +import { createHash } from "node:crypto"; +import { + configureNearAccessKeyVerifier, + configureNearAccountReader, + configureNearPolicyOwnerVerifier, + configurePolicyStorageEstimator, +} from "@near-intents-agent-api/agents-core"; +import type { OutlayerWalletClient } from "@near-intents-agent-api/outlayer"; +import { OutlayerError } from "@near-intents-agent-api/outlayer"; +import { ownerNep413SigningDigest } from "@near-intents-agent-api/relayer/owner-message"; + +export const storageDeposit = "11900000000000000000000"; + +export function nep413Digest(input: { message: string; nonceHex: string; recipient: string }) { + return ownerNep413SigningDigest({ + message: input.message, + nonce: Buffer.from(input.nonceHex, "hex"), + recipient: input.recipient, + }); +} + +export type StubProvider = { + client: OutlayerWalletClient; + encryptedPolicies: string[]; + state: { + submissions: number; + registerCalls: number; + nearMessageCalls: number; + policyStatus: "success" | "failed" | "pending"; + custodyKeys: Map; + controllers: Map; + controller: string; + nearAccountIdOverride: string | undefined; + registerFailure: boolean; + }; +}; + +/** + * Provider double with one token that has a defuse id and one that has none, per-wallet policy + * reads, and mutation results keyed by submission order. Ported from the upstream test harness. + */ +export function stubProvider(): StubProvider { + configurePolicyStorageEstimator(async () => "0"); + const state = { + encryptedPolicies: [] as string[], + submissions: 0, + registerCalls: 0, + nearMessageCalls: 0, + policyStatus: "success" as "success" | "failed" | "pending", + registerFailure: false, + controllers: new Map(), + custodyKeys: new Map(), + controller: "", + nearAccountIdOverride: undefined as string | undefined, + }; + const providerMutationResult = () => ({ + request_id: `req-${state.submissions}`, + status: state.policyStatus, + ...(state.policyStatus === "success" + ? { + tx_hash: `tx-${state.submissions}`, + intent_hash: `intent-${state.submissions}`, + settlement_tx_hash: `settlement-${state.submissions}`, + } + : {}), + }); + const client = { + network: "mainnet", + contractId: "outlayer.near", + async registerWallet() { + state.registerCalls += 1; + if (state.registerFailure) throw new Error("synthetic register failure"); + const apiKey = `wk_test_${state.registerCalls}`; + const nearAccountId = + state.nearAccountIdOverride ?? + createHash("sha256").update(`test-custody-wallet-${state.registerCalls}`).digest("hex"); + state.custodyKeys.set(nearAccountId, apiKey); + return { + api_key: apiKey, + near_account_id: nearAccountId, + wallet_id: `wallet-${apiKey.slice(8)}`, + }; + }, + async address(_apiKey: string, chain = "near") { + if (chain === "ethereum") return { address: `0x${"ab".repeat(20)}`, chain }; + return { address: `acct-${state.registerCalls}.near`, chain }; + }, + async balance() { + return { + account_id: "acct.near", + balance: "5000000000000000000000000", + source: "chain" as const, + }; + }, + async confidentialBalance() { + return { single: false as const, value: { account_id: "acct.near", balances: [] } }; + }, + async tokens() { + return { + tokens: [ + { + id: "wrap.near", + symbol: "wNEAR", + chains: ["near"], + decimals: 24, + defuse_asset_id: "nep141:wrap.near", + }, + { id: "usdt.tether-token.near", symbol: "USDT", chains: ["near"], decimals: 6 }, + ], + }; + }, + async policy(apiKey: string) { + const walletId = `wallet-${apiKey.slice(8)}`; + const policies = state.encryptedPolicies.map( + (encrypted) => JSON.parse(Buffer.from(encrypted, "base64").toString("utf8")) as object, + ); + const last = policies.findLast( + (policy) => (policy as { wallet_id?: string }).wallet_id === walletId, + ); + return last ? { ...last, controller: state.controllers.get(apiKey) ?? state.controller } : {}; + }, + async encryptPolicy(_apiKey: string, _policy: unknown, _walletId: string) { + const encrypted = Buffer.from( + JSON.stringify({ ...(_policy as object), wallet_id: _walletId }), + ).toString("base64"); + state.encryptedPolicies.push(encrypted); + return { encrypted_base64: encrypted }; + }, + async signPolicy(apiKey: string, input: { caller: string }) { + state.controller = input.caller; + state.controllers.set(apiKey, input.caller); + return { public_key_hex: "2".repeat(64), signature_hex: "3".repeat(128) }; + }, + async signNearMessage() { + state.nearMessageCalls += 1; + return { + public_key: `ed25519:${"4".repeat(64)}`, + signature: "5".repeat(128), + }; + }, + invalidatePolicyCache() { + return Promise.resolve({}); + }, + async walletExecution(_path: string, _key: string, body: { token?: string }) { + if (body.token === "nep141:unlisted.near") throw new OutlayerError("policy_denied", 403); + state.submissions++; + return providerMutationResult(); + }, + async deleteWallet() { + state.submissions += 1; + return providerMutationResult(); + }, + async requestStatus(_apiKey: string, requestId: string) { + return { + ...providerMutationResult(), + request_id: requestId, + }; + }, + async walletRead() { + return { checks: [] }; + }, + async walletQuote(path: string, _apiKey: string, body: Record) { + return { + route: path, + amount_out: "990000", + min_amount_out: body.min_amount_out ?? null, + fee_amount: "10000", + }; + }, + } as unknown as OutlayerWalletClient; + configureNearPolicyOwnerVerifier({ + async verify({ nearAccountId }) { + const apiKey = state.custodyKeys.get(nearAccountId); + return (apiKey && state.controllers.get(apiKey)) || state.controller || null; + }, + }); + configureNearAccountReader({ + async exists() { + return true; + }, + }); + configureNearAccessKeyVerifier({ async verifyFullAccess() {} }); + return { + client, + encryptedPolicies: state.encryptedPolicies, + state, + }; +} + +/** + * Owner policy sponsor double: relays the owner's signed delegate on the sponsor's key. + * Ported from the upstream test harness; nothing reaches a network. + */ +export function stubOwnerSponsor(onFreeze?: (frozen: boolean) => void) { + const calls: Array<{ freeze?: boolean }> = []; + let transactionStatus: "pending" | "succeeded" | "failed" | "dropped" = "succeeded"; + const sponsor = { + accountId: "sponsor.near", + receiverId: "outlayer.near", + gas: "100000000000000", + storageDepositYocto: "100000000000000000000000", + async prepare(input: { + senderId: string; + publicKey: string; + actions: Array>; + }) { + return { + ...input, + receiverId: "outlayer.near", + nonce: String(calls.length + 1), + maxBlockHeight: "100", + }; + }, + async blockHeight() { + return 50n; + }, + async status( + _hash: string, + _accountId?: string, + witness?: { publicKey: string; nonce: bigint }, + ) { + void witness; + if (transactionStatus === "dropped") return "pending"; + return transactionStatus; + }, + async assertBalance() {}, + async submit( + input: { actions: Array<{ methodName: string }> }, + _signatureHex: string, + beforeBroadcast: ( + hash: string, + witness?: { publicKey: string; nonce: bigint }, + ) => Promise, + beforeSigning?: () => Promise, + ) { + await beforeSigning?.(); + const method = input.actions.find( + (action) => action.methodName !== "store_wallet_policy", + )?.methodName; + if (method === "freeze_wallet") onFreeze?.(true); + if (method === "unfreeze_wallet") onFreeze?.(false); + calls.push({ + freeze: + method === "freeze_wallet" ? true : method === "unfreeze_wallet" ? false : undefined, + }); + await beforeBroadcast(`tx-${calls.length}`, { + publicKey: "ed25519:sponsor", + nonce: BigInt(100 + calls.length), + }); + return { transactionHash: `tx-${calls.length}`, finalExecutionStatus: "FINAL" }; + }, + }; + return { + sponsor, + calls, + setStatus(next: typeof transactionStatus) { + transactionStatus = next; + }, + }; +} + +/** + * Wallet sponsor double for passkey/EVM `0s` owners: storage funding is a separate transfer that + * calls the API's journal before it "broadcasts", then the owner's signed request is relayed. + * Ported from the upstream test harness. + */ +export function stubWalletSponsor() { + let transactionStatus: "pending" | "succeeded" | "failed" | "dropped" = "succeeded"; + const fundings: Array<{ receiverId: string; amount: bigint; hash: string }> = []; + const relays: string[] = []; + const relaySigned = Object.assign( + async ( + message: { signer_id: string }, + _proof: string, + beforeBroadcast: ( + hash: string, + witness?: { publicKey: string; nonce: bigint }, + ) => Promise, + ) => { + const hash = `relay-${relays.length + 1}`; + await beforeBroadcast(hash, { + publicKey: "ed25519:sponsor", + nonce: BigInt(500 + relays.length), + }); + relays.push(message.signer_id); + return { + transactionHash: hash, + finalExecutionStatus: "FINAL" as const, + status: { SuccessValue: "" }, + }; + }, + { + async status( + _hash: string, + _accountId?: string, + witness?: { publicKey: string; nonce: bigint }, + ) { + void witness; + if (transactionStatus === "dropped") return "pending"; + return transactionStatus; + }, + async assertBalance() {}, + }, + ); + const sponsor = { + accountId: "sponsor.near", + initialize: async () => "local-init", + relaySigned, + async fundPolicyStorage( + receiverId: string, + amount: bigint, + journal: (hash: string, witness?: { publicKey: string; nonce: bigint }) => Promise, + ) { + const hash = `funding-${fundings.length + 1}`; + await journal(hash, { publicKey: "ed25519:sponsor", nonce: BigInt(900 + fundings.length) }); + fundings.push({ receiverId, amount, hash }); + return hash; + }, + }; + return { + sponsor, + fundings, + relays, + setStatus(next: typeof transactionStatus) { + transactionStatus = next; + }, + }; +} diff --git a/plugins/agents/tests/support/core-setup.ts b/plugins/agents/tests/support/core-setup.ts new file mode 100644 index 000000000..2209ac923 --- /dev/null +++ b/plugins/agents/tests/support/core-setup.ts @@ -0,0 +1,82 @@ +import type { Actor } from "@near-intents-agent-api/agents-core"; +import { + configureDatabase, + configureNearVerifier, + configureOutlayer, + configureOwnerPolicySponsor, + configureRuntime, + configureWalletSponsor, +} from "@near-intents-agent-api/agents-core"; +import { createTestDatabase } from "@near-intents-agent-api/database/testing"; +import { PublicKey } from "near-api-js"; +import { actorForSession } from "../../src/actor"; +import type { DatabaseDriver } from "../../src/db"; +import { nep413Digest, stubOwnerSponsor, stubProvider, stubWalletSponsor } from "./agent-harness"; +import { closeServer, startNearRpc } from "./near-rpc-double"; + +/** + * One isolated core: PGlite database, provider and sponsor doubles, a local NEAR RPC for owner + * wallets. NEAR NEP-413 proofs are checked against the signed key. Ported from the upstream + * integration rig, with the plugin's session→actor mapping in place of API keys. + */ +export async function setupCore(userId: string) { + const database = await createTestDatabase(); + configureDatabase(database as unknown as DatabaseDriver); + const provider = stubProvider(); + configureOutlayer(provider.client); + const ownerSponsor = stubOwnerSponsor(); + configureOwnerPolicySponsor(ownerSponsor.sponsor as never); + const walletSponsor = stubWalletSponsor(); + configureWalletSponsor(walletSponsor.sponsor as never); + configureNearVerifier({ + async verify(input) { + if ( + !PublicKey.from(input.publicKey).verify( + nep413Digest(input), + Buffer.from(input.signatureHex, "hex"), + ) + ) + throw new Error("near_owner_proof_invalid"); + }, + }); + const rpc = await startNearRpc(); + configureRuntime({ + trustedOrigins: ["https://citynode.app"], + secretEncryptionKeys: { "env:v1": "t".repeat(32) }, + secretEncryptionActiveKeyId: "env:v1", + nearRpcUrls: [rpc.url], + network: "mainnet", + serviceUrl: "https://citynode.app", + sponsorDailyGlobalLimit: 100, + sponsorDailyTenantLimit: 20, + sponsorDailyAgentLimit: 10, + walletPolicyStorageLimitYocto: "11900000000000000000000", + sponsorAccountId: "sponsor.near", + logLevel: "error", + }); + const actor = (keyId = "session") => + actorForSession(database as unknown as DatabaseDriver, userId, keyId) as Promise; + const actorAs = (otherUserId: string) => + actorForSession( + database as unknown as DatabaseDriver, + otherUserId, + "session", + ) as Promise; + return { + database, + provider, + ownerSponsor, + walletSponsor, + rpc, + actor, + actorAs, + async close() { + configureWalletSponsor(undefined); + configureNearVerifier(undefined); + await closeServer(rpc.server); + await database.close(); + }, + }; +} + +export { stubOwnerSponsor, stubProvider, stubWalletSponsor }; diff --git a/plugins/agents/tests/support/grant-credential.ts b/plugins/agents/tests/support/grant-credential.ts new file mode 100644 index 000000000..8e48ebe76 --- /dev/null +++ b/plugins/agents/tests/support/grant-credential.ts @@ -0,0 +1,13 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** A new grant token and the commitment the owner signs for it (upstream SDK scheme). */ +export type GrantCredential = { token: string; commitment: string }; + +export function grantCommitment(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function createGrantCredential(): GrantCredential { + const token = `ngt_${randomBytes(32).toString("base64url")}`; + return { token, commitment: grantCommitment(token) }; +} diff --git a/plugins/agents/tests/support/grant-destinations.ts b/plugins/agents/tests/support/grant-destinations.ts new file mode 100644 index 000000000..efe3a14b9 --- /dev/null +++ b/plugins/agents/tests/support/grant-destinations.ts @@ -0,0 +1,47 @@ +import { canonicalDestination, type GrantDestination } from "@near-intents-agent-api/contracts"; +/** Explicit fixture permissions. Production never infers scope from an address. */ +export function testDestinations(addresses: readonly string[]): GrantDestination[] { + return addresses + .filter((address) => !address.startsWith("https://")) + .flatMap((address): GrantDestination[] => { + const chains = fixtureChains(address); + const destinations: GrantDestination[] = []; + for (const chain of chains) destinations.push(...chainDestinations(chain, address)); + if (chains[0] === "near") + for (const action of ["intents_transfer", "confidential_transfer"] as const) + destinations.push({ + action, + kind: action === "intents_transfer" ? "intents-account" : "confidential-account", + chain: "near", + network: "mainnet", + address, + memo: { kind: "none" }, + purpose: "payout", + }); + return destinations; + }); +} + +function chainDestinations(chain: string, address: string): GrantDestination[] { + return (["withdraw", "cross_chain_deposit"] as const).map((action) => + canonicalDestination({ + action, + kind: "chain-address", + chain, + network: "mainnet", + address, + memo: { kind: "none" }, + purpose: action === "withdraw" ? "payout" : "refund", + }), + ); +} + +/** + * The chains a fixture address belongs to, by its full format. A prefix test is not enough: an + * implicit NEAR account is 64 hex characters and starts with `1` or `3` one time in eight. + */ +function fixtureChains(address: string) { + if (address.startsWith("0x")) return ["base", "eth"]; + const bitcoin = /^(bc1[02-9ac-hj-np-z]{11,71}|[13][1-9A-HJ-NP-Za-km-z]{25,34})$/; + return bitcoin.test(address) ? ["btc"] : ["near"]; +} diff --git a/plugins/agents/tests/support/intent-signers.ts b/plugins/agents/tests/support/intent-signers.ts new file mode 100644 index 000000000..3e66d0f02 --- /dev/null +++ b/plugins/agents/tests/support/intent-signers.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import type { Intent, SignedData } from "@near-intents-agent-api/contracts/api"; +import { ownerNep413SigningDigest } from "@near-intents-agent-api/relayer/owner-message"; +import { + actions, + encodeDelegateAction, + encodeSignedDelegate, + KeyPair, + type KeyPairEd25519, + Signature, +} from "near-api-js"; + +/** + * Wallet doubles for generated intents. Each returns exactly what a real wallet returns for the + * standard, so tests exercise the server's normalization instead of pre-normalizing: + * NEAR returns base64 NEP-413 signatures and a base64 borsh `SignedDelegate`, like near-connect. + * Ported from the upstream test suite; EVM/passkey signers arrive with the delegated-execution + * port. + */ +export type IntentSigner = { sign(intent: Intent): SignedData }; + +export function ed25519Keypair() { + const keyPair = KeyPair.fromRandom("ed25519") as KeyPairEd25519; + return { + keyPair, + publicKey: keyPair.getPublicKey().toString(), + publicKeyHex: Buffer.from(keyPair.getPublicKey().data).toString("hex"), + }; +} + +export function nearIntentSigner(keyPair: KeyPair, accountId: string): IntentSigner { + return { + sign(intent) { + if (intent.standard === "nep413") { + const digest = ownerNep413SigningDigest({ + message: intent.payload.message, + recipient: intent.payload.recipient, + nonce: Buffer.from(intent.payload.nonce, "base64"), + }); + return { + ...intent, + public_key: keyPair.getPublicKey().toString(), + signature: Buffer.from(keyPair.sign(digest).signature).toString("base64"), + }; + } + if (intent.standard === "nep366") { + const delegateAction = { + senderId: accountId, + receiverId: intent.payload.receiverId, + publicKey: keyPair.getPublicKey(), + nonce: 1n, + maxBlockHeight: 100n, + actions: intent.payload.actions.map((action) => + actions.functionCall( + action.params.methodName, + Buffer.from(JSON.stringify(action.params.args)), + BigInt(action.params.gas), + BigInt(action.params.deposit), + ), + ), + }; + const digest = createHash("sha256").update(encodeDelegateAction(delegateAction)).digest(); + const signedDelegate = Buffer.from( + encodeSignedDelegate({ + delegateAction, + signature: new Signature({ keyType: 0, data: keyPair.sign(digest).signature }), + }), + ).toString("base64"); + return { ...intent, signedDelegate }; + } + throw new Error(`near signer cannot sign ${intent.standard}`); + }, + }; +} + +export function nearOwnerFixture(accountId = "owner.near") { + const { keyPair, publicKey } = ed25519Keypair(); + const owner = { + type: "near", + accountId, + publicKey, + } as const; + return { owner, keyPair, publicKey, signer: nearIntentSigner(keyPair, accountId) }; +} diff --git a/plugins/agents/tests/support/near-rpc-double.ts b/plugins/agents/tests/support/near-rpc-double.ts new file mode 100644 index 000000000..2942952b7 --- /dev/null +++ b/plugins/agents/tests/support/near-rpc-double.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import { EVM_WALLET_CODE_ID, PASSKEY_WALLET_CODE_ID } from "@near-intents-agent-api/owner-auth"; + +/** + * Local NEAR RPC for owner-wallet flows: final block, reviewed global wallet code hashes, `0s` + * wallet accounts bound to that code and owner-authorization view calls. It never executes a + * transaction. + */ +export async function startNearRpc() { + const contracts = new Map(); + /** NEAR Intents balances every custody account holds, by token id. */ + const intentsHeld: Record = {}; + const globalCodeHashes = new Map([ + [PASSKEY_WALLET_CODE_ID, "qD9cxbe38rJn7BwUBtqaC2vVAiYD7TS4vnrafccHsRp"], + [EVM_WALLET_CODE_ID, "FkAmDpjc2HaoFmU9xwgG6x5oJUXnpxAREtTMZi5UcgRy"], + ]); + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This local RPC fixture handles all wallet query variants in one request listener. + request.on("end", () => { + const rpc = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { + id: string | number; + method?: string; + params?: { + account_id?: string; + request_type?: string; + args_base64?: string; + block_id?: string; + }; + }; + const accountId = rpc.params?.account_id ?? ""; + response.writeHead(200, { "content-type": "application/json" }); + if (rpc.params?.request_type === "view_access_key") { + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { + nonce: 1, + permission: "FullAccess", + block_height: 100, + block_hash: "11111111111111111111111111111111", + }, + }), + ); + return; + } + if (rpc.method === "block") { + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { header: { hash: "test-final-block" } }, + }), + ); + return; + } + if (rpc.params?.request_type === "view_global_contract_code_by_account_id") { + assert.equal(rpc.params.block_id, "test-final-block"); + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { hash: globalCodeHashes.get(accountId), block_hash: "test-final-block" }, + }), + ); + return; + } + if (rpc.params?.request_type === "call_function" && accountId === "intents.near") { + const args = JSON.parse( + Buffer.from(rpc.params.args_base64 ?? "", "base64").toString("utf8"), + ) as { token_ids?: string[] }; + const value = + (rpc.params as { method_name?: string }).method_name === "mt_tokens_for_owner" + ? Object.keys(intentsHeld).map((token_id) => ({ token_id })) + : (args.token_ids ?? []).map((token) => intentsHeld[token] ?? "0"); + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { + result: [...Buffer.from(JSON.stringify(value))], + logs: [], + block_height: 1, + block_hash: "11111111111111111111111111111111", + }, + }), + ); + return; + } + if (rpc.params?.request_type === "call_function") { + assert.equal(rpc.params.block_id, "test-final-block"); + const args = JSON.parse( + Buffer.from(rpc.params.args_base64 ?? "", "base64").toString("utf8"), + ) as { + authorization?: string; + }; + const authorization = JSON.parse(args.authorization ?? "{}") as { + purpose?: string; + payload?: string; + signature?: { msg?: { payload?: string } }; + }; + const payload = authorization.purpose + ? authorization.payload + : authorization.signature?.msg?.payload; + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { + block_hash: "test-final-block", + result: [...Buffer.from(JSON.stringify({ payload }))], + }, + }), + ); + return; + } + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { + account_id: accountId, + amount: "0", + locked: "0", + code_hash: "1".repeat(64), + storage_usage: 0, + storage_paid_at: 0, + global_contract_account_id: contracts.get(accountId), + }, + }), + ); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address === "object"); + return { + server, + url: `http://127.0.0.1:${address.port}`, + contracts, + globalCodeHashes, + intentsHeld, + }; +} + +export async function closeServer(server: Server) { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +} diff --git a/plugins/agents/tests/support/race-db.ts b/plugins/agents/tests/support/race-db.ts new file mode 100644 index 000000000..cd8a18bc8 --- /dev/null +++ b/plugins/agents/tests/support/race-db.ts @@ -0,0 +1,48 @@ +import { configureDatabase } from "@near-intents-agent-api/agents-core"; +import { Context, Effect, Layer } from "effect"; +import { PluginIdTag } from "every-plugin"; +import pg from "pg"; +import type { DatabaseDriver } from "../../src/db"; +import { DatabaseLive, DatabaseTag } from "../../src/db/layer"; + +const adminUrl = process.env.AGENTS_RACE_DATABASE_URL; + +export function raceDatabaseUrl(name: string) { + return adminUrl?.replace(/\/[^/]+$/, `/${name}`) ?? ""; +} + +export function raceSuitesEnabled() { + return Boolean(adminUrl); +} + +export async function resetRaceDatabase(name: string) { + const admin = new pg.Pool({ connectionString: adminUrl }); + try { + await admin.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); + await admin.query(`CREATE DATABASE ${name}`); + } finally { + await admin.end(); + } +} + +export function dropRaceDatabase(name: string) { + return resetRaceDatabase(name); +} + +export function withDatabase( + url: string, + run: (driver: DatabaseDriver) => Promise, +): Promise { + const layer = DatabaseLive(url).pipe(Layer.provide(Layer.succeed(PluginIdTag, "agents"))); + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const scope = yield* Effect.scope; + const context = yield* Layer.buildWithScope(layer, scope); + const driver = Context.get(context, DatabaseTag); + configureDatabase(driver); + yield* Effect.tryPromise(() => run(driver)); + }), + ), + ); +} diff --git a/plugins/agents/tests/types.d.ts b/plugins/agents/tests/types.d.ts new file mode 100644 index 000000000..6030301b9 --- /dev/null +++ b/plugins/agents/tests/types.d.ts @@ -0,0 +1,8 @@ +import Plugin from "@/index"; +import pluginDevConfig from "../plugin.dev"; + +declare module "every-plugin" { + interface RegisteredPlugins { + [pluginDevConfig.pluginId]: typeof Plugin; + } +} diff --git a/plugins/agents/tsconfig.json b/plugins/agents/tsconfig.json new file mode 100644 index 000000000..b501591b8 --- /dev/null +++ b/plugins/agents/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", "src/routeTree.gen.ts"] +} diff --git a/plugins/agents/types/contract.d.ts b/plugins/agents/types/contract.d.ts new file mode 100644 index 000000000..3eea3050a --- /dev/null +++ b/plugins/agents/types/contract.d.ts @@ -0,0 +1,1640 @@ +import "@orpc/openapi/extensions/route"; +import type { GenerateIntentRequest, SubmitIntentRequest } from "@near-intents-agent-api/contracts/api"; +import { z } from "zod"; +export declare const contract: { + ping: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + }, z.core.$strip>, z.ZodObject<{ + message: z.ZodString; + timestamp: z.ZodString; + }, z.core.$strip>, object>; + dbHealth: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + ok: z.ZodBoolean; + agentCount: z.ZodNumber; + }, z.core.$strip>, object>; + generateIntent: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + name: z.ZodString; + externalUserId: z.ZodOptional; + owner: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"near">; + accountId: z.ZodString; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"evm">; + address: z.ZodString; + chainId: z.ZodNumber; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"passkey">; + credentialId: z.ZodString; + publicKey: z.ZodString; + rpId: z.ZodString; + origin: z.ZodString; + }, z.core.$strict>], "type">; + policy: z.ZodObject<{ + version: z.ZodLiteral<1>; + capabilities: z.ZodObject<{ + confidential: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + cross_chain_withdraw: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + evm_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + raw_tx: z.ZodBoolean; + }, z.core.$strict>; + raw_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + chains: z.ZodArray; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + sign_message: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + allowed_recipients: z.ZodArray; + }, z.core.$strict>; + swap: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + }, z.core.$strict>; + frozen: z.ZodBoolean; + rules: z.ZodObject<{ + allowed_tokens: z.ZodArray; + transaction_types: z.ZodArray>; + limits: z.ZodOptional>; + hourly: z.ZodOptional>; + daily: z.ZodOptional>; + monthly: z.ZodOptional>; + }, z.core.$strict>>; + rate_limit: z.ZodOptional>; + addresses: z.ZodOptional; + list: z.ZodArray; + }, z.core.$strict>>; + items: z.ZodOptional>>; + }, z.core.$strict>; + approval: z.ZodOptional; + }, z.core.$strict>>; + excluded_types: z.ZodArray>; + threshold: z.ZodObject<{ + required: z.ZodNumber; + }, z.core.$strict>; + }, z.core.$strict>>; + }, z.core.$strict>; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"policy_update">; + policy: z.ZodObject<{ + version: z.ZodLiteral<1>; + capabilities: z.ZodObject<{ + confidential: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + cross_chain_withdraw: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + evm_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + raw_tx: z.ZodBoolean; + }, z.core.$strict>; + raw_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + chains: z.ZodArray; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + sign_message: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + allowed_recipients: z.ZodArray; + }, z.core.$strict>; + swap: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + }, z.core.$strict>; + frozen: z.ZodBoolean; + rules: z.ZodObject<{ + allowed_tokens: z.ZodArray; + transaction_types: z.ZodArray>; + limits: z.ZodOptional>; + hourly: z.ZodOptional>; + daily: z.ZodOptional>; + monthly: z.ZodOptional>; + }, z.core.$strict>>; + rate_limit: z.ZodOptional>; + addresses: z.ZodOptional; + list: z.ZodArray; + }, z.core.$strict>>; + items: z.ZodOptional>>; + }, z.core.$strict>; + approval: z.ZodOptional; + }, z.core.$strict>>; + excluded_types: z.ZodArray>; + threshold: z.ZodObject<{ + required: z.ZodNumber; + }, z.core.$strict>; + }, z.core.$strict>>; + }, z.core.$strict>; + expectedRevision: z.ZodNumber; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"agent_freeze">; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"agent_unfreeze">; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"grant_issue">; + label: z.ZodString; + credential: z.ZodString; + actions: z.ZodArray; + recipients: z.ZodDefault; + kind: z.ZodEnum<{ + "chain-address": "chain-address"; + "confidential-account": "confidential-account"; + "intents-account": "intents-account"; + }>; + chain: z.ZodString; + network: z.ZodLiteral<"mainnet">; + address: z.ZodString; + memo: z.ZodUnion; + }, z.core.$strict>, z.ZodObject<{ + kind: z.ZodLiteral<"exact">; + value: z.ZodString; + }, z.core.$strict>]>; + purpose: z.ZodEnum<{ + payout: "payout"; + refund: "refund"; + }>; + }, z.core.$strict>>>; + signingAudiences: z.ZodDefault>; + expiresAt: z.ZodISODateTime; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"grant_revoke">; + grantId: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"timelock_set">; + delaySeconds: z.ZodNumber; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"budget_set">; + dailyUsd: z.ZodDefault>; + weeklyUsd: z.ZodDefault>; + monthlyUsd: z.ZodDefault>; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"execution_cancel">; + correlationId: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"agent_archive">; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"agent_restore">; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"agent_delete">; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"approval_vote">; + approvalId: z.ZodUUID; + verdict: z.ZodEnum<{ + approve: "approve"; + reject: "reject"; + }>; + signer: z.ZodOptional>; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"signing_artifact_read">; + correlationId: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + type: z.ZodLiteral<"signing_artifact_ack">; + correlationId: z.ZodString; + }, z.core.$strict>], "type">, z.ZodObject<{ + correlationId: z.ZodString; + type: z.ZodEnum<{ + agent_archive: "agent_archive"; + agent_create: "agent_create"; + agent_delete: "agent_delete"; + agent_freeze: "agent_freeze"; + agent_restore: "agent_restore"; + agent_unfreeze: "agent_unfreeze"; + approval_vote: "approval_vote"; + budget_set: "budget_set"; + execution_cancel: "execution_cancel"; + grant_issue: "grant_issue"; + grant_revoke: "grant_revoke"; + policy_update: "policy_update"; + signing_artifact_ack: "signing_artifact_ack"; + signing_artifact_read: "signing_artifact_read"; + timelock_set: "timelock_set"; + }>; + agentId: z.ZodString; + status: z.ZodLiteral<"PENDING_SIGNATURE">; + expiresAt: z.ZodISODateTime; + signer: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"near">; + accountId: z.ZodString; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"evm">; + address: z.ZodString; + chainId: z.ZodNumber; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"passkey">; + credentialId: z.ZodString; + publicKey: z.ZodString; + rpId: z.ZodString; + origin: z.ZodString; + }, z.core.$strict>], "type">; + intent: z.ZodDiscriminatedUnion<[z.ZodObject<{ + standard: z.ZodLiteral<"nep413">; + payload: z.ZodObject<{ + message: z.ZodString; + recipient: z.ZodString; + nonce: z.ZodString; + }, z.core.$strict>; + }, z.core.$strict>, z.ZodObject<{ + standard: z.ZodLiteral<"nep366">; + payload: z.ZodObject<{ + receiverId: z.ZodString; + actions: z.ZodArray; + params: z.ZodObject<{ + methodName: z.ZodString; + args: z.ZodRecord; + gas: z.ZodString; + deposit: z.ZodString; + }, z.core.$strict>; + }, z.core.$strict>>; + }, z.core.$strict>; + }, z.core.$strict>, z.ZodObject<{ + standard: z.ZodLiteral<"eip712">; + payload: z.ZodObject<{ + domain: z.ZodRecord; + types: z.ZodRecord>>; + primaryType: z.ZodString; + message: z.ZodRecord; + }, z.core.$strict>; + }, z.core.$strict>, z.ZodObject<{ + standard: z.ZodLiteral<"webauthn">; + payload: z.ZodObject<{ + challenge: z.ZodString; + rpId: z.ZodString; + allowCredentials: z.ZodArray; + }, z.core.$strict>>; + userVerification: z.ZodLiteral<"required">; + timeout: z.ZodNumber; + }, z.core.$strict>; + }, z.core.$strict>], "standard">; + preview: z.ZodObject<{ + summary: z.ZodString; + revision: z.ZodOptional; + previousRevision: z.ZodOptional; + policyHash: z.ZodOptional; + deletion: z.ZodOptional; + priceUpdatedAt: z.ZodNullable; + priceExpiresAt: z.ZodNullable; + symbol: z.ZodNullable; + decimals: z.ZodNullable; + blockchain: z.ZodNullable; + balanceRaw: z.ZodString; + balance: z.ZodNullable; + }, z.core.$strict>>; + confidential: z.ZodArray; + priceUpdatedAt: z.ZodNullable; + priceExpiresAt: z.ZodNullable; + symbol: z.ZodNullable; + decimals: z.ZodNullable; + blockchain: z.ZodNullable; + balanceRaw: z.ZodString; + balance: z.ZodNullable; + }, z.core.$strict>>; + assetsLost: z.ZodBoolean; + retirement: z.ZodEnum<{ + credential_erase: "credential_erase"; + provider_delete: "provider_delete"; + }>; + policyAllowsDelete: z.ZodBoolean; + }, z.core.$strict>>; + approval: z.ZodOptional; + }, z.core.$strict>>; + }, z.core.$strict>; + replayed: z.ZodBoolean; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + submitIntent: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + correlationId: z.ZodString; + signedData: z.ZodDiscriminatedUnion<[z.ZodObject<{ + standard: z.ZodLiteral<"nep413">; + payload: z.ZodObject<{ + message: z.ZodString; + recipient: z.ZodString; + nonce: z.ZodString; + }, z.core.$strict>; + public_key: z.ZodString; + signature: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + standard: z.ZodLiteral<"nep366">; + payload: z.ZodObject<{ + receiverId: z.ZodString; + actions: z.ZodArray; + params: z.ZodObject<{ + methodName: z.ZodString; + args: z.ZodRecord; + gas: z.ZodString; + deposit: z.ZodString; + }, z.core.$strict>; + }, z.core.$strict>>; + }, z.core.$strict>; + signedDelegate: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + standard: z.ZodLiteral<"eip712">; + payload: z.ZodObject<{ + domain: z.ZodRecord; + types: z.ZodRecord>>; + primaryType: z.ZodString; + message: z.ZodRecord; + }, z.core.$strict>; + signature: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + standard: z.ZodLiteral<"webauthn">; + payload: z.ZodObject<{ + challenge: z.ZodString; + rpId: z.ZodString; + allowCredentials: z.ZodArray; + }, z.core.$strict>>; + userVerification: z.ZodLiteral<"required">; + timeout: z.ZodNumber; + }, z.core.$strict>; + credential: z.ZodObject<{ + id: z.ZodString; + rawId: z.ZodString; + type: z.ZodLiteral<"public-key">; + response: z.ZodObject<{ + clientDataJSON: z.ZodString; + authenticatorData: z.ZodString; + signature: z.ZodString; + userHandle: z.ZodOptional; + }, z.core.$strip>; + clientExtensionResults: z.ZodRecord; + authenticatorAttachment: z.ZodOptional>; + }, z.core.$strip>; + }, z.core.$strict>], "standard">; + }, z.core.$strict>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + intentStatus: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + }, z.core.$strip>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + listAgents: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + cursor: z.ZodOptional; + }, z.core.$strict>, z.ZodObject<{ + data: z.ZodArray; + status: z.ZodEnum<{ + ABANDONED: "ABANDONED"; + ACTIVE: "ACTIVE"; + ARCHIVED: "ARCHIVED"; + DELETED: "DELETED"; + PENDING: "PENDING"; + }>; + archived: z.ZodBoolean; + deleted: z.ZodBoolean; + owner: z.ZodNullable; + accountId: z.ZodString; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"evm">; + address: z.ZodString; + chainId: z.ZodNumber; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"passkey">; + credentialId: z.ZodString; + publicKey: z.ZodString; + rpId: z.ZodString; + origin: z.ZodString; + }, z.core.$strict>], "type">>; + ownerAccount: z.ZodNullable; + }, z.core.$strict>>; + wallet: z.ZodNullable; + }, z.core.$strict>>; + createdAt: z.ZodString; + }, z.core.$strict>>; + nextCursor: z.ZodNullable; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getAgent: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + externalUserId: z.ZodNullable; + status: z.ZodEnum<{ + ABANDONED: "ABANDONED"; + ACTIVE: "ACTIVE"; + ARCHIVED: "ARCHIVED"; + DELETED: "DELETED"; + PENDING: "PENDING"; + }>; + archived: z.ZodBoolean; + deleted: z.ZodBoolean; + owner: z.ZodNullable; + accountId: z.ZodString; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"evm">; + address: z.ZodString; + chainId: z.ZodNumber; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"passkey">; + credentialId: z.ZodString; + publicKey: z.ZodString; + rpId: z.ZodString; + origin: z.ZodString; + }, z.core.$strict>], "type">>; + ownerAccount: z.ZodNullable; + }, z.core.$strict>>; + wallet: z.ZodNullable; + }, z.core.$strict>>; + createdAt: z.ZodString; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getWallet: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + walletId: z.ZodString; + nearAccountId: z.ZodString; + evmAddress: z.ZodNullable; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getBalances: import("@orpc/contract").ProcedureContractBuilderWithInputOutput>; + asset: z.ZodOptional; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodObject<{ + nearAccountId: z.ZodString; + source: z.ZodEnum<{ + confidential: "confidential"; + public: "public"; + }>; + balances: z.ZodArray; + priceUpdatedAt: z.ZodNullable; + priceExpiresAt: z.ZodNullable; + symbol: z.ZodNullable; + decimals: z.ZodNullable; + blockchain: z.ZodNullable; + balanceRaw: z.ZodString; + balance: z.ZodNullable; + }, z.core.$strict>>; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getPolicy: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + walletId: z.ZodString; + revision: z.ZodNullable; + policyHash: z.ZodNullable; + status: z.ZodEnum<{ + APPLIED: "APPLIED"; + FAILED: "FAILED"; + NONE: "NONE"; + SIGNED: "SIGNED"; + }>; + appliedAt: z.ZodNullable; + transactionHash: z.ZodNullable; + providerPolicySynced: z.ZodBoolean; + policy: z.ZodNullable; + capabilities: z.ZodObject<{ + confidential: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + cross_chain_withdraw: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + evm_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + raw_tx: z.ZodBoolean; + }, z.core.$strict>; + raw_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + chains: z.ZodArray; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + sign_message: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + allowed_recipients: z.ZodArray; + }, z.core.$strict>; + swap: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + }, z.core.$strict>; + frozen: z.ZodBoolean; + rules: z.ZodObject<{ + allowed_tokens: z.ZodArray; + transaction_types: z.ZodArray>; + limits: z.ZodOptional>; + hourly: z.ZodOptional>; + daily: z.ZodOptional>; + monthly: z.ZodOptional>; + }, z.core.$strict>>; + rate_limit: z.ZodOptional>; + addresses: z.ZodOptional; + list: z.ZodArray; + }, z.core.$strict>>; + items: z.ZodOptional>>; + }, z.core.$strict>; + approval: z.ZodOptional; + }, z.core.$strict>>; + excluded_types: z.ZodArray>; + threshold: z.ZodObject<{ + required: z.ZodNumber; + }, z.core.$strict>; + }, z.core.$strict>>; + }, z.core.$strict>>; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getPolicyHistory: import("@orpc/contract").ProcedureContractBuilderWithInputOutput>; + limit: z.ZodDefault>; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodObject<{ + data: z.ZodArray; + policyHash: z.ZodNullable; + status: z.ZodEnum<{ + APPLIED: "APPLIED"; + FAILED: "FAILED"; + NONE: "NONE"; + SIGNED: "SIGNED"; + }>; + appliedAt: z.ZodNullable; + transactionHash: z.ZodNullable; + policy: z.ZodNullable; + capabilities: z.ZodObject<{ + confidential: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + cross_chain_withdraw: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + evm_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + raw_tx: z.ZodBoolean; + }, z.core.$strict>; + raw_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + chains: z.ZodArray; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + sign_message: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + allowed_recipients: z.ZodArray; + }, z.core.$strict>; + swap: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + }, z.core.$strict>; + frozen: z.ZodBoolean; + rules: z.ZodObject<{ + allowed_tokens: z.ZodArray; + transaction_types: z.ZodArray>; + limits: z.ZodOptional>; + hourly: z.ZodOptional>; + daily: z.ZodOptional>; + monthly: z.ZodOptional>; + }, z.core.$strict>>; + rate_limit: z.ZodOptional>; + addresses: z.ZodOptional; + list: z.ZodArray; + }, z.core.$strict>>; + items: z.ZodOptional>>; + }, z.core.$strict>; + approval: z.ZodOptional; + }, z.core.$strict>>; + excluded_types: z.ZodArray>; + threshold: z.ZodObject<{ + required: z.ZodNumber; + }, z.core.$strict>; + }, z.core.$strict>>; + }, z.core.$strict>>; + }, z.core.$strict>>; + nextCursor: z.ZodNullable; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getLimits: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + walletId: z.ZodString; + frozen: z.ZodNullable; + capabilities: z.ZodNullable; + cross_chain_withdraw: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + evm_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + raw_tx: z.ZodBoolean; + }, z.core.$strict>; + raw_sign: z.ZodObject<{ + allowed: z.ZodBoolean; + chains: z.ZodArray; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + sign_message: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + allowed_recipients: z.ZodArray; + }, z.core.$strict>; + swap: z.ZodObject<{ + allowed: z.ZodBoolean; + requires_approval: z.ZodBoolean; + }, z.core.$strict>; + }, z.core.$strict>>; + limits: z.ZodNullable>; + hourly: z.ZodOptional>; + daily: z.ZodOptional>; + monthly: z.ZodOptional>; + }, z.core.$strict>>; + rateLimit: z.ZodNullable>; + addresses: z.ZodNullable; + list: z.ZodArray; + }, z.core.$strict>>; + allowedTokens: z.ZodNullable>; + transactionTypes: z.ZodNullable>>; + approval: z.ZodNullable; + }, z.core.$strict>>; + excluded_types: z.ZodArray>; + threshold: z.ZodObject<{ + required: z.ZodNumber; + }, z.core.$strict>; + }, z.core.$strict>>; + policySynced: z.ZodBoolean; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getBudget: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + daily: z.ZodObject<{ + limitUsd: z.ZodNullable; + spentUsd: z.ZodString; + remainingUsd: z.ZodNullable; + resetsAt: z.ZodNullable; + }, z.core.$strict>; + weekly: z.ZodObject<{ + limitUsd: z.ZodNullable; + spentUsd: z.ZodString; + remainingUsd: z.ZodNullable; + resetsAt: z.ZodNullable; + }, z.core.$strict>; + monthly: z.ZodObject<{ + limitUsd: z.ZodNullable; + spentUsd: z.ZodString; + remainingUsd: z.ZodNullable; + resetsAt: z.ZodNullable; + }, z.core.$strict>; + revision: z.ZodNumber; + enforcedBy: z.ZodLiteral<"agent_api">; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getTimelock: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + delaySeconds: z.ZodNumber; + revision: z.ZodNumber; + scheduledCount: z.ZodNumber; + enforcedBy: z.ZodLiteral<"agent_api">; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + listScheduledExecutions: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + limit: z.ZodDefault>; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodObject<{ + data: z.ZodArray; + action: z.ZodNullable; + }, z.core.$strict>>; + nextCursor: z.ZodNullable; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + getTokenCatalog: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + data: z.ZodArray; + priceUpdatedAt: z.ZodNullable; + priceExpiresAt: z.ZodNullable; + }, z.core.$strict>>; + }, z.core.$strict>, object>; + getHistory: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + limit: z.ZodDefault>; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodObject<{ + data: z.ZodArray; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">>; + nextCursor: z.ZodNullable; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + listGrants: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + data: z.ZodArray; + recipients: z.ZodArray; + kind: z.ZodEnum<{ + "chain-address": "chain-address"; + "confidential-account": "confidential-account"; + "intents-account": "intents-account"; + }>; + chain: z.ZodString; + network: z.ZodLiteral<"mainnet">; + address: z.ZodString; + memo: z.ZodUnion; + }, z.core.$strict>, z.ZodObject<{ + kind: z.ZodLiteral<"exact">; + value: z.ZodString; + }, z.core.$strict>]>; + purpose: z.ZodEnum<{ + payout: "payout"; + refund: "refund"; + }>; + }, z.core.$strict>>; + signingAudiences: z.ZodArray; + issuedAt: z.ZodString; + expiresAt: z.ZodString; + revokedAt: z.ZodNullable; + revokedReason: z.ZodNullable; + ownerEpoch: z.ZodNumber; + ownerMessage: z.ZodUnion; + owner: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"near">; + accountId: z.ZodString; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"evm">; + address: z.ZodString; + chainId: z.ZodNumber; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"passkey">; + credentialId: z.ZodString; + publicKey: z.ZodString; + rpId: z.ZodString; + origin: z.ZodString; + }, z.core.$strict>], "type">; + tenant_id: z.ZodString; + agent_id: z.ZodString; + wallet_id: z.ZodString; + label: z.ZodString; + credential: z.ZodString; + actions: z.ZodArray; + recipients: z.ZodArray; + kind: z.ZodEnum<{ + "chain-address": "chain-address"; + "confidential-account": "confidential-account"; + "intents-account": "intents-account"; + }>; + chain: z.ZodString; + network: z.ZodLiteral<"mainnet">; + address: z.ZodString; + memo: z.ZodUnion; + }, z.core.$strict>, z.ZodObject<{ + kind: z.ZodLiteral<"exact">; + value: z.ZodString; + }, z.core.$strict>]>; + purpose: z.ZodEnum<{ + payout: "payout"; + refund: "refund"; + }>; + }, z.core.$strict>>; + signing_audiences: z.ZodArray; + network: z.ZodLiteral<"mainnet">; + issued_at_ms: z.ZodNumber; + expires_at_ms: z.ZodNumber; + owner_epoch: z.ZodNumber; + nonce: z.ZodString; + recipient: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + owner: z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral<"near">; + accountId: z.ZodString; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"evm">; + address: z.ZodString; + chainId: z.ZodNumber; + publicKey: z.ZodString; + }, z.core.$strict>, z.ZodObject<{ + type: z.ZodLiteral<"passkey">; + credentialId: z.ZodString; + publicKey: z.ZodString; + rpId: z.ZodString; + origin: z.ZodString; + }, z.core.$strict>], "type">; + tenant_id: z.ZodString; + agent_id: z.ZodString; + wallet_id: z.ZodString; + label: z.ZodString; + credential: z.ZodString; + actions: z.ZodArray; + issued_at_ms: z.ZodNumber; + expires_at_ms: z.ZodNumber; + owner_epoch: z.ZodNumber; + nonce: z.ZodString; + recipient: z.ZodString; + domain: z.ZodLiteral<"near-intents-agent-api.agent-grant.v3">; + recipients: z.ZodArray; + }, z.core.$strict>]>; + }, z.core.$strict>>; + }, z.core.$strict>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + swap: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + confidential: z.ZodDefault; + dry: z.ZodDefault; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodUnion; + type: z.ZodEnum<{ + swap: "swap"; + withdraw: "withdraw"; + }>; + quote: z.ZodRecord; + }, z.core.$strict>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">]>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + withdraw: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + confidential: z.ZodDefault; + async: z.ZodDefault; + dry: z.ZodDefault; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodUnion; + type: z.ZodEnum<{ + swap: "swap"; + withdraw: "withdraw"; + }>; + quote: z.ZodRecord; + }, z.core.$strict>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">]>, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + transfer: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + shield: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + unshield: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + confidentialDeposit: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + deposit: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + destinationAsset: z.ZodOptional; + amount: z.ZodString; + chain: z.ZodOptional; + asset: z.ZodOptional; + refundTo: z.ZodOptional; + confidential: z.ZodDefault; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + recover: import("@orpc/contract").ProcedureContractBuilderWithInputOutput; + confidential: z.ZodDefault; + dry: z.ZodDefault; + type: z.ZodLiteral<"swap">; + }, z.core.$strict>, z.ZodObject<{ + asset: z.ZodString; + amount: z.ZodString; + chain: z.ZodString; + recipient: z.ZodString; + memo: z.ZodOptional; + confidential: z.ZodDefault; + async: z.ZodDefault; + dry: z.ZodDefault; + type: z.ZodLiteral<"withdraw">; + }, z.core.$strict>, z.ZodObject<{ + asset: z.ZodString; + amount: z.ZodString; + recipient: z.ZodString; + confidential: z.ZodDefault; + type: z.ZodLiteral<"transfer">; + }, z.core.$strict>, z.ZodObject<{ + asset: z.ZodString; + amount: z.ZodString; + type: z.ZodLiteral<"shield">; + }, z.core.$strict>, z.ZodObject<{ + asset: z.ZodString; + amount: z.ZodString; + type: z.ZodLiteral<"unshield">; + }, z.core.$strict>, z.ZodObject<{ + asset: z.ZodString; + amount: z.ZodString; + type: z.ZodLiteral<"confidential_deposit">; + }, z.core.$strict>], "type">; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; + signMessage: import("@orpc/contract").ProcedureContractBuilderWithInputOutput, z.ZodEnum<{ + arbitrum: "arbitrum"; + avalanche: "avalanche"; + base: "base"; + bsc: "bsc"; + ethereum: "ethereum"; + optimism: "optimism"; + polygon: "polygon"; + }>]>; + message: z.ZodString; + encoding: z.ZodDefault>; + recipient: z.ZodOptional; + }, z.core.$strict>, z.ZodObject<{ + agentId: z.ZodString; + }, z.core.$strip>>, z.ZodDiscriminatedUnion<[z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>, ...z.ZodObject<{ + type: z.ZodLiteral; + }, z.core.$strip>[]], "type">, { + UNAUTHORIZED: { + readonly status: 401; + readonly data: z.ZodObject<{ + apiKeyProvided: z.ZodBoolean; + provider: z.ZodOptional; + authType: z.ZodOptional>; + }, z.core.$strip>; + }; + }>; +}; +export type GenerateIntentInput = GenerateIntentRequest; +export type SubmitIntentInput = SubmitIntentRequest; +export type ContractType = typeof contract; diff --git a/plugins/agents/ui/components.json b/plugins/agents/ui/components.json new file mode 100644 index 000000000..65be6fbba --- /dev/null +++ b/plugins/agents/ui/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-maia", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "../../../ui/src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "phosphor", + "rtl": false, + "menuColor": "default", + "menuAccent": "bold" +} diff --git a/plugins/agents/ui/postcss.config.mjs b/plugins/agents/ui/postcss.config.mjs new file mode 100644 index 000000000..d24dcd984 --- /dev/null +++ b/plugins/agents/ui/postcss.config.mjs @@ -0,0 +1,12 @@ +/** + * PostCSS configuration with Tailwind CSS. + * + * BE CAREFUL MODIFYING THIS FILE — changes will be overwritten by `bos sync` / `bos upgrade`. + * Prefer upstream changes at https://github.com/nearbuilders/everything-dev + */ + +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/plugins/agents/ui/src/api.ts b/plugins/agents/ui/src/api.ts new file mode 100644 index 000000000..b49b34123 --- /dev/null +++ b/plugins/agents/ui/src/api.ts @@ -0,0 +1,28 @@ +import type { ContractRouterClient } from "@orpc/contract"; +import { + type ClientServiceConfig, + createPluginApiClient as createFrameworkPluginClient, +} from "everything-dev/ui/api"; +import type { ContractType } from "../../src/contract"; + +export type AgentsContract = ContractType; +export type AgentsClient = ContractRouterClient; + +export function createAgentsClient(config: ClientServiceConfig): AgentsClient { + return createFrameworkPluginClient("agents", config); +} + +/** + * A grant-scoped agents client: each token gets its own instance carrying + * the `x-grant-token` header on every call. + */ +export function createGrantScopedAgentsClient( + config: ClientServiceConfig, + token: string, +): AgentsClient { + return createFrameworkPluginClient( + "agents", + config, + new Headers({ "x-grant-token": token }), + ); +} diff --git a/plugins/agents/ui/src/components/empty-state.tsx b/plugins/agents/ui/src/components/empty-state.tsx new file mode 100644 index 000000000..d68917b5b --- /dev/null +++ b/plugins/agents/ui/src/components/empty-state.tsx @@ -0,0 +1,27 @@ +import type { ComponentType, ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface EmptyStateProps { + icon?: ComponentType<{ size?: number; className?: string }>; + title?: string; + description?: ReactNode; + action?: ReactNode; + className?: string; +} + +export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) { + return ( +
+
+ {Icon && ( +
+ +
+ )} + {title &&

{title}

} + {description &&
{description}
} + {action &&
{action}
} +
+
+ ); +} diff --git a/plugins/agents/ui/src/components/index.ts b/plugins/agents/ui/src/components/index.ts new file mode 100644 index 000000000..b8fbf2d56 --- /dev/null +++ b/plugins/agents/ui/src/components/index.ts @@ -0,0 +1,5 @@ +export { EmptyState } from "./empty-state"; +export { PageContainer } from "./layout/page-container"; +export { PageHeader } from "./layout/page-header"; +export { SectionHeader } from "./layout/section-header"; +export { Button } from "./ui/button"; diff --git a/plugins/agents/ui/src/components/layout/page-container.tsx b/plugins/agents/ui/src/components/layout/page-container.tsx new file mode 100644 index 000000000..6a756fbfe --- /dev/null +++ b/plugins/agents/ui/src/components/layout/page-container.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +const variants = { + narrow: "max-w-2xl", + default: "max-w-5xl", + wide: "max-w-7xl", +} as const; + +type PageContainerVariant = keyof typeof variants; + +interface PageContainerProps { + variant?: PageContainerVariant; + children: ReactNode; + className?: string; +} + +export function PageContainer({ variant = "default", children, className }: PageContainerProps) { + return ( +
+ {children} +
+ ); +} diff --git a/plugins/agents/ui/src/components/layout/page-header.tsx b/plugins/agents/ui/src/components/layout/page-header.tsx new file mode 100644 index 000000000..08cf0bb74 --- /dev/null +++ b/plugins/agents/ui/src/components/layout/page-header.tsx @@ -0,0 +1,46 @@ +import type { ComponentType, ReactNode } from "react"; + +interface PageHeaderProps { + icon?: ComponentType<{ className?: string }>; + label?: ReactNode; + title: ReactNode; + subtitle?: ReactNode; + description?: ReactNode; + actions?: ReactNode; + headerTestId?: string; +} + +export function PageHeader({ + icon: Icon, + label, + title, + subtitle, + description, + actions, + headerTestId, +}: PageHeaderProps) { + return ( +
+ {label && ( +
+ {Icon && } + {label} +
+ )} +
+
+

+ {title} +

+ {subtitle && ( +
{subtitle}
+ )} + {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+
+ ); +} diff --git a/plugins/agents/ui/src/components/layout/section-header.tsx b/plugins/agents/ui/src/components/layout/section-header.tsx new file mode 100644 index 000000000..3d5eb30dd --- /dev/null +++ b/plugins/agents/ui/src/components/layout/section-header.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; + +interface SectionHeaderProps { + title: ReactNode; + description?: ReactNode; + action?: ReactNode; + sectionTestId?: string; +} + +export function SectionHeader({ title, description, action, sectionTestId }: SectionHeaderProps) { + return ( +
+
+

+ {title} +

+ {description &&

{description}

} +
+ {action} +
+ ); +} diff --git a/plugins/agents/ui/src/components/ui/badge.tsx b/plugins/agents/ui/src/components/ui/badge.tsx new file mode 100644 index 000000000..b89828880 --- /dev/null +++ b/plugins/agents/ui/src/components/ui/badge.tsx @@ -0,0 +1,53 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border bg-input/30 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + success: + "border-status-success-border/40 bg-status-success-bg text-status-success-foreground", + warning: + "border-status-warning-border/40 bg-status-warning-bg text-status-warning-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props, + ), + render, + state: { + slot: "badge", + variant, + }, + }); +} + +export { Badge, badgeVariants }; diff --git a/plugins/agents/ui/src/components/ui/button.tsx b/plugins/agents/ui/src/components/ui/button.tsx new file mode 100644 index 000000000..fc49ffdfc --- /dev/null +++ b/plugins/agents/ui/src/components/ui/button.tsx @@ -0,0 +1,91 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-11 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4", + xs: "h-7 gap-1 px-3 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", + sm: "h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + lg: "h-12 gap-2 px-6 text-base has-data-[icon=inline-end]:pr-5 has-data-[icon=inline-start]:pl-5 [&_svg:not([class*='size-'])]:size-5", + icon: "size-11", + "icon-xs": "size-7 [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-9", + "icon-lg": "size-12 [&_svg:not([class*='size-'])]:size-5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function ButtonLink({ + className, + variant = "default", + size = "default", + render, + nativeButton: _nativeButton, + focusableWhenDisabled: _focusableWhenDisabled, + disabled = false, + style, + ...props +}: ButtonPrimitive.Props & VariantProps) { + const state: { disabled: boolean } = { disabled }; + const resolvedClassName = typeof className === "function" ? className(state) : className; + return useRender({ + defaultTagName: "a", + render, + state, + props: { + ...mergeProps<"button">( + { + className: cn(buttonVariants({ variant, size, className: resolvedClassName })), + style: typeof style === "function" ? style(state) : style, + "aria-disabled": disabled || undefined, + }, + props, + ), + "data-slot": "button", + }, + }); +} + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + if (props.nativeButton === false && props.render) { + return ; + } + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/plugins/agents/ui/src/components/ui/card.tsx b/plugins/agents/ui/src/components/ui/card.tsx new file mode 100644 index 000000000..77dc17cf4 --- /dev/null +++ b/plugins/agents/ui/src/components/ui/card.tsx @@ -0,0 +1,80 @@ +import { cn } from "cn"; +import type * as React from "react"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/plugins/agents/ui/src/components/ui/dialog.tsx b/plugins/agents/ui/src/components/ui/dialog.tsx new file mode 100644 index 000000000..de1c2cfbe --- /dev/null +++ b/plugins/agents/ui/src/components/ui/dialog.tsx @@ -0,0 +1,132 @@ +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { XIcon } from "@phosphor-icons/react"; +import { cn } from "cn"; +import type * as React from "react"; +import { Button } from "@/components/ui/button"; + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return ; +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return ; +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return ; +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return ; +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + } + > + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + }>Close + )} +
+ ); +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/plugins/agents/ui/src/components/ui/field.tsx b/plugins/agents/ui/src/components/ui/field.tsx new file mode 100644 index 000000000..d121ebebb --- /dev/null +++ b/plugins/agents/ui/src/components/ui/field.tsx @@ -0,0 +1,222 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; +import { useMemo } from "react"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, +}); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( + // biome-ignore lint/a11y/useSemanticElements: fieldset semantics come from FieldSet; Field groups a single control +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function FieldLabel({ className, ...props }: React.ComponentProps) { + return ( +