@onride/agent 0.0.0-stage → 0.5.1-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.env.example +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +174 -2
  4. package/dist/account_balances.js +435 -0
  5. package/dist/account_reads.js +199 -0
  6. package/dist/agent/adapter.js +115 -0
  7. package/dist/agent/analytics.js +174 -0
  8. package/dist/agent/backend.js +237 -0
  9. package/dist/agent/card_hosts.js +9 -0
  10. package/dist/agent/contracts.js +276 -0
  11. package/dist/agent/conversational.js +86 -0
  12. package/dist/agent/core_bridge.js +1155 -0
  13. package/dist/agent/deposits.js +309 -0
  14. package/dist/agent/discovery.js +205 -0
  15. package/dist/agent/domain.js +507 -0
  16. package/dist/agent/firebase.js +34 -0
  17. package/dist/agent/funding.js +230 -0
  18. package/dist/agent/http.js +40 -0
  19. package/dist/agent/human.js +298 -0
  20. package/dist/agent/human_handoffs.js +39 -0
  21. package/dist/agent/ledger.js +137 -0
  22. package/dist/agent/mcp.js +245 -0
  23. package/dist/agent/order_rules.js +61 -0
  24. package/dist/agent/perps_account.js +41 -0
  25. package/dist/agent/prediction.js +607 -0
  26. package/dist/agent/prediction_equity_history.js +910 -0
  27. package/dist/agent/prediction_history.js +336 -0
  28. package/dist/agent/preferences.js +114 -0
  29. package/dist/agent/repository.js +106 -0
  30. package/dist/agent/runner.js +683 -0
  31. package/dist/agent/service.js +1347 -0
  32. package/dist/backend.js +6 -0
  33. package/dist/bridge.js +6 -0
  34. package/dist/cli/clients.js +99 -0
  35. package/dist/cli/commands.js +332 -0
  36. package/dist/cli/onboarding.js +53 -0
  37. package/dist/cli/session.js +224 -0
  38. package/dist/cli/setup.js +256 -0
  39. package/dist/cli.js +16 -0
  40. package/dist/guide.js +92 -0
  41. package/dist/http_security.js +79 -0
  42. package/dist/mcp-cli.js +7 -0
  43. package/dist/oauth.js +1032 -0
  44. package/dist/server.js +171 -0
  45. package/dist/start.js +18 -0
  46. package/dist/version.js +25 -0
  47. package/docs/confirmation-cards.md +118 -0
  48. package/docs/conversation-v050.md +30 -0
  49. package/docs/conversational-v17.md +48 -0
  50. package/docs/deployment.md +58 -0
  51. package/docs/perps-leverage.md +43 -0
  52. package/docs/prediction-history.md +28 -0
  53. package/docs/preferences-funding-v042.md +17 -0
  54. package/docs/ride-agent-migration.md +127 -0
  55. package/docs/v17-completion-checklist.md +31 -0
  56. package/docs/v17-rollout.md +44 -0
  57. package/docs/widget-account-v043.md +17 -0
  58. package/package.json +75 -4
  59. package/public/agent.html +2313 -0
  60. package/public/confirm.html +2373 -0
  61. package/public/fund.html +563 -0
  62. package/skills/ride/SKILL.md +18 -0
  63. package/skills/ride/references/commands.md +47 -0
  64. package/skills/ride/references/workflow.md +47 -0
