@floatonnear/float-adapter 0.0.0-stage → 0.1.1
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.
- package/dist/blocked-scan.d.ts +153 -0
- package/dist/blocked-scan.js +274 -0
- package/dist/env-file.d.ts +45 -0
- package/dist/env-file.js +82 -0
- package/dist/gate.d.ts +81 -0
- package/dist/gate.js +139 -0
- package/dist/handlers.d.ts +210 -0
- package/dist/handlers.js +246 -0
- package/dist/keyring.d.ts +68 -0
- package/dist/keyring.js +121 -0
- package/dist/map.d.ts +159 -0
- package/dist/map.js +219 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.js +434 -0
- package/package.json +26 -4
- package/README.md +0 -3
package/dist/keyring.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which agent signs a payment.
|
|
3
|
+
*
|
|
4
|
+
* The adapter used to hold exactly one agent credential, in
|
|
5
|
+
* NEAR_VAULT_AGENT_ID / NEAR_VAULT_AGENT_KEY, and `POST /v1/pay` always signed
|
|
6
|
+
* as that agent. The dashboard meanwhile renders the whole tree. So a vault
|
|
7
|
+
* with sixteen agents showed sixteen rows and could act as one of them — and
|
|
8
|
+
* the sixteen-agent demo had to bypass the product entirely and drive the CLI
|
|
9
|
+
* instead.
|
|
10
|
+
*
|
|
11
|
+
* This matters more than it looks, because the contract resolves the spending
|
|
12
|
+
* node from the SIGNER (`account_to_node`), not from anything in the request
|
|
13
|
+
* body. Which key signs *is* which budget is charged. A single-key adapter
|
|
14
|
+
* cannot exercise a hierarchy at all; it can only ever spend one leaf.
|
|
15
|
+
*
|
|
16
|
+
* On holding several keys at once: each is the restricted key from
|
|
17
|
+
* `create_agent_account`, which can call only `request_payment` on its own
|
|
18
|
+
* vault, cannot call other contracts, and cannot attach a deposit. The vault
|
|
19
|
+
* enforces every cap regardless of who holds the key, so the blast radius of
|
|
20
|
+
* the whole keyring is bounded by the tree's own limits. That is the same
|
|
21
|
+
* bound as for one key, applied more times.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
24
|
+
export function emptyKeyring() {
|
|
25
|
+
return { byAccount: new Map(), byNode: new Map() };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build a keyring from the singular env vars plus, optionally, the JSON file
|
|
29
|
+
* `create-float` writes at `.float/agent-keys.json`:
|
|
30
|
+
*
|
|
31
|
+
* [{ "nodeId": 3, "agentId": "r1.float.me.testnet", "secret": "ed25519:..." }]
|
|
32
|
+
*
|
|
33
|
+
* The singular env pair stays authoritative for the fallback, so adding a file
|
|
34
|
+
* can never silently change which agent an un-targeted payment comes from.
|
|
35
|
+
*/
|
|
36
|
+
export function loadKeyring(env) {
|
|
37
|
+
const ring = emptyKeyring();
|
|
38
|
+
const add = (c) => {
|
|
39
|
+
ring.byAccount.set(c.agentId, c);
|
|
40
|
+
if (typeof c.nodeId === "number")
|
|
41
|
+
ring.byNode.set(c.nodeId, c);
|
|
42
|
+
};
|
|
43
|
+
if (env.agentKeysPath && existsSync(env.agentKeysPath)) {
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(readFileSync(env.agentKeysPath, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A malformed keyring must not take the server down, and must not be
|
|
50
|
+
// mistaken for an empty one. Paying with an unknown agent will fail
|
|
51
|
+
// loudly at selection time with a message that lists what IS loaded.
|
|
52
|
+
parsed = null;
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(parsed)) {
|
|
55
|
+
for (const e of parsed) {
|
|
56
|
+
if (e && typeof e.agentId === "string" && typeof e.secret === "string") {
|
|
57
|
+
add({
|
|
58
|
+
agentId: e.agentId,
|
|
59
|
+
secret: e.secret,
|
|
60
|
+
nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (env.agentId && env.agentKey) {
|
|
67
|
+
const c = { agentId: env.agentId, secret: env.agentKey };
|
|
68
|
+
add(c);
|
|
69
|
+
ring.fallback = c;
|
|
70
|
+
}
|
|
71
|
+
else if (ring.byAccount.size === 1) {
|
|
72
|
+
// Exactly one key in the file and no env pair: unambiguous, so use it.
|
|
73
|
+
// More than one with no env pair is deliberately NOT guessed at — see
|
|
74
|
+
// selectAgent.
|
|
75
|
+
ring.fallback = [...ring.byAccount.values()][0];
|
|
76
|
+
}
|
|
77
|
+
return ring;
|
|
78
|
+
}
|
|
79
|
+
export class AgentSelectionError extends Error {
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Pick the credential for a payment request.
|
|
83
|
+
*
|
|
84
|
+
* Errors name every agent that IS available. A selection failure is almost
|
|
85
|
+
* always a typo or a stale node id, and an error that says only "not found"
|
|
86
|
+
* costs the caller another round trip to discover what the valid options were.
|
|
87
|
+
*/
|
|
88
|
+
export function selectAgent(ring, want = {}) {
|
|
89
|
+
const known = () => {
|
|
90
|
+
const list = [...ring.byAccount.values()].map((c) => `${c.agentId}${typeof c.nodeId === "number" ? ` (node ${c.nodeId})` : ""}`);
|
|
91
|
+
return list.length ? list.join(", ") : "none";
|
|
92
|
+
};
|
|
93
|
+
if (want.agent !== undefined) {
|
|
94
|
+
const c = ring.byAccount.get(want.agent);
|
|
95
|
+
if (!c) {
|
|
96
|
+
throw new AgentSelectionError(`no key for agent '${want.agent}'. Loaded agents: ${known()}.`);
|
|
97
|
+
}
|
|
98
|
+
return c;
|
|
99
|
+
}
|
|
100
|
+
if (want.nodeId !== undefined) {
|
|
101
|
+
const c = ring.byNode.get(want.nodeId);
|
|
102
|
+
if (!c) {
|
|
103
|
+
throw new AgentSelectionError(`no key bound to node ${want.nodeId}. Loaded agents: ${known()}. ` +
|
|
104
|
+
`Node ids come from the keyring file; a key added via ` +
|
|
105
|
+
`NEAR_VAULT_AGENT_ID alone has no node id attached.`);
|
|
106
|
+
}
|
|
107
|
+
return c;
|
|
108
|
+
}
|
|
109
|
+
if (ring.fallback)
|
|
110
|
+
return ring.fallback;
|
|
111
|
+
if (ring.byAccount.size > 1) {
|
|
112
|
+
// Refuse rather than guess. Picking arbitrarily would charge a budget the
|
|
113
|
+
// caller did not choose, and the payment would succeed — so the mistake
|
|
114
|
+
// would surface later as a confusing balance, not as an error.
|
|
115
|
+
throw new AgentSelectionError(`several agents are loaded and the request named none, so there is no ` +
|
|
116
|
+
`safe default. Pass "agent" or "nodeId". Loaded agents: ${known()}.`);
|
|
117
|
+
}
|
|
118
|
+
throw new AgentSelectionError("no agent credentials are configured. Set NEAR_VAULT_AGENT_ID and " +
|
|
119
|
+
"NEAR_VAULT_AGENT_KEY, or point NEAR_VAULT_AGENT_KEYS at the " +
|
|
120
|
+
"agent-keys.json written by create-float.");
|
|
121
|
+
}
|
package/dist/map.d.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps NEAR vault views onto the shapes the EXISTING Float dashboard already
|
|
3
|
+
* reads, so that UI runs against the NEAR hierarchical vault unchanged — same
|
|
4
|
+
* components, same logo, same styles, same screens.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here invents a field. Where Solana-era Float had a concept the NEAR
|
|
7
|
+
* vault does not (x402 rails, per-agent token balances, auto-top-up), the
|
|
8
|
+
* field is OMITTED rather than filled with a plausible default: every one of
|
|
9
|
+
* those is optional in the UI's types, and a made-up value would render as
|
|
10
|
+
* fact.
|
|
11
|
+
*/
|
|
12
|
+
export interface VaultTokenLimit {
|
|
13
|
+
token: string;
|
|
14
|
+
daily_cap: string;
|
|
15
|
+
per_tx_cap: string;
|
|
16
|
+
}
|
|
17
|
+
export interface VaultNode {
|
|
18
|
+
id: number;
|
|
19
|
+
parent: number | null;
|
|
20
|
+
path: number[];
|
|
21
|
+
label: string;
|
|
22
|
+
account: string | null;
|
|
23
|
+
limits: VaultTokenLimit[];
|
|
24
|
+
child_cap_sum: [string, string][];
|
|
25
|
+
child_count: number;
|
|
26
|
+
cooldown_sec: number;
|
|
27
|
+
frozen: boolean;
|
|
28
|
+
recipients: string[] | null;
|
|
29
|
+
}
|
|
30
|
+
export interface VaultRequest {
|
|
31
|
+
request_id: string;
|
|
32
|
+
node_id: number;
|
|
33
|
+
token: string;
|
|
34
|
+
amount: string;
|
|
35
|
+
recipient: string;
|
|
36
|
+
memo: string;
|
|
37
|
+
reserved_chain: number[];
|
|
38
|
+
reserved_day: number;
|
|
39
|
+
/**
|
|
40
|
+
* Absent on requests stored before routes existed.
|
|
41
|
+
*
|
|
42
|
+
* `base_usdc` is retired along with the MPC rail, but it is still listed
|
|
43
|
+
* here: a vault deployed before the change has those requests in its
|
|
44
|
+
* state, and `list_requests` will keep returning them for as long as they
|
|
45
|
+
* are not purged. Dropping it from the type would make `railOf` report
|
|
46
|
+
* those as NEAR — quietly relabelling a cross-chain payment as a local one.
|
|
47
|
+
*/
|
|
48
|
+
route?: "near_direct" | {
|
|
49
|
+
base_usdc: unknown;
|
|
50
|
+
} | {
|
|
51
|
+
intents: unknown;
|
|
52
|
+
} | null;
|
|
53
|
+
status: string;
|
|
54
|
+
reason_code: string;
|
|
55
|
+
created_at_ns: string;
|
|
56
|
+
updated_at_ns: string;
|
|
57
|
+
}
|
|
58
|
+
export interface VaultOutcome {
|
|
59
|
+
request_id: string;
|
|
60
|
+
node_id: number;
|
|
61
|
+
status: string;
|
|
62
|
+
reason_code: string;
|
|
63
|
+
}
|
|
64
|
+
export interface DayUsage {
|
|
65
|
+
executed: string;
|
|
66
|
+
pending: string;
|
|
67
|
+
}
|
|
68
|
+
/** The dashboard's ApiAgent, plus the two fields it reads off the policy. */
|
|
69
|
+
export interface ApiAgent {
|
|
70
|
+
agentId: string;
|
|
71
|
+
walletAddress: string;
|
|
72
|
+
createdAt: string;
|
|
73
|
+
status: "active" | "killed";
|
|
74
|
+
displayName: string;
|
|
75
|
+
lifetimeSpent?: string;
|
|
76
|
+
lifetimePayments?: number;
|
|
77
|
+
walletBalance?: string | null;
|
|
78
|
+
dailySpent?: string;
|
|
79
|
+
dailyBudgetUsedPercent?: number;
|
|
80
|
+
policy: {
|
|
81
|
+
dailyBudget?: string;
|
|
82
|
+
perTxLimit?: string;
|
|
83
|
+
cooldownSeconds?: number;
|
|
84
|
+
blockUnknown?: boolean;
|
|
85
|
+
allowedAddresses?: string[];
|
|
86
|
+
} | null;
|
|
87
|
+
}
|
|
88
|
+
export interface Payment {
|
|
89
|
+
id: string;
|
|
90
|
+
agentId: string;
|
|
91
|
+
agentName: string;
|
|
92
|
+
amount: string;
|
|
93
|
+
currency: string;
|
|
94
|
+
recipientAddress: string | null;
|
|
95
|
+
venue: string;
|
|
96
|
+
rail: string;
|
|
97
|
+
status: string;
|
|
98
|
+
txSignature: string | null;
|
|
99
|
+
createdAt: string;
|
|
100
|
+
prompt: string | null;
|
|
101
|
+
resultUrl: string | null;
|
|
102
|
+
blockReason?: string | null;
|
|
103
|
+
blockMessage?: string | null;
|
|
104
|
+
}
|
|
105
|
+
export declare function nodeToApiAgent(n: VaultNode, usage: DayUsage, decimals: number): ApiAgent;
|
|
106
|
+
/**
|
|
107
|
+
* Tree order, root-first, with the depth drawn into the display name.
|
|
108
|
+
*
|
|
109
|
+
* The Float agents table is flat. Indenting the label is the minimal way to
|
|
110
|
+
* show root -> team -> agent in it without redesigning the screen.
|
|
111
|
+
*/
|
|
112
|
+
export declare function treeToApiAgents(tree: VaultNode[], budgets: Map<number, DayUsage>, decimals: number, lifetime?: Map<number, {
|
|
113
|
+
spent: bigint;
|
|
114
|
+
count: number;
|
|
115
|
+
}>): ApiAgent[];
|
|
116
|
+
/**
|
|
117
|
+
* Depth prefix using NON-BREAKING spaces.
|
|
118
|
+
*
|
|
119
|
+
* Plain spaces look right in a test assertion and vanish on screen: HTML
|
|
120
|
+
* collapses leading whitespace, so depth-2 rendered identically to depth-1 and
|
|
121
|
+
* the hierarchy — the entire point of the view — was invisible. The unit test
|
|
122
|
+
* was green while the screen was wrong, which is why this is asserted against
|
|
123
|
+
* the rendered character and not a trimmed string.
|
|
124
|
+
*/
|
|
125
|
+
export declare function indent(depth: number, label: string): string;
|
|
126
|
+
/** Mirrors the contract's `effective_recipients`: nearest ancestor with a list. */
|
|
127
|
+
export declare function effectiveRecipients(n: VaultNode, byId: Map<number, VaultNode>): string[] | undefined;
|
|
128
|
+
/** Settled spend per node, within RETAINED history (purge_settled removes rows). */
|
|
129
|
+
export declare function lifetimeByNode(requests: VaultRequest[]): Map<number, {
|
|
130
|
+
spent: bigint;
|
|
131
|
+
count: number;
|
|
132
|
+
}>;
|
|
133
|
+
/**
|
|
134
|
+
* Which rail the dashboard shows.
|
|
135
|
+
*
|
|
136
|
+
* NOT hardcoded to "near": a cross-chain payment rendered as a NEAR one is the
|
|
137
|
+
* same class of bug as the "Open x402" badge — the UI stating something false
|
|
138
|
+
* about where the money went, while every test passes. An unrecognised or
|
|
139
|
+
* absent route reports "near", because requests stored before routes existed
|
|
140
|
+
* have no route field and were all NEAR.
|
|
141
|
+
*/
|
|
142
|
+
export declare function railOf(route: VaultRequest["route"]): string;
|
|
143
|
+
/** A Base delivery the relayer observed and recorded off-chain. */
|
|
144
|
+
export interface Delivery {
|
|
145
|
+
baseTxHash: string;
|
|
146
|
+
mode: "vault" | "operator";
|
|
147
|
+
}
|
|
148
|
+
export declare function requestToPayment(r: VaultRequest, nodeLabels: Record<number, string>, decimals: number, delivery?: Delivery): Payment;
|
|
149
|
+
/**
|
|
150
|
+
* Plain-English for every reason code the hot path can return.
|
|
151
|
+
*
|
|
152
|
+
* `PARENT_DAILY_BUDGET` and `DAILY_BUDGET` get deliberately DIFFERENT
|
|
153
|
+
* sentences. Those two codes are the entire point of the hierarchy — one says
|
|
154
|
+
* the agent is out of budget, the other says the pool it shares is — and
|
|
155
|
+
* rendering the same text for both would make the product's distinguishing
|
|
156
|
+
* behaviour invisible to the person looking at the screen.
|
|
157
|
+
*/
|
|
158
|
+
export declare const REASONS: Record<string, string>;
|
|
159
|
+
export declare function blockedToPayment(o: VaultOutcome, nodeLabels: Record<number, string>, amountHuman: string, recipient: string, createdAtIso: string): Payment;
|
package/dist/map.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps NEAR vault views onto the shapes the EXISTING Float dashboard already
|
|
3
|
+
* reads, so that UI runs against the NEAR hierarchical vault unchanged — same
|
|
4
|
+
* components, same logo, same styles, same screens.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here invents a field. Where Solana-era Float had a concept the NEAR
|
|
7
|
+
* vault does not (x402 rails, per-agent token balances, auto-top-up), the
|
|
8
|
+
* field is OMITTED rather than filled with a plausible default: every one of
|
|
9
|
+
* those is optional in the UI's types, and a made-up value would render as
|
|
10
|
+
* fact.
|
|
11
|
+
*/
|
|
12
|
+
import { fromBaseUnits } from "@floatonnear/mcp/amounts";
|
|
13
|
+
export function nodeToApiAgent(n, usage, decimals) {
|
|
14
|
+
const lim = n.limits[0];
|
|
15
|
+
const cap = lim ? BigInt(lim.daily_cap) : 0n;
|
|
16
|
+
// executed + pending, exactly as the contract computes `available`. A
|
|
17
|
+
// dashboard that ignored in-flight spend would advertise headroom the
|
|
18
|
+
// contract is about to refuse.
|
|
19
|
+
const used = BigInt(usage.executed) + BigInt(usage.pending);
|
|
20
|
+
return {
|
|
21
|
+
// An internal (team) node has no account, but it does hold a real budget,
|
|
22
|
+
// so it still gets a row. Dropping it would hide the parent pool, which is
|
|
23
|
+
// the thing this product exists to make visible.
|
|
24
|
+
agentId: n.account ?? `node-${n.id}`,
|
|
25
|
+
walletAddress: n.account ?? "",
|
|
26
|
+
createdAt: new Date(0).toISOString(),
|
|
27
|
+
status: n.frozen ? "killed" : "active",
|
|
28
|
+
displayName: n.label,
|
|
29
|
+
dailySpent: fromBaseUnits(used, decimals),
|
|
30
|
+
dailyBudgetUsedPercent: cap > 0n ? Number((used * 100n) / cap) : 0,
|
|
31
|
+
walletBalance: null, // the VAULT holds the tokens; an agent account holds none
|
|
32
|
+
policy: {
|
|
33
|
+
dailyBudget: lim ? fromBaseUnits(cap, decimals) : undefined,
|
|
34
|
+
perTxLimit: lim ? fromBaseUnits(BigInt(lim.per_tx_cap), decimals) : undefined,
|
|
35
|
+
cooldownSeconds: n.cooldown_sec,
|
|
36
|
+
// Not a toggle here: the payee allowlist is always enforced on-chain, so
|
|
37
|
+
// reporting false would misdescribe the contract.
|
|
38
|
+
blockUnknown: true,
|
|
39
|
+
allowedAddresses: n.recipients ?? undefined,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Tree order, root-first, with the depth drawn into the display name.
|
|
45
|
+
*
|
|
46
|
+
* The Float agents table is flat. Indenting the label is the minimal way to
|
|
47
|
+
* show root -> team -> agent in it without redesigning the screen.
|
|
48
|
+
*/
|
|
49
|
+
export function treeToApiAgents(tree, budgets, decimals, lifetime = new Map()) {
|
|
50
|
+
const byId = new Map(tree.map((n) => [n.id, n]));
|
|
51
|
+
return tree.map((n) => {
|
|
52
|
+
const a = nodeToApiAgent(n, budgets.get(n.id) ?? { executed: "0", pending: "0" }, decimals);
|
|
53
|
+
a.displayName = indent(n.path.length, n.label);
|
|
54
|
+
// The EFFECTIVE allowlist, resolved the way the contract resolves it.
|
|
55
|
+
// Reporting the raw `recipients` field made every inheriting node look
|
|
56
|
+
// unrestricted in the UI ("Open x402"), which is the opposite of true:
|
|
57
|
+
// they inherit the root's allowlist and the contract enforces it.
|
|
58
|
+
a.policy = { ...a.policy, allowedAddresses: effectiveRecipients(n, byId) };
|
|
59
|
+
const lt = lifetime.get(n.id);
|
|
60
|
+
if (lt) {
|
|
61
|
+
a.lifetimeSpent = fromBaseUnits(lt.spent, decimals);
|
|
62
|
+
a.lifetimePayments = lt.count;
|
|
63
|
+
}
|
|
64
|
+
return a;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Depth prefix using NON-BREAKING spaces.
|
|
69
|
+
*
|
|
70
|
+
* Plain spaces look right in a test assertion and vanish on screen: HTML
|
|
71
|
+
* collapses leading whitespace, so depth-2 rendered identically to depth-1 and
|
|
72
|
+
* the hierarchy — the entire point of the view — was invisible. The unit test
|
|
73
|
+
* was green while the screen was wrong, which is why this is asserted against
|
|
74
|
+
* the rendered character and not a trimmed string.
|
|
75
|
+
*/
|
|
76
|
+
export function indent(depth, label) {
|
|
77
|
+
if (depth === 0)
|
|
78
|
+
return label;
|
|
79
|
+
return `${"\u00a0".repeat(3 * (depth - 1))}└─ ${label}`;
|
|
80
|
+
}
|
|
81
|
+
/** Mirrors the contract's `effective_recipients`: nearest ancestor with a list. */
|
|
82
|
+
export function effectiveRecipients(n, byId) {
|
|
83
|
+
const chain = [...n.path, n.id].reverse(); // self first, then upward
|
|
84
|
+
for (const id of chain) {
|
|
85
|
+
const node = byId.get(id);
|
|
86
|
+
if (node?.recipients && node.recipients.length)
|
|
87
|
+
return node.recipients;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/** Settled spend per node, within RETAINED history (purge_settled removes rows). */
|
|
92
|
+
export function lifetimeByNode(requests) {
|
|
93
|
+
const m = new Map();
|
|
94
|
+
for (const r of requests) {
|
|
95
|
+
if (r.status !== "SETTLED")
|
|
96
|
+
continue;
|
|
97
|
+
const cur = m.get(r.node_id) ?? { spent: 0n, count: 0 };
|
|
98
|
+
m.set(r.node_id, { spent: cur.spent + BigInt(r.amount), count: cur.count + 1 });
|
|
99
|
+
}
|
|
100
|
+
return m;
|
|
101
|
+
}
|
|
102
|
+
function nsToIso(ns) {
|
|
103
|
+
// Nanoseconds. Dividing by 1e6 for milliseconds, via BigInt so a 19-digit
|
|
104
|
+
// value does not lose its low digits on the way through a float.
|
|
105
|
+
return new Date(Number(BigInt(ns) / 1000000n)).toISOString();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Which rail the dashboard shows.
|
|
109
|
+
*
|
|
110
|
+
* NOT hardcoded to "near": a cross-chain payment rendered as a NEAR one is the
|
|
111
|
+
* same class of bug as the "Open x402" badge — the UI stating something false
|
|
112
|
+
* about where the money went, while every test passes. An unrecognised or
|
|
113
|
+
* absent route reports "near", because requests stored before routes existed
|
|
114
|
+
* have no route field and were all NEAR.
|
|
115
|
+
*/
|
|
116
|
+
export function railOf(route) {
|
|
117
|
+
if (route && typeof route === "object") {
|
|
118
|
+
if ("intents" in route)
|
|
119
|
+
return "intents";
|
|
120
|
+
// Retired rail, still present in older stored requests. Reporting these
|
|
121
|
+
// as "near" would say a cross-chain payment settled locally.
|
|
122
|
+
if ("base_usdc" in route)
|
|
123
|
+
return "base";
|
|
124
|
+
}
|
|
125
|
+
return "near";
|
|
126
|
+
}
|
|
127
|
+
const STATUS = {
|
|
128
|
+
SETTLED: "success",
|
|
129
|
+
TRANSFER_FAILED: "failed",
|
|
130
|
+
TRANSFER_PENDING: "pending",
|
|
131
|
+
BLOCKED: "blocked",
|
|
132
|
+
};
|
|
133
|
+
export function requestToPayment(r, nodeLabels, decimals, delivery) {
|
|
134
|
+
return {
|
|
135
|
+
id: r.request_id,
|
|
136
|
+
agentId: String(r.node_id),
|
|
137
|
+
agentName: nodeLabels[r.node_id] ?? `node-${r.node_id}`,
|
|
138
|
+
amount: fromBaseUnits(BigInt(r.amount), decimals),
|
|
139
|
+
currency: "USDC",
|
|
140
|
+
recipientAddress: r.recipient,
|
|
141
|
+
// What the agent paid FOR, which is what the memo carries. The recipient
|
|
142
|
+
// is already its own column, so echoing it adds nothing.
|
|
143
|
+
venue: r.memo || r.recipient,
|
|
144
|
+
rail: railOf(r.route),
|
|
145
|
+
// A Base payment is NOT "success". The contract proved it obtained a
|
|
146
|
+
// signature; it cannot see whether the transaction landed, and nothing in
|
|
147
|
+
// this repo relays it. Rendering SETTLED as "success" for that rail would
|
|
148
|
+
// tell the reader the money arrived, which is the one claim the whole
|
|
149
|
+
// design is careful never to make. The NEAR rail is unaffected: there,
|
|
150
|
+
// SETTLED really does mean the tokens moved.
|
|
151
|
+
// signed -> delivered, but ONLY on evidence. A Base payment is "signed"
|
|
152
|
+
// when the vault proved it authorized the transfer, and "delivered" only
|
|
153
|
+
// once a relayer reports a Base transaction hash for it. The chain cannot
|
|
154
|
+
// attest the second step, so it is never inferred — no hash, no claim.
|
|
155
|
+
status: railOf(r.route) === "base" && r.reason_code === "SIGNED"
|
|
156
|
+
? delivery
|
|
157
|
+
? "delivered"
|
|
158
|
+
: "signed"
|
|
159
|
+
: STATUS[r.status] ?? r.status.toLowerCase(),
|
|
160
|
+
// The BASE transaction hash, when one exists. Null on the NEAR rail and on
|
|
161
|
+
// anything undelivered.
|
|
162
|
+
txSignature: delivery?.baseTxHash ?? null,
|
|
163
|
+
createdAt: nsToIso(r.created_at_ns),
|
|
164
|
+
prompt: r.memo || null,
|
|
165
|
+
resultUrl: null,
|
|
166
|
+
// Carried for terminal failures too, so a failed row can say WHY.
|
|
167
|
+
blockReason: r.status === "SETTLED" ? null : r.reason_code,
|
|
168
|
+
blockMessage: r.status === "SETTLED" ? null : REASONS[r.reason_code] ?? null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Plain-English for every reason code the hot path can return.
|
|
173
|
+
*
|
|
174
|
+
* `PARENT_DAILY_BUDGET` and `DAILY_BUDGET` get deliberately DIFFERENT
|
|
175
|
+
* sentences. Those two codes are the entire point of the hierarchy — one says
|
|
176
|
+
* the agent is out of budget, the other says the pool it shares is — and
|
|
177
|
+
* rendering the same text for both would make the product's distinguishing
|
|
178
|
+
* behaviour invisible to the person looking at the screen.
|
|
179
|
+
*/
|
|
180
|
+
export const REASONS = {
|
|
181
|
+
VAULT_PAUSED: "The vault is paused, so no agent can pay.",
|
|
182
|
+
NODE_FROZEN: "This agent, or a team above it, is frozen.",
|
|
183
|
+
NOT_AGENT: "That account is not bound to any budget node.",
|
|
184
|
+
DUPLICATE_REQUEST: "A payment with this request id already exists.",
|
|
185
|
+
INVALID_REQUEST_ID: "The request id is empty or uses characters the vault rejects.",
|
|
186
|
+
INVALID_AMOUNT: "The amount must be greater than zero.",
|
|
187
|
+
INVALID_MEMO: "The memo is too long or contains control characters.",
|
|
188
|
+
TOKEN_NOT_ALLOWED: "This node has no limit set for that token.",
|
|
189
|
+
RECIPIENT_NOT_ALLOWED: "That payee is not on this node's allowlist.",
|
|
190
|
+
COOLDOWN: "Too soon after the last payment from this agent.",
|
|
191
|
+
PER_TX_LIMIT: "Over this agent's own per-payment cap.",
|
|
192
|
+
PARENT_PER_TX_LIMIT: "Over a per-payment cap set higher up the tree.",
|
|
193
|
+
DAILY_BUDGET: "This agent has spent its own daily budget.",
|
|
194
|
+
PARENT_DAILY_BUDGET: "The shared pool is empty. This agent still has its own limit left, but the team " +
|
|
195
|
+
"or root budget it draws from does not.",
|
|
196
|
+
TRANSFER_FAILED: "The token transfer failed, so the reservation was released.",
|
|
197
|
+
INSUFFICIENT_GAS: "Not enough gas was attached to dispatch the transfer.",
|
|
198
|
+
};
|
|
199
|
+
export function blockedToPayment(o, nodeLabels, amountHuman, recipient, createdAtIso) {
|
|
200
|
+
return {
|
|
201
|
+
id: o.request_id,
|
|
202
|
+
agentId: String(o.node_id),
|
|
203
|
+
agentName: nodeLabels[o.node_id] ?? `node-${o.node_id}`,
|
|
204
|
+
amount: amountHuman,
|
|
205
|
+
currency: "USDC",
|
|
206
|
+
recipientAddress: recipient,
|
|
207
|
+
venue: recipient,
|
|
208
|
+
// The rail and status the existing Float UI already filters blocked rows
|
|
209
|
+
// on — no UI change needed to render these.
|
|
210
|
+
rail: "policy",
|
|
211
|
+
status: "blocked",
|
|
212
|
+
txSignature: null,
|
|
213
|
+
createdAt: createdAtIso,
|
|
214
|
+
prompt: null,
|
|
215
|
+
resultUrl: null,
|
|
216
|
+
blockReason: o.reason_code,
|
|
217
|
+
blockMessage: REASONS[o.reason_code] ?? `Blocked by vault policy (${o.reason_code}).`,
|
|
218
|
+
};
|
|
219
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Serves the existing Float dashboard's /v1 API from NEAR vault views.
|
|
4
|
+
*
|
|
5
|
+
* The point of this process: the Float dashboard runs UNCHANGED. Same
|
|
6
|
+
* components, same logo, same styles, same screens — only
|
|
7
|
+
* NEXT_PUBLIC_FLOAT_API_URL points here instead of at the Solana backend.
|
|
8
|
+
*
|
|
9
|
+
* node:http with no framework, because the whole job is four JSON endpoints.
|
|
10
|
+
*
|
|
11
|
+
* NEAR_VAULT_CONTRACT required
|
|
12
|
+
* NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
|
|
13
|
+
* NEAR_VAULT_TOKEN required (the NEP-141 the vault pays in)
|
|
14
|
+
* NEAR_VAULT_TOKEN_DECIMALS default 6
|
|
15
|
+
* NEAR_VAULT_AGENT_ID only needed for POST /v1/pay
|
|
16
|
+
* NEAR_VAULT_AGENT_KEY only needed for POST /v1/pay (the restricted key)
|
|
17
|
+
* PORT default 8787 (Float's dashboard default)
|
|
18
|
+
*/
|
|
19
|
+
export {};
|