@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,507 @@
1
+ import { Decimal } from "decimal.js";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { AgentError } from "./contracts.js";
4
+ Decimal.set({ precision: 48, rounding: Decimal.ROUND_DOWN });
5
+ export const D = Object.assign((v) => new Decimal(v), {
6
+ min: Decimal.min.bind(Decimal),
7
+ max: Decimal.max.bind(Decimal),
8
+ });
9
+ export const cash = (v) => D(v).toFixed(6);
10
+ export const uid = (kind) => `${kind}_${randomUUID().replaceAll("-", "")}`;
11
+ function canonical(value) {
12
+ if (Array.isArray(value))
13
+ return value.map(canonical);
14
+ if (value && typeof value === "object")
15
+ return Object.fromEntries(Object.entries(value)
16
+ .filter(([, v]) => v !== undefined)
17
+ .sort(([a], [b]) => a.localeCompare(b))
18
+ .map(([k, v]) => [k, canonical(v)]));
19
+ return value;
20
+ }
21
+ export const fingerprint = (value) => createHash("sha256")
22
+ .update(JSON.stringify(canonical(value)))
23
+ .digest("hex");
24
+ export const now = () => Date.now();
25
+ export const fresh = (at, ttl = 30_000) => Number.isFinite(at) && at <= now() + 1000 && now() - at <= ttl;
26
+ // Match the existing App's editable Perps Auto-Ride setting.
27
+ export const PERPS_COPY_MAX_LEVERAGE = 40;
28
+ export function allocate(budget, count) {
29
+ const micros = D(budget).times(1e6);
30
+ if (!micros.isInteger() || count < 1)
31
+ throw new AgentError("INVALID_AMOUNT", "USDC requires at most six decimal places.");
32
+ const base = micros.div(count).floor(), remainder = micros.minus(base.times(count)).toNumber();
33
+ return Array.from({ length: count }, (_, i) => base
34
+ .plus(i < remainder ? 1 : 0)
35
+ .div(1e6)
36
+ .toFixed(6));
37
+ }
38
+ export function defaultLeverageMode(p, c) {
39
+ return c.market === "perps" && p.portfolio_kind != null
40
+ ? "follow_source"
41
+ : "cap";
42
+ }
43
+ // A suggestion is not the maximum a user may explicitly approve.
44
+ export function maximumUserLeverage(c) {
45
+ if (c.market === "prediction")
46
+ return 1;
47
+ return PERPS_COPY_MAX_LEVERAGE;
48
+ }
49
+ export function recommendedLeverage(p, c, mode = defaultLeverageMode(p, c)) {
50
+ const drawdown = c.source_drawdown_pct;
51
+ if (drawdown == null || !Number.isFinite(drawdown) || drawdown < 0)
52
+ return null;
53
+ const riskCap = drawdown === 0 ? 8 : Math.floor(p.loss_trigger_pct / drawdown);
54
+ if (c.market === "prediction")
55
+ return riskCap >= 1 ? 1 : null;
56
+ const toleranceCap = p.risk_tolerance === "low" ? 3 : p.risk_tolerance === "medium" ? 5 : 8;
57
+ if (mode === "follow_source") {
58
+ if (!Number.isFinite(c.venue_max_leverage) || c.venue_max_leverage < 1)
59
+ return null;
60
+ // Validate planning evidence only. New allocations have no extra user cap;
61
+ // runtime verifies each source position and fresh per-asset venue limit.
62
+ const cap = Math.floor(c.venue_max_leverage);
63
+ return cap >= 1 ? cap : null;
64
+ }
65
+ // Preserve existing capped plans and explicitly selected copy modes.
66
+ const atrCap = c.atr_pct == null
67
+ ? 3
68
+ : Math.max(3, Math.min(8, Math.floor(12 / Math.max(c.atr_pct, 0.01))));
69
+ const cap = Math.min(c.venue_max_leverage, atrCap, riskCap, toleranceCap);
70
+ return cap >= 3 ? cap : null;
71
+ }
72
+ // Asset category and symbol restrictions are independent of the market selector.
73
+ // Empty symbols + allow_altcoins means unrestricted assets, including BTC/ETH.
74
+ export function assetAllowed(p, asset, category, version = 2) {
75
+ if (version !== 2) {
76
+ if (p.asset_category === "any")
77
+ return true;
78
+ if (p.asset_category === "rwa")
79
+ return category === "rwa";
80
+ return p.allow_altcoins || p.assets.includes(asset);
81
+ }
82
+ if (p.asset_category &&
83
+ p.asset_category !== "any" &&
84
+ category !== p.asset_category)
85
+ return false;
86
+ if (!p.assets.length)
87
+ return p.allow_altcoins;
88
+ return (p.assets.some((a) => a.toLowerCase() === asset.toLowerCase()) ||
89
+ (p.allow_altcoins && !["BTC", "ETH"].includes(asset.toUpperCase())));
90
+ }
91
+ export function matchesAssets(p, c, version = 2) {
92
+ if (version !== 2)
93
+ return (c.assets.length > 0 &&
94
+ (!p.asset_category ||
95
+ p.asset_category === "any" ||
96
+ c.asset_category === p.asset_category) &&
97
+ (c.market === "prediction" ||
98
+ c.assets.every((a) => (p.asset_category === "rwa" && c.asset_category === "rwa") ||
99
+ p.asset_category === "any" ||
100
+ p.assets.includes(a) ||
101
+ (p.allow_altcoins && !["BTC", "ETH"].includes(a)))));
102
+ return (c.assets.length > 0 &&
103
+ (c.market === "prediction" ||
104
+ c.assets.every((a) => assetAllowed(p, a, c.asset_categories?.[a] ?? c.asset_category))));
105
+ }
106
+ export function eligibleCandidates(p, candidates, version = 2) {
107
+ return candidates
108
+ .filter((c) => c.eligibility === "PASS" &&
109
+ c.execution_supported !== false &&
110
+ c.expires_at > now() &&
111
+ fresh(c.scored_at, 36 * 3600_000) &&
112
+ (p.market === "both" || c.market === p.market) &&
113
+ matchesAssets(p, c, version) &&
114
+ recommendedLeverage(p, c) !== null &&
115
+ !(p.copy_mode === "fixed" && c.market === "prediction"))
116
+ .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
117
+ }
118
+ export const minimumAllocation = (p, c) =>
119
+ // Perps collateral is not order notional. New Agent plans accept any
120
+ // positive micro-USDC allocation; each sized order is checked at execution.
121
+ // Keep Prediction and legacy plan allocation rules unchanged.
122
+ p.portfolio_kind && c.market === "perps"
123
+ ? D("0.000001")
124
+ : p.portfolio_kind
125
+ ? D.max(50, c.min_notional_usdc)
126
+ : D(c.min_notional_usdc);
127
+ export function requestedTraderCount(p) {
128
+ return p.portfolio_kind === "single"
129
+ ? 1
130
+ : (p.basket_size ?? (p.portfolio_kind === "basket" ? 4 : 6));
131
+ }
132
+ function selectForCount(p, candidates, count) {
133
+ const amount = D(p.budget_usdc)
134
+ .div(count)
135
+ .toDecimalPlaces(6, Decimal.ROUND_DOWN);
136
+ const allowed = eligibleCandidates(p, candidates).filter((c) => amount.gte(minimumAllocation(p, c)));
137
+ const chosen = [];
138
+ while (chosen.length < count && allowed.length) {
139
+ allowed.sort((a, b) => diversity(b, chosen) - diversity(a, chosen) || a.id.localeCompare(b.id));
140
+ chosen.push(allowed.shift());
141
+ }
142
+ return chosen;
143
+ }
144
+ export function recommend(p, candidates) {
145
+ const maximum = requestedTraderCount(p);
146
+ const minimum = p.portfolio_kind === "single" ? 1 : p.portfolio_kind === "basket" ? 2 : 3;
147
+ if (maximum < minimum || maximum > (p.portfolio_kind ? 8 : 6))
148
+ throw new AgentError("INVALID_BASKET", `Select ${minimum}–${p.portfolio_kind ? 8 : 6} traders for this plan.`, false, 400);
149
+ let chosen = [];
150
+ // An explicit count is never silently reduced. Return feasible alternatives instead.
151
+ for (let count = maximum; count >= (p.portfolio_kind ? maximum : minimum); count--) {
152
+ const selection = selectForCount(p, candidates, count);
153
+ if (selection.length === count) {
154
+ chosen = selection;
155
+ break;
156
+ }
157
+ }
158
+ if (!chosen.length)
159
+ throw new AgentError("NO_EXECUTABLE_PLAN", `The requested ${maximum}-trader plan is unavailable. Review the returned rules and feasible counts.`);
160
+ // 'both' is an allowed search universe, not a mandatory cross-market allocation.
161
+ const amounts = allocate(p.budget_usdc, chosen.length);
162
+ return {
163
+ asset_filter_version: 2,
164
+ id: uid("plan"),
165
+ created_at: now(),
166
+ expires_at: now() + 10 * 60_000,
167
+ preferences: p,
168
+ source_version: [...new Set(chosen.map((c) => c.score_version))].join("+"),
169
+ allocations: chosen.map((c, i) => ({
170
+ copy_id: uid("copy"),
171
+ trader: c,
172
+ amount_usdc: amounts[i],
173
+ leverage_cap: defaultLeverageMode(p, c) === "follow_source"
174
+ ? null
175
+ : recommendedLeverage(p, c),
176
+ ...(defaultLeverageMode(p, c) === "follow_source"
177
+ ? { leverage_mode: "follow_source" }
178
+ : {}),
179
+ stop_loss_pct: 30,
180
+ ...(p.copy_mode ? { copy_mode: p.copy_mode } : {}),
181
+ ...(p.fixed_margin_usdc
182
+ ? { fixed_margin_usdc: cash(p.fixed_margin_usdc) }
183
+ : {}),
184
+ ...(p.proportional_multiplier
185
+ ? { proportional_multiplier: p.proportional_multiplier }
186
+ : {}),
187
+ ...(p.loss_policy
188
+ ? {
189
+ loss_policy: p.loss_policy,
190
+ emergency_exit_pct: p.emergency_exit_pct ?? 70,
191
+ }
192
+ : {}),
193
+ })),
194
+ };
195
+ }
196
+ export function recommendationDiagnostics(p, candidates) {
197
+ const requested = requestedTraderCount(p);
198
+ const minimum = p.portfolio_kind === "single" ? 1 : p.portfolio_kind === "basket" ? 2 : 3;
199
+ const marketRows = candidates.filter((c) => p.market === "both" || c.market === p.market);
200
+ const freshRows = marketRows.filter((c) => c.expires_at > now() && fresh(c.scored_at, 36 * 3600_000));
201
+ const qualified = freshRows.filter((c) => c.eligibility === "PASS");
202
+ const assets = qualified.filter((c) => matchesAssets(p, c));
203
+ const eligible = eligibleCandidates(p, candidates);
204
+ const amount = D(p.budget_usdc)
205
+ .div(requested)
206
+ .toDecimalPlaces(6, Decimal.ROUND_DOWN);
207
+ const affordable = eligible.filter((c) => amount.gte(minimumAllocation(p, c)));
208
+ const feasible = Array.from({ length: 8 }, (_, i) => i + 1).filter((count) => {
209
+ const alternative = {
210
+ ...p,
211
+ portfolio_kind: count === 1 ? "single" : "basket",
212
+ basket_size: count,
213
+ };
214
+ return selectForCount(alternative, candidates, count).length === count;
215
+ });
216
+ const blocker = !candidates.length
217
+ ? "source_data_unavailable"
218
+ : !marketRows.length
219
+ ? "market_source_data_unavailable"
220
+ : !freshRows.length
221
+ ? "stale_source_scores"
222
+ : !qualified.length
223
+ ? "source_eligibility"
224
+ : !assets.length
225
+ ? "asset_preferences"
226
+ : !eligible.length
227
+ ? "risk_execution_or_copy_mode"
228
+ : affordable.length < Math.min(minimum, eligible.length)
229
+ ? "budget_minimums"
230
+ : eligible.length < requested
231
+ ? "eligible_trader_supply"
232
+ : affordable.length < requested
233
+ ? "budget_minimums"
234
+ : null;
235
+ const evaluations = candidates.map((c) => {
236
+ const rules = [];
237
+ const add = (rule, field, current, required, message) => rules.push({ rule, field, current, required, message });
238
+ if (p.market !== "both" && c.market !== p.market)
239
+ add("market", "market", c.market, p.market, "Trader belongs to another market.");
240
+ if (c.expires_at <= now() || !fresh(c.scored_at, 36 * 3600_000))
241
+ add("stale_score", "scored_at", c.scored_at, "fresh score within 36 hours", "Score expired or its data is unavailable.");
242
+ if (c.eligibility !== "PASS")
243
+ add("source_eligibility", "eligibility", c.eligibility, "PASS", "Source qualification did not pass.");
244
+ if (!matchesAssets(p, c))
245
+ add("asset_preferences", "asset_category/assets", { category: c.asset_category, assets: c.assets }, {
246
+ category: p.asset_category,
247
+ assets: p.assets,
248
+ allow_altcoins: p.allow_altcoins,
249
+ }, "Trader assets do not match the saved asset preference.");
250
+ if (c.execution_supported === false)
251
+ add("execution_unsupported", "execution_supported", false, true, "Venue/account execution is unsupported.");
252
+ if (recommendedLeverage(p, c) === null)
253
+ add(c.source_drawdown_pct == null ? "missing_risk_data" : "risk_limit", "loss_trigger_pct", {
254
+ loss_trigger_pct: p.loss_trigger_pct,
255
+ source_drawdown_pct: c.source_drawdown_pct,
256
+ }, c.market === "prediction"
257
+ ? "at least 1x within the requested loss trigger"
258
+ : "at least 3x within the requested loss trigger", "Missing risk evidence or loss/risk/venue caps prevent executable leverage. Review trader risk data; raising risk is not automatic.");
259
+ if (p.copy_mode === "fixed" && c.market === "prediction")
260
+ add("copy_mode", "copy_mode", "fixed", "Perps trader or another copy mode", "Prediction does not support Fixed margin mode.");
261
+ if (amount.lt(minimumAllocation(p, c)))
262
+ add("allocation_minimum", "budget_usdc/basket_size", {
263
+ budget_usdc: p.budget_usdc,
264
+ count: requested,
265
+ per_trader_usdc: cash(amount),
266
+ }, {
267
+ per_trader_usdc: cash(minimumAllocation(p, c)),
268
+ venue_minimum_usdc: c.min_notional_usdc,
269
+ policy_minimum_usdc: p.portfolio_kind && c.market === "prediction" ? "50.000000" : null,
270
+ }, "Allocation is below the applicable positive-USDC, legacy venue or Prediction product minimum. Perps order minimums are checked separately after sizing.");
271
+ return {
272
+ trader_id: c.id,
273
+ eligible: eligible.some((e) => e.id === c.id),
274
+ budget_eligible: affordable.some((e) => e.id === c.id),
275
+ rules,
276
+ };
277
+ });
278
+ return {
279
+ diagnostic_id: uid("diag"),
280
+ stage: !blocker
281
+ ? "plan_ready"
282
+ : ({
283
+ source_data_unavailable: "source_candidates",
284
+ market_source_data_unavailable: "market_filter",
285
+ stale_source_scores: "score_freshness",
286
+ source_eligibility: "source_qualification",
287
+ asset_preferences: "asset_filter",
288
+ risk_execution_or_copy_mode: "risk_execution_and_copy_mode",
289
+ budget_minimums: "allocation_minimums",
290
+ eligible_trader_supply: "requested_trader_count",
291
+ }[blocker] ?? "plan_allocation"),
292
+ count_semantics: "Sequential filters; each count is a subset of the preceding count. Budget qualification uses the requested count.",
293
+ source_candidates: candidates.length,
294
+ market_candidates: marketRows.length,
295
+ fresh_candidates: freshRows.length,
296
+ qualified_candidates: qualified.length,
297
+ asset_matching_candidates: assets.length,
298
+ eligible_candidates: eligible.length,
299
+ budget_eligible_candidates: affordable.length,
300
+ minimum_basket_size: minimum,
301
+ requested_basket_size: requested,
302
+ feasible_basket_sizes: feasible,
303
+ blocker,
304
+ funding_required: false,
305
+ execution_available: false,
306
+ market_counts: {
307
+ perps: eligible.filter((c) => c.market === "perps").length,
308
+ prediction: eligible.filter((c) => c.market === "prediction").length,
309
+ },
310
+ rules: [
311
+ ...(blocker && eligible.length && affordable.length < requested
312
+ ? [
313
+ {
314
+ trader_id: "plan",
315
+ rule: blocker,
316
+ field: "basket_size",
317
+ current: requested,
318
+ required: feasible,
319
+ message: `Requested ${requested} traders; ${eligible.length} qualify and ${affordable.length} meet the per-trader minimum at this count. ${feasible.length ? `Review a ${feasible.join(" or ")}-trader plan with the same risk settings.` : "No supported count is executable; inspect the trader minimums and eligibility rules."}`,
320
+ },
321
+ ]
322
+ : []),
323
+ ...evaluations.flatMap((e) => e.rules.map((r) => ({ ...r, trader_id: e.trader_id }))),
324
+ ],
325
+ candidate_evaluations: evaluations,
326
+ adjustments: feasible
327
+ .filter((n) => n !== requested)
328
+ .map((count) => ({
329
+ label: `Review ${count}-trader plan`,
330
+ field: "basket_size",
331
+ current: requested,
332
+ required: count,
333
+ preferences: {
334
+ ...p,
335
+ portfolio_kind: count === 1 ? "single" : "basket",
336
+ basket_size: count,
337
+ },
338
+ })),
339
+ };
340
+ }
341
+ function diversity(c, chosen) {
342
+ return (c.score -
343
+ chosen.reduce((n, x) => n +
344
+ (x.style === c.style ? 8 : 0) +
345
+ (x.direction === c.direction ? 5 : 0) +
346
+ 6 * assetOverlap(x.assets, c.assets) +
347
+ (x.market === c.market ? 2 : 0), 0));
348
+ }
349
+ function assetOverlap(a, b) {
350
+ const left = new Set(a.map((asset) => asset.toUpperCase())), right = new Set(b.map((asset) => asset.toUpperCase()));
351
+ const union = new Set([...left, ...right]);
352
+ return union.size
353
+ ? [...left].filter((asset) => right.has(asset)).length / union.size
354
+ : 0;
355
+ }
356
+ export function aggregate(p, prices, book = "intent") {
357
+ const out = new Map();
358
+ for (const s of p.sleeves) {
359
+ const rows = book === "intent" ? Object.values(s.intents) : Object.values(s.lots);
360
+ for (const x of rows) {
361
+ if (D(x.quantity).isZero())
362
+ continue;
363
+ const price = prices[x.key];
364
+ if (price == null || !D(price).gt(0))
365
+ throw new AgentError("STALE_PRICE", "A fresh price is required for every owned position.", true);
366
+ const a = out.get(x.key) ?? {
367
+ position_id: `pos_${fingerprint([p.id, x.key]).slice(0, 32)}`,
368
+ key: x.key,
369
+ asset: x.asset,
370
+ market: x.market,
371
+ quantity: "0",
372
+ gross_notional_usdc: "0",
373
+ net_notional_usdc: "0",
374
+ margin_usdc: "0",
375
+ concentration_pct: null,
376
+ sleeve_ids: [],
377
+ };
378
+ const notional = D(x.quantity).abs().times(price);
379
+ const leverage = book === "intent"
380
+ ? x.source_leverage
381
+ : (s.intents[x.key]?.source_leverage ?? 1);
382
+ a.quantity = D(a.quantity).plus(x.quantity).toString();
383
+ a.gross_notional_usdc = D(a.gross_notional_usdc)
384
+ .plus(notional)
385
+ .toString();
386
+ a.margin_usdc = D(a.margin_usdc)
387
+ .plus(notional.div(Math.max(1, Math.min(s.leverage_cap ?? leverage, leverage))))
388
+ .toString();
389
+ a.sleeve_ids.push(s.copy_id);
390
+ out.set(x.key, a);
391
+ }
392
+ }
393
+ const total = [...out.values()].reduce((n, a) => n.plus(a.gross_notional_usdc), D(0));
394
+ const byAsset = new Map();
395
+ for (const a of out.values())
396
+ byAsset.set(a.asset, (byAsset.get(a.asset) ?? D(0)).plus(a.gross_notional_usdc));
397
+ return [...out.values()].map((a) => ({
398
+ ...a,
399
+ gross_notional_usdc: cash(a.gross_notional_usdc),
400
+ net_notional_usdc: cash(D(a.quantity).times(prices[a.key])),
401
+ margin_usdc: cash(a.margin_usdc),
402
+ concentration_pct: total.isZero()
403
+ ? null
404
+ : byAsset.get(a.asset).div(total).times(100).toFixed(6),
405
+ }));
406
+ }
407
+ export function risk(p, prices, allowReduction = false) {
408
+ const next = aggregate(p, prices), prior = aggregate(p, prices, "actual");
409
+ const gross = next.reduce((n, a) => n.plus(a.gross_notional_usdc), D(0));
410
+ const margin = next.reduce((n, a) => n.plus(a.margin_usdc), D(0));
411
+ const budget = D(p.preferences.budget_usdc);
412
+ // A sleeve reserves its gross margin even when another sleeve offsets it.
413
+ const uncapped = p.sleeves.some((s) => s.leverage_mode === "follow_source" && s.leverage_cap == null);
414
+ const approvedLeverage = p.sleeves.some((s) => s.leverage_mode != null)
415
+ ? Math.max(1, ...p.sleeves.map((s) => s.leverage_cap ?? 1))
416
+ : 8;
417
+ if ((!uncapped && gross.gt(budget.times(approvedLeverage))) ||
418
+ margin.gt(budget))
419
+ throw new AgentError("BUDGET_EXCEEDED", "Target gross exposure or gross margin exceeds the approved budget.");
420
+ if (p.snapshot?.available_by_scope) {
421
+ const scopeMargin = new Map(), oldMargin = new Map();
422
+ for (const a of next.filter((a) => a.market === "perps")) {
423
+ const scope = a.asset.includes(":") ? a.asset.split(":")[0] : "core";
424
+ scopeMargin.set(scope, (scopeMargin.get(scope) ?? D(0)).plus(a.margin_usdc));
425
+ }
426
+ for (const a of prior.filter((a) => a.market === "perps")) {
427
+ const scope = a.asset.includes(":") ? a.asset.split(":")[0] : "core";
428
+ oldMargin.set(scope, (oldMargin.get(scope) ?? D(0)).plus(a.margin_usdc));
429
+ }
430
+ for (const [scope, target] of scopeMargin)
431
+ if (target.gt((oldMargin.get(scope) ?? D(0)).plus(p.snapshot.available_by_scope[scope] ?? 0)))
432
+ throw new AgentError("SCOPED_COLLATERAL_REQUIRED", "Fresh available collateral in the selected DEX is insufficient; another DEX's balance cannot fund this target.");
433
+ }
434
+ for (const a of next) {
435
+ const old = prior.find((x) => x.key === a.key);
436
+ const reduces = allowReduction &&
437
+ D(a.net_notional_usdc)
438
+ .abs()
439
+ .lte(D(old?.net_notional_usdc ?? 0).abs()) &&
440
+ D(a.gross_notional_usdc).lte(old?.gross_notional_usdc ?? 0);
441
+ if (D(a.concentration_pct ?? 0).gt(p.preferences.portfolio_kind === "single" ? 100 : 60) &&
442
+ !reduces)
443
+ throw new AgentError("ASSET_CONCENTRATION", "An asset exceeds 60% of total gross sleeve exposure.");
444
+ if (a.market === "prediction" && D(a.quantity).lt(0))
445
+ throw new AgentError("INVALID_OUTCOME_POSITION", "Prediction outcome tokens cannot have negative inventory.");
446
+ }
447
+ return next;
448
+ }
449
+ export function pnl(p) {
450
+ const quantitiesMatch = p.snapshot &&
451
+ [
452
+ ...new Set([
453
+ ...Object.keys(p.snapshot.positions),
454
+ ...p.sleeves.flatMap((s) => Object.keys(s.lots)),
455
+ ]),
456
+ ].every((key) => p.sleeves
457
+ .reduce((n, s) => n.plus(s.lots[key]?.quantity ?? 0), D(0))
458
+ .eq(p.snapshot.positions[key] ?? 0));
459
+ if (!p.snapshot ||
460
+ p.reconciliation !== "complete" ||
461
+ !p.snapshot.coverage_complete ||
462
+ !fresh(p.snapshot.observed_at) ||
463
+ !quantitiesMatch ||
464
+ p.sleeves.some((s) => Object.values(s.lots).some((l) => !D(l.quantity).isZero() && !p.snapshot.prices[l.key])))
465
+ return {
466
+ net_usdc: null,
467
+ sleeves: p.sleeves.map((s) => ({ copy_id: s.copy_id, net_usdc: null })),
468
+ external_flows_usdc: cash(p.flow_total),
469
+ };
470
+ const rawSleeves = p.sleeves.map((s) => ({
471
+ copy_id: s.copy_id,
472
+ net_usdc: Object.values(s.lots).reduce((n, l) => n
473
+ .plus(l.realized)
474
+ .minus(l.fees)
475
+ .plus(l.funding)
476
+ .plus(D(l.quantity)
477
+ .times(p.snapshot.prices[l.key] ?? 0)
478
+ .minus(l.cost)), D(0)),
479
+ }));
480
+ const total = rawSleeves.reduce((n, s) => n.plus(s.net_usdc), D(0));
481
+ const accountDelta = D(p.snapshot.account_value_usdc)
482
+ .minus(p.initial_equity ?? 0)
483
+ .minus(p.flow_total);
484
+ if (total.minus(accountDelta).abs().gt("0.000001"))
485
+ return {
486
+ net_usdc: null,
487
+ sleeves: rawSleeves.map((s) => ({ copy_id: s.copy_id, net_usdc: null })),
488
+ external_flows_usdc: cash(p.flow_total),
489
+ };
490
+ // Validate unrounded attribution, then conserve the verified account delta
491
+ // when presenting each sleeve at micro-USDC precision.
492
+ const shares = rawSleeves.map((s) => s.net_usdc.toDecimalPlaces(6));
493
+ const remainder = D(cash(accountDelta)).minus(shares.reduce((n, share) => n.plus(share), D(0)));
494
+ if (!remainder.isZero() && shares.length) {
495
+ const owner = rawSleeves.reduce((best, s, i) => s.net_usdc.abs().gt(rawSleeves[best].net_usdc.abs()) ? i : best, 0);
496
+ shares[owner] = shares[owner].plus(remainder);
497
+ }
498
+ const sleeves = rawSleeves.map((s, i) => ({
499
+ copy_id: s.copy_id,
500
+ net_usdc: cash(shares[i].isZero() ? 0 : shares[i]),
501
+ }));
502
+ return {
503
+ net_usdc: cash(accountDelta),
504
+ sleeves,
505
+ external_flows_usdc: cash(p.flow_total),
506
+ };
507
+ }
@@ -0,0 +1,34 @@
1
+ import { createPrivateKey } from "node:crypto";
2
+ import { AgentError } from "./contracts.js";
3
+ // Existing Ride uses FCM_SERVICE_ACCOUNT_JSON_BASE64. Keep the V3 raw-JSON
4
+ // setting compatible, and never include credential text in an error/result.
5
+ export function firebaseAccount(env = process.env) {
6
+ const raw = env.FIREBASE_SERVICE_ACCOUNT_JSON?.trim() ||
7
+ env.FCM_SERVICE_ACCOUNT_JSON?.trim();
8
+ const encoded = env.FCM_SERVICE_ACCOUNT_JSON_BASE64?.trim();
9
+ if (!raw && !encoded)
10
+ return null;
11
+ try {
12
+ if (!raw && !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded))
13
+ throw 0;
14
+ const account = JSON.parse(raw || Buffer.from(encoded, "base64").toString("utf8"));
15
+ if (account.type !== "service_account" ||
16
+ typeof account.project_id !== "string" ||
17
+ !/^[a-z][a-z0-9-]{4,60}$/.test(account.project_id) ||
18
+ typeof account.client_email !== "string" ||
19
+ !account.client_email.endsWith(".gserviceaccount.com") ||
20
+ typeof account.private_key !== "string" ||
21
+ createPrivateKey(account.private_key).asymmetricKeyType !== "rsa" ||
22
+ (env.RIDE_AGENT_FCM_PROJECT_ID &&
23
+ account.project_id !== env.RIDE_AGENT_FCM_PROJECT_ID))
24
+ throw 0;
25
+ return {
26
+ project_id: account.project_id,
27
+ client_email: account.client_email,
28
+ private_key: account.private_key,
29
+ };
30
+ }
31
+ catch {
32
+ throw new AgentError("PUSH_CONFIGURATION_INVALID", "Ride push credentials are invalid or belong to a different Firebase project.", false, 503);
33
+ }
34
+ }