@@ -0,0 +1,137 @@
1
+ import { AgentError } from "./contracts.js";
2
+ import { D, } from "./domain.js";
3
+ function trade(l, q, px) {
4
+ const old = D(l.quantity), delta = D(q), price = D(px);
5
+ if (delta.isZero())
6
+ return;
7
+ const closes = !old.isZero() && old.isPositive() !== delta.isPositive();
8
+ const closed = closes ? D.min(old.abs(), delta.abs()) : D(0);
9
+ const avg = old.isZero() ? D(0) : D(l.cost).div(old);
10
+ l.realized = D(l.realized)
11
+ .plus(closed.times(price.minus(avg)).times(old.isPositive() ? 1 : -1))
12
+ .toString();
13
+ const next = old.plus(delta);
14
+ l.cost = (closes
15
+ ? next.isZero()
16
+ ? D(0)
17
+ : next.isPositive() === old.isPositive()
18
+ ? next.times(avg)
19
+ : next.times(price)
20
+ : D(l.cost).plus(delta.times(price))).toString();
21
+ l.quantity = next.toString();
22
+ }
23
+ function lot(p, sleeveId, key) {
24
+ const s = p.sleeves.find((x) => x.copy_id === sleeveId);
25
+ if (!s)
26
+ throw new AgentError("LEDGER_OWNER", "Unknown sleeve owner.");
27
+ const intent = s.intents[key], existing = s.lots[key];
28
+ if (existing)
29
+ return existing;
30
+ if (!intent)
31
+ throw new AgentError("LEDGER_OWNER", "An owned intent is required.");
32
+ return (s.lots[key] = {
33
+ key,
34
+ asset: intent.asset,
35
+ market: intent.market,
36
+ quantity: "0",
37
+ cost: "0",
38
+ realized: "0",
39
+ fees: "0",
40
+ funding: "0",
41
+ });
42
+ }
43
+ // Internal crosses settle virtual sleeve transfers at the same observed mark.
44
+ // Their quantities sum to zero, and their combined PnL is conserved.
45
+ export function internalCross(p, key, price) {
46
+ const deltas = p.sleeves.map((s) => ({
47
+ id: s.copy_id,
48
+ q: D(s.intents[key]?.quantity ?? 0).minus(s.lots[key]?.quantity ?? 0),
49
+ }));
50
+ const buys = deltas.filter((x) => x.q.gt(0)), sells = deltas.filter((x) => x.q.lt(0));
51
+ for (const buy of buys)
52
+ for (const sell of sells) {
53
+ const q = D.min(buy.q, sell.q.neg());
54
+ if (q.isZero())
55
+ continue;
56
+ trade(lot(p, buy.id, key), q.toString(), price);
57
+ trade(lot(p, sell.id, key), q.neg().toString(), price);
58
+ buy.q = buy.q.minus(q);
59
+ sell.q = sell.q.plus(q);
60
+ }
61
+ return Object.fromEntries(deltas.filter((x) => !x.q.isZero()).map((x) => [x.id, x.q.toString()]));
62
+ }
63
+ export function applyFill(p, fill) {
64
+ if (p.seen_fills.includes(fill.id))
65
+ return;
66
+ const e = p.executions.find((x) => x.task_id === fill.task_id && x.key === fill.key);
67
+ if (!e)
68
+ throw new AgentError("UNOWNED_FILL", "Exchange fill has no owned execution.");
69
+ const entries = Object.entries(e.allocations).filter(([, q]) => !D(q).isZero()), total = entries.reduce((n, [, q]) => n.plus(q), D(0));
70
+ const q = D(fill.quantity);
71
+ if (total.isZero() ||
72
+ q.isZero() ||
73
+ q.isPositive() !== total.isPositive() ||
74
+ entries.some(([, allocation]) => D(allocation).isPositive() !== q.isPositive()) ||
75
+ q.abs().gt(total.abs()))
76
+ throw new AgentError("FILL_MISMATCH", "Fill exceeds the unsettled owned execution.");
77
+ let remaining = q, feeRemaining = D(fill.fee_usdc);
78
+ // Keep virtual ownership on the venue's quantity grid. A repeating decimal
79
+ // share otherwise leaves the owned sum unequal to an exact exchange position.
80
+ const decimals = Math.max(p.snapshot?.quantity_decimals?.[fill.key] ?? 8, q.decimalPlaces(), ...entries.map(([, allocation]) => D(allocation).decimalPlaces()));
81
+ for (let i = 0; i < entries.length; i++) {
82
+ const [sid, remainingQ] = entries[i], last = i === entries.length - 1;
83
+ const laterCapacity = entries
84
+ .slice(i + 1)
85
+ .reduce((n, [, allocation]) => n.plus(D(allocation).abs()), D(0));
86
+ const proportional = q
87
+ .abs()
88
+ .times(D(remainingQ).abs())
89
+ .div(total.abs())
90
+ .toDecimalPlaces(decimals, 1);
91
+ const minimum = D.max(0, remaining.abs().minus(laterCapacity));
92
+ const part = last
93
+ ? remaining
94
+ : D.min(D(remainingQ).abs(), D.max(proportional, minimum)).times(q.isPositive() ? 1 : -1);
95
+ const fee = last ? feeRemaining : D(fill.fee_usdc).times(part.div(q));
96
+ const l = lot(p, sid, fill.key);
97
+ trade(l, part.toString(), fill.price);
98
+ l.fees = D(l.fees).plus(fee).toString();
99
+ e.allocations[sid] = D(remainingQ).minus(part).toString();
100
+ remaining = remaining.minus(part);
101
+ feeRemaining = feeRemaining.minus(fee);
102
+ }
103
+ p.seen_fills.push(fill.id);
104
+ }
105
+ export function applyFunding(p, event, holdings) {
106
+ if (p.seen_funding.includes(event.id))
107
+ return;
108
+ const total = Object.values(holdings).reduce((n, q) => n.plus(q), D(0));
109
+ if (total.isZero() && !D(event.amount_usdc).isZero())
110
+ throw new AgentError("FUNDING_MISMATCH", "Funding has no verified historical owned position.");
111
+ const entries = Object.entries(holdings).filter(([, q]) => !D(q).isZero());
112
+ let remaining = D(event.amount_usdc);
113
+ for (let i = 0; i < entries.length; i++) {
114
+ const [sid, q] = entries[i];
115
+ const share = i === entries.length - 1
116
+ ? remaining
117
+ : D(event.amount_usdc).times(D(q).div(total));
118
+ const l = lot(p, sid, event.key);
119
+ l.funding = D(l.funding).plus(share).toString();
120
+ remaining = remaining.minus(share);
121
+ }
122
+ p.seen_funding.push(event.id);
123
+ }
124
+ export function reconcileQuantities(p) {
125
+ if (!p.snapshot?.coverage_complete)
126
+ return false;
127
+ const keys = new Set([
128
+ ...Object.keys(p.snapshot.positions),
129
+ ...p.sleeves.flatMap((s) => Object.keys(s.lots)),
130
+ ]);
131
+ for (const key of keys) {
132
+ const actual = p.sleeves.reduce((n, s) => n.plus(s.lots[key]?.quantity ?? 0), D(0));
133
+ if (!actual.eq(p.snapshot.positions[key] ?? 0))
134
+ return false;
135
+ }
136
+ return true;
137
+ }
@@ -0,0 +1,245 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { z } from "zod";
4
+ import { accountCopiesInput } from "../account_reads.js";
5
+ import { claudeCardDomain } from "./card_hosts.js";
6
+ import { AgentError, failure, inputs, mutationTools, stateTools, publicResult, output, TOOL_NAMES, } from "./contracts.js";
7
+ export const RESOURCE_URI = "ui://ride/agent-v7-confirmation.html";
8
+ export function registerAppAccountRead(server, invoke) {
9
+ server.registerTool("get_my_copies", {
10
+ title: "Your Ride App copies and positions",
11
+ description: "Read the signed-in account's existing Ride App Perps and prediction copies and current copied positions. Successful reads verify login, including empty accounts. App copies remain managed in Ride.",
12
+ inputSchema: accountCopiesInput,
13
+ outputSchema: output,
14
+ annotations: {
15
+ readOnlyHint: true,
16
+ destructiveHint: false,
17
+ idempotentHint: true,
18
+ openWorldHint: true,
19
+ },
20
+ }, async (args, extra) => {
21
+ try {
22
+ const value = publicResult(await invoke(args, extra));
23
+ return {
24
+ content: [{ type: "text", text: JSON.stringify(value) }],
25
+ structuredContent: value,
26
+ isError: !!value.error,
27
+ };
28
+ }
29
+ catch (e) {
30
+ const value = failure(e);
31
+ return {
32
+ content: [{ type: "text", text: JSON.stringify(value) }],
33
+ structuredContent: value,
34
+ isError: true,
35
+ };
36
+ }
37
+ });
38
+ }
39
+ export function registerAgentTools(server, invoke, readOnly = false, confirmation) {
40
+ const confirmationOrigin = confirmation
41
+ ? new URL(confirmation.base).origin
42
+ : undefined;
43
+ server.server.registerCapabilities({
44
+ extensions: {
45
+ "io.modelcontextprotocol/ui": {
46
+ mimeTypes: ["text/html;profile=mcp-app"],
47
+ },
48
+ },
49
+ });
50
+ for (const resourceUri of [
51
+ RESOURCE_URI,
52
+ "ui://ride/agent-v6-confirmation.html",
53
+ "ui://ride/agent-v5-confirmation.html",
54
+ "ui://ride/agent-v4-confirmation.html",
55
+ "ui://ride/agent-v3.html",
56
+ ])
57
+ server.registerResource("ride-agent-cards", resourceUri, {
58
+ mimeType: "text/html;profile=mcp-app",
59
+ _meta: { "openai/widgetPrefersBorder": true },
60
+ }, async () => ({
61
+ contents: [
62
+ {
63
+ uri: resourceUri,
64
+ mimeType: "text/html;profile=mcp-app",
65
+ text: (await readFile(fileURLToPath(new URL("../../public/agent.html", import.meta.url)), "utf8")).replace("/*HOST_CONFIG*/{}", JSON.stringify({
66
+ confirmation_origin: confirmationOrigin,
67
+ }).replaceAll("<", "\\u003c")),
68
+ _meta: {
69
+ ui: {
70
+ prefersBorder: true,
71
+ ...(confirmationOrigin
72
+ ? {
73
+ csp: { frameDomains: [confirmationOrigin] },
74
+ domain: claudeCardDomain(confirmation.base),
75
+ }
76
+ : {}),
77
+ },
78
+ ...(confirmationOrigin
79
+ ? {
80
+ "openai/widgetCSP": { frame_domains: [confirmationOrigin] },
81
+ }
82
+ : {}),
83
+ },
84
+ },
85
+ ],
86
+ }));
87
+ if (confirmation && !readOnly)
88
+ server.registerTool("show_confirmation_card", {
89
+ title: "Ride confirmation card",
90
+ description: "Show an owned frozen proposal in a human confirmation card. mode:demo renders a 0-USDC interface test without account changes or any execution. This tool never confirms, signs or submits a proposal.",
91
+ inputSchema: z
92
+ .object({
93
+ mode: z.enum(["demo", "proposal"]),
94
+ proposal_id: z
95
+ .string()
96
+ .regex(/^proposal_[a-zA-Z0-9_-]{8,100}$/)
97
+ .optional(),
98
+ })
99
+ .strict(),
100
+ outputSchema: output,
101
+ annotations: {
102
+ readOnlyHint: true,
103
+ destructiveHint: false,
104
+ idempotentHint: true,
105
+ openWorldHint: false,
106
+ },
107
+ _meta: {
108
+ ui: { resourceUri: RESOURCE_URI },
109
+ "openai/outputTemplate": RESOURCE_URI,
110
+ },
111
+ }, async ({ mode, proposal_id }, extra) => {
112
+ try {
113
+ if (!extra.authInfo?.scopes?.includes("ride:read"))
114
+ throw new AgentError("SCOPE_REQUIRED", "Missing ride:read permission.", false, 403);
115
+ let value;
116
+ if (mode === "demo")
117
+ value = {
118
+ version: "3",
119
+ status: "ok",
120
+ data: {
121
+ confirmation_demo: true,
122
+ proposal: {
123
+ id: "proposal_card_demo_00000000",
124
+ action: "demo",
125
+ status: "requires_confirmation",
126
+ arguments: { amount_usdc: "0", execution: "disabled" },
127
+ preview: {},
128
+ expires_at: Date.now() + 300_000,
129
+ confirmation_url: new URL("/agent/card-demo", confirmation.base).href,
130
+ },
131
+ },
132
+ warnings: [],
133
+ as_of: new Date().toISOString(),
134
+ };
135
+ else {
136
+ if (!proposal_id)
137
+ throw new AgentError("NOT_FOUND", "Choose an owned proposal to display.", false, 400);
138
+ value = publicResult(await confirmation.readProposal(proposal_id, extra));
139
+ value.data.confirmation_card = true;
140
+ }
141
+ return {
142
+ content: [{ type: "text", text: JSON.stringify(value) }],
143
+ structuredContent: value,
144
+ isError: !!value.error,
145
+ };
146
+ }
147
+ catch (e) {
148
+ const value = failure(e);
149
+ return {
150
+ content: [{ type: "text", text: JSON.stringify(value) }],
151
+ structuredContent: value,
152
+ isError: true,
153
+ };
154
+ }
155
+ });
156
+ for (const name of TOOL_NAMES) {
157
+ if (readOnly &&
158
+ (mutationTools.has(name) ||
159
+ (stateTools.has(name) && name !== "recommend_traders")))
160
+ continue;
161
+ server.registerTool(name, {
162
+ title: name.replaceAll("_", " "),
163
+ description: description(name),
164
+ inputSchema: inputs[name],
165
+ outputSchema: output,
166
+ annotations: {
167
+ readOnlyHint: !mutationTools.has(name) && !stateTools.has(name),
168
+ destructiveHint: mutationTools.has(name),
169
+ idempotentHint: !stateTools.has(name) && !mutationTools.has(name),
170
+ openWorldHint: true,
171
+ },
172
+ ...(mutationTools.has(name) || name === "prepare_deposit"
173
+ ? {
174
+ _meta: {
175
+ ui: { resourceUri: RESOURCE_URI },
176
+ "openai/outputTemplate": RESOURCE_URI,
177
+ },
178
+ }
179
+ : {}),
180
+ }, async (args, extra) => {
181
+ try {
182
+ const value = publicResult(await invoke(name, args, extra));
183
+ return {
184
+ content: [{ type: "text", text: JSON.stringify(value) }],
185
+ structuredContent: value,
186
+ isError: !!value.error,
187
+ };
188
+ }
189
+ catch (e) {
190
+ const value = failure(e);
191
+ return {
192
+ content: [{ type: "text", text: JSON.stringify(value) }],
193
+ structuredContent: value,
194
+ isError: true,
195
+ };
196
+ }
197
+ });
198
+ }
199
+ }
200
+ export async function invokeAgent(url, token, name, args) {
201
+ if (!token)
202
+ throw new AgentError("AUTH_REQUIRED", "Log in to Ride first.", false, 401);
203
+ const response = await fetch(new URL(`/agent/v1/${name}`, url), {
204
+ method: "POST",
205
+ headers: {
206
+ "Content-Type": "application/json",
207
+ Authorization: `Bearer ${token}`,
208
+ },
209
+ body: JSON.stringify(args),
210
+ signal: AbortSignal.timeout(25000),
211
+ redirect: "error",
212
+ });
213
+ const body = await response.json();
214
+ if (!response.ok && !body.error)
215
+ throw new AgentError("BACKEND_UNAVAILABLE", "Ride Agent request failed.", response.status >= 500, response.status);
216
+ return publicResult(body);
217
+ }
218
+ function description(name) {
219
+ const descriptions = {
220
+ get_preferences: "Read saved/partial answers. Start in chat with budget and risk preference only; ask only returned missing questions. Preserve earlier answers. Numeric risk presets are suggestions requiring final confirmation, not user consent.",
221
+ prepare_deposit: "Prepare an exact-amount deposit activation for an owned funding request. Returns a human Ride confirmation URL; no funds move until the user confirms and signs there.",
222
+ get_deposit_options: "Read currently supported funding routes. No funds are transferred.",
223
+ get_deposit_address: "Create an owned funding request and verified receiving address/QR for the chosen supported network. No transfer or signing is performed.",
224
+ check_deposit_status: "Read an account-owned deposit request. Wallet receipt, bridge settlement and copy-ready funds remain separate.",
225
+ get_daily_checkin: "Offer one portfolio performance review per local day and timezone; does not send a message or notification.",
226
+ update_portfolio: "Propose changing the owned portfolio loss threshold; requires Ride confirmation and cannot unlatch a triggered stop.",
227
+ set_preferences: "Save only supplied user answers, including partial budget/risk answers. Defaults and numeric risk suggestions are marked separately; do not ask for optional settings up front. Existing portfolios are unchanged.",
228
+ recalculate_plan: "Refresh a saved preview or validate edited trader allocations against fresh eligibility, budget and leverage limits. Returns a new frozen plan; no execution.",
229
+ get_performance: "Read persisted accounting snapshots for daily, weekly or inception performance. Historical sleeves remain after replacement; incomplete periods return null profit.",
230
+ diagnose_copy: "Read an owned copy's source fills, attributed follower receipts and event-time execution decisions for the same window (maximum 31 days). Missing evidence and counterfactual profit remain unavailable.",
231
+ get_notification_preferences: "Read daily Ride App digest preferences and actual push registration readiness. Server monitoring is independent of this chat.",
232
+ set_notification_preferences: "Save explicitly requested daily Ride App digest opt-in, local time and IANA timezone. Critical risk alerts remain enabled; this does not authorize trading.",
233
+ recommend_traders: "After get_preferences collect only missing budget/risk in conversation. Show recommended_portfolio and candidate_table as two compact chat tables. Assets default unrestricted, markets default both (either allowed); count is automatically feasible up to four unless user chooses 1–8 or trader_ids. Selections persist across edits; selection:system clears the pinned selection. Show candidates even if plan fails; no funding required. Defaults and risk parameters are suggestions for final Ride confirmation.",
234
+ browse_traders: "Page or sort the entire current verified candidate snapshot by score/drawdown/ROI; missing metrics sort last. Default five, request limit:10 for ten more, pass next_cursor with the same sort. compare_ids compares named public IDs. Does not replace the plan, allocations or selections. Disclose source pool limits, metric periods and incomparable/missing data. Refresh expired snapshots through recommend_traders.",
235
+ get_trader_profile: "Read verified source statistics and actual Copy Score components using a public trader ID.",
236
+ start_copy: "Create an expiring start proposal. Human confirmation in Ride is required unless bounded Ride authority applies.",
237
+ get_portfolio: "Read the signed-in account, per-market account balances, Agent portfolio and existing Ride App copies and positions. Account funds and copy allocation remaining are distinct; unavailable modules are null, not zero. An empty Agent portfolio still confirms login. App copies remain separately managed in Ride. Missing values are null.",
238
+ update_copy: "Propose amount, leverage cap, sleeve loss trigger or an explicit eligible replacement. Released funds must be verified.",
239
+ stop_copy: "Propose wind_down (default) or close_now. Removing a hedge can increase net exposure and is risk checked.",
240
+ review_portfolio: "Read factual portfolio observations and suggested changes. Suggestions do not execute.",
241
+ get_updates: "Read persisted monitoring and execution events using an opaque account-bound cursor. This call does not schedule monitoring.",
242
+ close_position: "Propose closing one owned aggregate position. All affected sleeves are included.",
243
+ };
244
+ return descriptions[name];
245
+ }
@@ -0,0 +1,61 @@
1
+ import { D } from "./domain.js";
2
+ // Hyperliquid's protocol opening-order floor, not a collateral allocation floor.
3
+ // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/error-responses
4
+ export const HYPERLIQUID_MIN_OPEN_NOTIONAL_USDC = "10";
5
+ /** Check the actual net opening leg, including a separate opening leg on flips.
6
+ * Hyperliquid reduce-only reductions/closes retain their dust-close exemption.
7
+ * Never round up an order or fall back to a trader's historical minimum.
8
+ */
9
+ export function checkPerpsOrder(snapshot, key, targetQuantity) {
10
+ const unavailable = {
11
+ ok: false,
12
+ code: "VENUE_RULES_UNAVAILABLE",
13
+ message: "Verified per-asset venue price, quantity precision and opening-order minimum are required.",
14
+ };
15
+ try {
16
+ const price = D(snapshot.prices[key] ?? "NaN");
17
+ const decimals = snapshot.quantity_decimals?.[key];
18
+ const target = D(targetQuantity);
19
+ const current = D(snapshot.positions[key] ?? "0");
20
+ if (!price.isFinite() ||
21
+ price.lte(0) ||
22
+ decimals == null ||
23
+ !Number.isInteger(decimals) ||
24
+ decimals < 0 ||
25
+ decimals > 8 ||
26
+ !target.isFinite() ||
27
+ !current.isFinite())
28
+ return unavailable;
29
+ if (!target.eq(target.toDecimalPlaces(decimals)))
30
+ return {
31
+ ok: false,
32
+ code: "INVALID_ORDER_QUANTITY",
33
+ message: "Target quantity exceeds the venue's current size precision; it must not be rounded up.",
34
+ };
35
+ if (target.eq(current))
36
+ return { ok: true };
37
+ const flip = !current.isZero() &&
38
+ !target.isZero() &&
39
+ current.isPositive() !== target.isPositive();
40
+ const reduction = !flip && target.abs().lte(current.abs());
41
+ if (reduction)
42
+ return { ok: true };
43
+ const minimum = D(snapshot.min_notionals[key] ?? "NaN");
44
+ if (!minimum.isFinite() || minimum.lte(0))
45
+ return unavailable;
46
+ const size = flip ? target.abs() : target.minus(current).abs();
47
+ const notional = size.times(price);
48
+ if (notional.lt(minimum))
49
+ return {
50
+ ok: false,
51
+ code: "BELOW_MINIMUM_NOTIONAL",
52
+ message: "Sized Perps opening order is below the venue minimum; skipped without increasing quantity or leverage.",
53
+ order_notional_usdc: notional.toString(),
54
+ minimum_notional_usdc: minimum.toString(),
55
+ };
56
+ return { ok: true };
57
+ }
58
+ catch {
59
+ return unavailable;
60
+ }
61
+ }
@@ -0,0 +1,41 @@
1
+ import { AgentError } from "./contracts.js";
2
+ import { D, cash } from "./domain.js";
3
+ export function perpsAccountMode(value) {
4
+ if (value && typeof value === "object") {
5
+ const row = value;
6
+ return perpsAccountMode(row.abstraction ?? row.userAbstraction ?? row.mode ?? row.response);
7
+ }
8
+ const mode = typeof value === "string"
9
+ ? value.replace(/[^a-z0-9]/gi, "").toLowerCase()
10
+ : "";
11
+ if (["disabled", "default"].includes(mode))
12
+ return "standard";
13
+ if (["unifiedaccount", "unified", "u"].includes(mode))
14
+ return "unified";
15
+ return "unsupported";
16
+ }
17
+ // Unified accounts report the single USDC balance and all holds in spot state.
18
+ // Per-DEX equity must never be added to this balance.
19
+ export function unifiedUsdc(state) {
20
+ try {
21
+ if (!Array.isArray(state?.balances))
22
+ throw Error();
23
+ const rows = state.balances.filter((row) => row.coin === "USDC");
24
+ if (rows.length !== 1 || rows[0].token !== 0)
25
+ throw Error();
26
+ const { total, hold } = rows[0];
27
+ if (typeof total !== "string" || typeof hold !== "string")
28
+ throw Error();
29
+ const equity = D(total), reserve = D(hold);
30
+ if (!equity.isFinite() ||
31
+ !reserve.isFinite() ||
32
+ equity.lt(0) ||
33
+ reserve.lt(0) ||
34
+ reserve.gt(equity))
35
+ throw Error();
36
+ return { equity: cash(equity), available: cash(equity.minus(reserve)) };
37
+ }
38
+ catch {
39
+ throw new AgentError("ACCOUNT_STALE", "Unified account USDC balance or holds could not be verified.", true);
40
+ }
41
+ }