@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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovering refused payments from the chain, continuously.
|
|
3
|
+
*
|
|
4
|
+
* A refusal is deliberately NOT stored in contract state — storing one would
|
|
5
|
+
* let an agent inflate the vault's storage bill by spamming rejects, which
|
|
6
|
+
* docs/KNOWN-TRADEOFFS.md §2 names as the real griefing vector. The contract
|
|
7
|
+
* emits a NEP-297 event and keeps nothing. That makes the single most
|
|
8
|
+
* important thing this product does invisible to anything that was not
|
|
9
|
+
* watching at the moment it happened.
|
|
10
|
+
*
|
|
11
|
+
* The events do survive, inside receipt outcomes, so they can be read back.
|
|
12
|
+
* No indexer is involved: NearBlocks returns nothing at all for this testnet
|
|
13
|
+
* account, so everything here is plain RPC and reproducible by anyone.
|
|
14
|
+
*
|
|
15
|
+
* Two things this module is careful about:
|
|
16
|
+
*
|
|
17
|
+
* COST. A naive walk queries every chunk of every block. In practice one
|
|
18
|
+
* chunk in ten carries any transaction, and an empty chunk's `tx_root` is
|
|
19
|
+
* the all-ones hash, so empties are skipped without a second call. Work per
|
|
20
|
+
* tick is also capped, so a long outage cannot turn the catch-up into a
|
|
21
|
+
* thundering scan.
|
|
22
|
+
*
|
|
23
|
+
* HONESTY ABOUT COVERAGE. A public RPC garbage-collects old blocks and
|
|
24
|
+
* occasionally fails a request. Those blocks are HOLES, and a range that
|
|
25
|
+
* contains holes is not covered. Reporting `from..to` while having silently
|
|
26
|
+
* skipped blocks inside it would turn "we did not look there" into "nothing
|
|
27
|
+
* happened" — the exact confusion this feature exists to remove.
|
|
28
|
+
*/
|
|
29
|
+
/** A chunk with no transactions carries this as its tx_root. */
|
|
30
|
+
export declare const EMPTY_TX_ROOT = "11111111111111111111111111111111";
|
|
31
|
+
export type RpcFn = (method: string, params: unknown) => Promise<any>;
|
|
32
|
+
/**
|
|
33
|
+
* An RPC caller that paces itself and backs off when throttled.
|
|
34
|
+
*
|
|
35
|
+
* Added after watching the follower shred its own coverage map. Public
|
|
36
|
+
* endpoints rate-limit; without backoff every transient `429` turned into a
|
|
37
|
+
* PERMANENT gap, because a block that could not be read is recorded as
|
|
38
|
+
* unread. One load test left 33 fragmented gaps of three to twenty blocks
|
|
39
|
+
* each — each one technically honest and collectively useless, since nobody
|
|
40
|
+
* is ever going to backfill a five-block hole.
|
|
41
|
+
*
|
|
42
|
+
* Holes should mean "this span is genuinely unavailable", not "the endpoint
|
|
43
|
+
* was briefly busy". Retrying is what keeps that distinction meaningful.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createRpc(url: string, opts?: {
|
|
46
|
+
paceMs?: number;
|
|
47
|
+
maxRetries?: number;
|
|
48
|
+
onThrottle?: (waitMs: number) => void;
|
|
49
|
+
}): RpcFn;
|
|
50
|
+
export interface BlockedRecord {
|
|
51
|
+
requestId: string;
|
|
52
|
+
nodeId: number;
|
|
53
|
+
reasonCode: string;
|
|
54
|
+
agent: string;
|
|
55
|
+
txHash: string;
|
|
56
|
+
blockHeight: number;
|
|
57
|
+
blockTimestampNs: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ScanResult {
|
|
60
|
+
records: BlockedRecord[];
|
|
61
|
+
blocksSeen: number;
|
|
62
|
+
chunksFetched: number;
|
|
63
|
+
chunksSkipped: number;
|
|
64
|
+
/** Heights that could not be read. Coverage inside a hole is unknown. */
|
|
65
|
+
holes: number[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Pull `request_blocked` events out of one transaction's receipt logs.
|
|
69
|
+
* Exported for testing: this parsing is the part that silently breaks if the
|
|
70
|
+
* event format ever changes.
|
|
71
|
+
*/
|
|
72
|
+
export declare function extractBlocked(logs: string[], ctx: {
|
|
73
|
+
agent: string;
|
|
74
|
+
txHash: string;
|
|
75
|
+
blockHeight: number;
|
|
76
|
+
blockTimestampNs: string;
|
|
77
|
+
}): BlockedRecord[];
|
|
78
|
+
/** Scan an inclusive block range for refusals by `vault`. */
|
|
79
|
+
export declare function scanRange(rpc: RpcFn, vault: string, from: number, to: number): Promise<ScanResult>;
|
|
80
|
+
/** An inclusive span of blocks that was NOT read. */
|
|
81
|
+
export interface Hole {
|
|
82
|
+
from: number;
|
|
83
|
+
to: number;
|
|
84
|
+
}
|
|
85
|
+
export interface BlockedStore {
|
|
86
|
+
vault: string;
|
|
87
|
+
scannedFrom: number | null;
|
|
88
|
+
scannedTo: number | null;
|
|
89
|
+
/**
|
|
90
|
+
* Spans known to be unread, as RANGES rather than individual heights.
|
|
91
|
+
* An adapter restarted after a day is tens of thousands of blocks behind;
|
|
92
|
+
* listing each one would bloat the file to no purpose. Kept so that
|
|
93
|
+
* coverage is never overstated — `scannedFrom..scannedTo` minus these is
|
|
94
|
+
* what has actually been examined.
|
|
95
|
+
*/
|
|
96
|
+
holes: Hole[];
|
|
97
|
+
blocked: Record<string, BlockedRecord>;
|
|
98
|
+
}
|
|
99
|
+
/** Merge overlapping/adjacent spans so the list stays short and readable. */
|
|
100
|
+
export declare function coalesce(spans: Hole[]): Hole[];
|
|
101
|
+
export declare function emptyStore(vault: string): BlockedStore;
|
|
102
|
+
/** Fold a scan result into a store. Pure, so the merge rules are testable. */
|
|
103
|
+
export declare function mergeScan(store: BlockedStore, from: number, to: number, res: ScanResult): {
|
|
104
|
+
store: BlockedStore;
|
|
105
|
+
added: number;
|
|
106
|
+
};
|
|
107
|
+
export interface FollowerOptions {
|
|
108
|
+
rpc: RpcFn;
|
|
109
|
+
vault: string;
|
|
110
|
+
load: () => BlockedStore;
|
|
111
|
+
save: (s: BlockedStore) => void;
|
|
112
|
+
/** Milliseconds between ticks. */
|
|
113
|
+
intervalMs?: number;
|
|
114
|
+
/** Hard cap on blocks examined per tick, so catch-up stays bounded. */
|
|
115
|
+
maxBlocksPerTick?: number;
|
|
116
|
+
/**
|
|
117
|
+
* If the cursor falls further behind the head than this, STOP trying to
|
|
118
|
+
* walk the gap: record it as a hole and resume at the head.
|
|
119
|
+
*
|
|
120
|
+
* Grinding through a day's backlog is tens of thousands of RPC calls and
|
|
121
|
+
* leaves the follower useless for hours. Jumping silently would be worse —
|
|
122
|
+
* it would report the skipped span as covered. Recording the hole keeps the
|
|
123
|
+
* adapter current AND keeps the coverage claim true; the gap can then be
|
|
124
|
+
* filled deliberately with scripts/scan-blocked.mjs.
|
|
125
|
+
*/
|
|
126
|
+
maxCatchupBlocks?: number;
|
|
127
|
+
onTick?: (info: {
|
|
128
|
+
from: number;
|
|
129
|
+
to: number;
|
|
130
|
+
added: number;
|
|
131
|
+
holes: number;
|
|
132
|
+
}) => void;
|
|
133
|
+
/** Called when a backlog was too large to walk and became a hole. */
|
|
134
|
+
onSkip?: (span: Hole) => void;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Follows the chain head, recording refusals as they happen.
|
|
138
|
+
*
|
|
139
|
+
* Starts from the stored cursor when there is one, and otherwise from the
|
|
140
|
+
* CURRENT head — never from genesis. A fresh adapter should not spend its
|
|
141
|
+
* first hour replaying history nobody asked for; `scripts/scan-blocked.mjs`
|
|
142
|
+
* exists for deliberate backfill.
|
|
143
|
+
*/
|
|
144
|
+
export declare class BlockedFollower {
|
|
145
|
+
private readonly o;
|
|
146
|
+
private timer;
|
|
147
|
+
private running;
|
|
148
|
+
private stopped;
|
|
149
|
+
constructor(o: FollowerOptions);
|
|
150
|
+
tick(): Promise<void>;
|
|
151
|
+
start(): void;
|
|
152
|
+
stop(): void;
|
|
153
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovering refused payments from the chain, continuously.
|
|
3
|
+
*
|
|
4
|
+
* A refusal is deliberately NOT stored in contract state — storing one would
|
|
5
|
+
* let an agent inflate the vault's storage bill by spamming rejects, which
|
|
6
|
+
* docs/KNOWN-TRADEOFFS.md §2 names as the real griefing vector. The contract
|
|
7
|
+
* emits a NEP-297 event and keeps nothing. That makes the single most
|
|
8
|
+
* important thing this product does invisible to anything that was not
|
|
9
|
+
* watching at the moment it happened.
|
|
10
|
+
*
|
|
11
|
+
* The events do survive, inside receipt outcomes, so they can be read back.
|
|
12
|
+
* No indexer is involved: NearBlocks returns nothing at all for this testnet
|
|
13
|
+
* account, so everything here is plain RPC and reproducible by anyone.
|
|
14
|
+
*
|
|
15
|
+
* Two things this module is careful about:
|
|
16
|
+
*
|
|
17
|
+
* COST. A naive walk queries every chunk of every block. In practice one
|
|
18
|
+
* chunk in ten carries any transaction, and an empty chunk's `tx_root` is
|
|
19
|
+
* the all-ones hash, so empties are skipped without a second call. Work per
|
|
20
|
+
* tick is also capped, so a long outage cannot turn the catch-up into a
|
|
21
|
+
* thundering scan.
|
|
22
|
+
*
|
|
23
|
+
* HONESTY ABOUT COVERAGE. A public RPC garbage-collects old blocks and
|
|
24
|
+
* occasionally fails a request. Those blocks are HOLES, and a range that
|
|
25
|
+
* contains holes is not covered. Reporting `from..to` while having silently
|
|
26
|
+
* skipped blocks inside it would turn "we did not look there" into "nothing
|
|
27
|
+
* happened" — the exact confusion this feature exists to remove.
|
|
28
|
+
*/
|
|
29
|
+
/** A chunk with no transactions carries this as its tx_root. */
|
|
30
|
+
export const EMPTY_TX_ROOT = "11111111111111111111111111111111";
|
|
31
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
/**
|
|
33
|
+
* An RPC caller that paces itself and backs off when throttled.
|
|
34
|
+
*
|
|
35
|
+
* Added after watching the follower shred its own coverage map. Public
|
|
36
|
+
* endpoints rate-limit; without backoff every transient `429` turned into a
|
|
37
|
+
* PERMANENT gap, because a block that could not be read is recorded as
|
|
38
|
+
* unread. One load test left 33 fragmented gaps of three to twenty blocks
|
|
39
|
+
* each — each one technically honest and collectively useless, since nobody
|
|
40
|
+
* is ever going to backfill a five-block hole.
|
|
41
|
+
*
|
|
42
|
+
* Holes should mean "this span is genuinely unavailable", not "the endpoint
|
|
43
|
+
* was briefly busy". Retrying is what keeps that distinction meaningful.
|
|
44
|
+
*/
|
|
45
|
+
export function createRpc(url, opts = {}) {
|
|
46
|
+
const paceMs = opts.paceMs ?? 25;
|
|
47
|
+
const maxRetries = opts.maxRetries ?? 5;
|
|
48
|
+
return async function rpc(method, params, attempt = 0) {
|
|
49
|
+
if (paceMs)
|
|
50
|
+
await sleep(paceMs);
|
|
51
|
+
let r;
|
|
52
|
+
try {
|
|
53
|
+
r = await fetch(url, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
// A dropped connection mid-scan is routine, not exceptional.
|
|
61
|
+
if (attempt >= maxRetries)
|
|
62
|
+
throw e;
|
|
63
|
+
await sleep(Math.min(30_000, 500 * 2 ** attempt));
|
|
64
|
+
return rpc(method, params, attempt + 1);
|
|
65
|
+
}
|
|
66
|
+
if (r.status === 429 || r.status >= 500) {
|
|
67
|
+
if (attempt >= maxRetries)
|
|
68
|
+
throw new Error(`RPC HTTP ${r.status} after ${attempt} retries`);
|
|
69
|
+
const wait = Math.min(30_000, 500 * 2 ** attempt);
|
|
70
|
+
opts.onThrottle?.(wait);
|
|
71
|
+
await sleep(wait);
|
|
72
|
+
return rpc(method, params, attempt + 1);
|
|
73
|
+
}
|
|
74
|
+
if (!r.ok)
|
|
75
|
+
throw new Error(`RPC HTTP ${r.status}`);
|
|
76
|
+
const j = await r.json();
|
|
77
|
+
if (j.error)
|
|
78
|
+
throw new Error(`${method}: ${JSON.stringify(j.error).slice(0, 140)}`);
|
|
79
|
+
return j.result;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Pull `request_blocked` events out of one transaction's receipt logs.
|
|
84
|
+
* Exported for testing: this parsing is the part that silently breaks if the
|
|
85
|
+
* event format ever changes.
|
|
86
|
+
*/
|
|
87
|
+
export function extractBlocked(logs, ctx) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const line of logs) {
|
|
90
|
+
if (!line.startsWith("EVENT_JSON:") || !line.includes("request_blocked"))
|
|
91
|
+
continue;
|
|
92
|
+
let ev;
|
|
93
|
+
try {
|
|
94
|
+
ev = JSON.parse(line.slice("EVENT_JSON:".length));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// A malformed log line is not a reason to abandon the rest of the
|
|
98
|
+
// transaction, nor to pretend the others do not exist.
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ev?.event !== "request_blocked")
|
|
102
|
+
continue;
|
|
103
|
+
for (const d of ev.data ?? []) {
|
|
104
|
+
if (typeof d?.request_id !== "string")
|
|
105
|
+
continue;
|
|
106
|
+
out.push({
|
|
107
|
+
requestId: d.request_id,
|
|
108
|
+
nodeId: Number(d.node_id),
|
|
109
|
+
reasonCode: String(d.reason_code ?? "UNKNOWN"),
|
|
110
|
+
...ctx,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** Scan an inclusive block range for refusals by `vault`. */
|
|
117
|
+
export async function scanRange(rpc, vault, from, to) {
|
|
118
|
+
const res = {
|
|
119
|
+
records: [],
|
|
120
|
+
blocksSeen: 0,
|
|
121
|
+
chunksFetched: 0,
|
|
122
|
+
chunksSkipped: 0,
|
|
123
|
+
holes: [],
|
|
124
|
+
};
|
|
125
|
+
for (let h = from; h <= to; h++) {
|
|
126
|
+
let blk;
|
|
127
|
+
try {
|
|
128
|
+
blk = await rpc("block", { block_id: h });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
res.holes.push(h);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
res.blocksSeen++;
|
|
135
|
+
for (const ch of blk.chunks ?? []) {
|
|
136
|
+
if (ch.tx_root === EMPTY_TX_ROOT) {
|
|
137
|
+
res.chunksSkipped++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
let chunk;
|
|
141
|
+
try {
|
|
142
|
+
chunk = await rpc("chunk", { chunk_id: ch.chunk_hash });
|
|
143
|
+
res.chunksFetched++;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// A chunk we could not read means this block is not fully covered.
|
|
147
|
+
res.holes.push(h);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const tx of chunk.transactions ?? []) {
|
|
151
|
+
if (tx.receiver_id !== vault)
|
|
152
|
+
continue;
|
|
153
|
+
let st;
|
|
154
|
+
try {
|
|
155
|
+
st = await rpc("EXPERIMENTAL_tx_status", [tx.hash, tx.signer_id]);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
res.holes.push(h);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const logs = (st.receipts_outcome ?? []).flatMap((o) => o.outcome?.logs ?? []);
|
|
162
|
+
res.records.push(...extractBlocked(logs, {
|
|
163
|
+
agent: tx.signer_id,
|
|
164
|
+
txHash: tx.hash,
|
|
165
|
+
blockHeight: h,
|
|
166
|
+
blockTimestampNs: String(blk.header?.timestamp ?? "0"),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return res;
|
|
172
|
+
}
|
|
173
|
+
/** Merge overlapping/adjacent spans so the list stays short and readable. */
|
|
174
|
+
export function coalesce(spans) {
|
|
175
|
+
if (spans.length < 2)
|
|
176
|
+
return [...spans];
|
|
177
|
+
const sorted = [...spans].sort((a, b) => a.from - b.from);
|
|
178
|
+
const out = [{ ...sorted[0] }];
|
|
179
|
+
for (const s of sorted.slice(1)) {
|
|
180
|
+
const last = out[out.length - 1];
|
|
181
|
+
if (s.from <= last.to + 1)
|
|
182
|
+
last.to = Math.max(last.to, s.to);
|
|
183
|
+
else
|
|
184
|
+
out.push({ ...s });
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
export function emptyStore(vault) {
|
|
189
|
+
return { vault, scannedFrom: null, scannedTo: null, holes: [], blocked: {} };
|
|
190
|
+
}
|
|
191
|
+
/** Fold a scan result into a store. Pure, so the merge rules are testable. */
|
|
192
|
+
export function mergeScan(store, from, to, res) {
|
|
193
|
+
let added = 0;
|
|
194
|
+
for (const r of res.records) {
|
|
195
|
+
if (store.blocked[r.requestId])
|
|
196
|
+
continue; // same refusal seen twice
|
|
197
|
+
store.blocked[r.requestId] = r;
|
|
198
|
+
added++;
|
|
199
|
+
}
|
|
200
|
+
store.scannedFrom = store.scannedFrom === null ? from : Math.min(store.scannedFrom, from);
|
|
201
|
+
store.scannedTo = Math.max(store.scannedTo ?? 0, to);
|
|
202
|
+
if (res.holes.length) {
|
|
203
|
+
store.holes = coalesce([...store.holes, ...res.holes.map((h) => ({ from: h, to: h }))]);
|
|
204
|
+
}
|
|
205
|
+
return { store, added };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Follows the chain head, recording refusals as they happen.
|
|
209
|
+
*
|
|
210
|
+
* Starts from the stored cursor when there is one, and otherwise from the
|
|
211
|
+
* CURRENT head — never from genesis. A fresh adapter should not spend its
|
|
212
|
+
* first hour replaying history nobody asked for; `scripts/scan-blocked.mjs`
|
|
213
|
+
* exists for deliberate backfill.
|
|
214
|
+
*/
|
|
215
|
+
export class BlockedFollower {
|
|
216
|
+
o;
|
|
217
|
+
timer = null;
|
|
218
|
+
running = false;
|
|
219
|
+
stopped = false;
|
|
220
|
+
constructor(o) {
|
|
221
|
+
this.o = o;
|
|
222
|
+
}
|
|
223
|
+
async tick() {
|
|
224
|
+
// Ticks must never overlap. A slow catch-up on a quiet RPC would
|
|
225
|
+
// otherwise stack, and two scans sharing a cursor double the work and
|
|
226
|
+
// race on the store.
|
|
227
|
+
if (this.running || this.stopped)
|
|
228
|
+
return;
|
|
229
|
+
this.running = true;
|
|
230
|
+
try {
|
|
231
|
+
const head = (await this.o.rpc("block", { finality: "final" })).header.height;
|
|
232
|
+
const store = this.o.load();
|
|
233
|
+
const cap = this.o.maxBlocksPerTick ?? 50;
|
|
234
|
+
const maxCatchup = this.o.maxCatchupBlocks ?? 5_000;
|
|
235
|
+
let from = store.scannedTo === null ? head : store.scannedTo + 1;
|
|
236
|
+
if (from > head)
|
|
237
|
+
return;
|
|
238
|
+
if (head - from > maxCatchup) {
|
|
239
|
+
const skipFrom = from;
|
|
240
|
+
const skipTo = head - 1;
|
|
241
|
+
store.holes = coalesce([...store.holes, { from: skipFrom, to: skipTo }]);
|
|
242
|
+
this.o.onSkip?.({ from: skipFrom, to: skipTo });
|
|
243
|
+
from = head;
|
|
244
|
+
}
|
|
245
|
+
const to = Math.min(head, from + cap - 1);
|
|
246
|
+
const res = await scanRange(this.o.rpc, this.o.vault, from, to);
|
|
247
|
+
const { store: next, added } = mergeScan(store, from, to, res);
|
|
248
|
+
next.vault = this.o.vault;
|
|
249
|
+
this.o.save(next);
|
|
250
|
+
this.o.onTick?.({ from, to, added, holes: res.holes.length });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// A failed tick is normal on a public endpoint. Swallow it: the cursor
|
|
254
|
+
// did not advance, so the next tick retries the same range.
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
this.running = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
start() {
|
|
261
|
+
this.stopped = false;
|
|
262
|
+
const ms = this.o.intervalMs ?? 10_000;
|
|
263
|
+
void this.tick();
|
|
264
|
+
this.timer = setInterval(() => void this.tick(), ms);
|
|
265
|
+
// Do not hold the process open on this timer alone.
|
|
266
|
+
this.timer.unref?.();
|
|
267
|
+
}
|
|
268
|
+
stop() {
|
|
269
|
+
this.stopped = true;
|
|
270
|
+
if (this.timer)
|
|
271
|
+
clearInterval(this.timer);
|
|
272
|
+
this.timer = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an env file without involving a shell.
|
|
3
|
+
*
|
|
4
|
+
* Every doc in this repo tells the operator to run:
|
|
5
|
+
*
|
|
6
|
+
* set -a && . .float/adapter.env && set +a && npx @floatonnear/float-adapter
|
|
7
|
+
*
|
|
8
|
+
* That idiom has already failed on a real box. From this project's memory
|
|
9
|
+
* (prod-env-file-cannot-be-sourced-by-bash): one unquoted value containing a
|
|
10
|
+
* space made `set -a; . .env; set +a` die with `Scan: command not found`, and
|
|
11
|
+
* every script opening with that line failed before it ran.
|
|
12
|
+
*
|
|
13
|
+
* Two reasons that is the wrong mechanism HERE specifically, rather than merely
|
|
14
|
+
* fragile:
|
|
15
|
+
*
|
|
16
|
+
* 1. The file is written by create-float and carries NEAR_VAULT_AGENT_KEY,
|
|
17
|
+
* FLOAT_QUOTE_SIGNER_KEY and FLOAT_API_TOKEN. Sourcing it means the shell
|
|
18
|
+
* EXECUTES its contents: `$(...)` and backticks in a value run as
|
|
19
|
+
* commands. A secrets file should be data.
|
|
20
|
+
* 2. It has to work the same way on Windows and on a VPS. `set -a` is not
|
|
21
|
+
* available in PowerShell at all, which is why this repo's memory carries
|
|
22
|
+
* a bespoke `Get-Content | ForEach-Object { Set-Item Env: }` incantation
|
|
23
|
+
* just to start the adapter locally.
|
|
24
|
+
*
|
|
25
|
+
* One parser, tested, used by both.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Parse `KEY=value` lines into a plain object.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT a dotenv clone: no variable interpolation, no escape
|
|
31
|
+
* sequences, no multi-line values. Each of those is a way for the contents of a
|
|
32
|
+
* secrets file to change meaning, and none is needed by the file create-float
|
|
33
|
+
* writes.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseEnvFile(text: string): Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* Apply a parsed env file WITHOUT overriding anything already set.
|
|
38
|
+
*
|
|
39
|
+
* A real environment variable must outrank the file, so a deploy can point one
|
|
40
|
+
* service at a different port or contract without editing shared secrets. That
|
|
41
|
+
* precedence is the same one the pythscan sandbox relied on to keep a verify run
|
|
42
|
+
* aimed at a database COPY while every credential still came from the
|
|
43
|
+
* production file.
|
|
44
|
+
*/
|
|
45
|
+
export declare function applyEnvFile(parsed: Record<string, string>, env?: Record<string, string | undefined>): string[];
|
package/dist/env-file.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an env file without involving a shell.
|
|
3
|
+
*
|
|
4
|
+
* Every doc in this repo tells the operator to run:
|
|
5
|
+
*
|
|
6
|
+
* set -a && . .float/adapter.env && set +a && npx @floatonnear/float-adapter
|
|
7
|
+
*
|
|
8
|
+
* That idiom has already failed on a real box. From this project's memory
|
|
9
|
+
* (prod-env-file-cannot-be-sourced-by-bash): one unquoted value containing a
|
|
10
|
+
* space made `set -a; . .env; set +a` die with `Scan: command not found`, and
|
|
11
|
+
* every script opening with that line failed before it ran.
|
|
12
|
+
*
|
|
13
|
+
* Two reasons that is the wrong mechanism HERE specifically, rather than merely
|
|
14
|
+
* fragile:
|
|
15
|
+
*
|
|
16
|
+
* 1. The file is written by create-float and carries NEAR_VAULT_AGENT_KEY,
|
|
17
|
+
* FLOAT_QUOTE_SIGNER_KEY and FLOAT_API_TOKEN. Sourcing it means the shell
|
|
18
|
+
* EXECUTES its contents: `$(...)` and backticks in a value run as
|
|
19
|
+
* commands. A secrets file should be data.
|
|
20
|
+
* 2. It has to work the same way on Windows and on a VPS. `set -a` is not
|
|
21
|
+
* available in PowerShell at all, which is why this repo's memory carries
|
|
22
|
+
* a bespoke `Get-Content | ForEach-Object { Set-Item Env: }` incantation
|
|
23
|
+
* just to start the adapter locally.
|
|
24
|
+
*
|
|
25
|
+
* One parser, tested, used by both.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Parse `KEY=value` lines into a plain object.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT a dotenv clone: no variable interpolation, no escape
|
|
31
|
+
* sequences, no multi-line values. Each of those is a way for the contents of a
|
|
32
|
+
* secrets file to change meaning, and none is needed by the file create-float
|
|
33
|
+
* writes.
|
|
34
|
+
*/
|
|
35
|
+
export function parseEnvFile(text) {
|
|
36
|
+
const out = {};
|
|
37
|
+
// Strip a BOM. PowerShell's Out-File and Set-Content write one by default, so
|
|
38
|
+
// any file the operator has touched from a one-liner has it, and it would
|
|
39
|
+
// otherwise become part of the FIRST key's name — producing a missing-variable
|
|
40
|
+
// failure whose cause is invisible.
|
|
41
|
+
for (const raw of text.replace(/^/, "").split(/\r?\n/)) {
|
|
42
|
+
const line = raw.trim();
|
|
43
|
+
if (!line || line.startsWith("#"))
|
|
44
|
+
continue;
|
|
45
|
+
// Split on the FIRST '=' only. Base64url tokens and ed25519 keys contain
|
|
46
|
+
// '=' as padding, and splitting on each one silently truncates a secret —
|
|
47
|
+
// which surfaces as a 401 that nobody can account for.
|
|
48
|
+
const eq = line.indexOf("=");
|
|
49
|
+
if (eq < 0)
|
|
50
|
+
continue; // not an assignment; not our business
|
|
51
|
+
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
|
52
|
+
if (!key)
|
|
53
|
+
continue;
|
|
54
|
+
let value = line.slice(eq + 1).trim();
|
|
55
|
+
// Strip ONE layer of matching surrounding quotes, so both `A="x y"` and the
|
|
56
|
+
// bare `A=x y` that broke the shell mean the same thing here.
|
|
57
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
58
|
+
value = value.slice(1, -1);
|
|
59
|
+
}
|
|
60
|
+
out[key] = value;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Apply a parsed env file WITHOUT overriding anything already set.
|
|
66
|
+
*
|
|
67
|
+
* A real environment variable must outrank the file, so a deploy can point one
|
|
68
|
+
* service at a different port or contract without editing shared secrets. That
|
|
69
|
+
* precedence is the same one the pythscan sandbox relied on to keep a verify run
|
|
70
|
+
* aimed at a database COPY while every credential still came from the
|
|
71
|
+
* production file.
|
|
72
|
+
*/
|
|
73
|
+
export function applyEnvFile(parsed, env = process.env) {
|
|
74
|
+
const applied = [];
|
|
75
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
76
|
+
if (env[k] !== undefined)
|
|
77
|
+
continue;
|
|
78
|
+
env[k] = v;
|
|
79
|
+
applied.push(k);
|
|
80
|
+
}
|
|
81
|
+
return applied;
|
|
82
|
+
}
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback by default. Exposure is opted into, never inherited.
|
|
3
|
+
*
|
|
4
|
+
* `listen(port)` with no host is the dangerous default — it binds every
|
|
5
|
+
* interface. Passing a host explicitly is the whole fix, and defaulting it to
|
|
6
|
+
* loopback means a deployment has to SAY it wants to be reachable.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveHost(host: string | undefined): string;
|
|
9
|
+
/**
|
|
10
|
+
* CORS headers, echoing only an exact-match origin.
|
|
11
|
+
*
|
|
12
|
+
* A wildcard plus an approving preflight is what made this drive-by reachable:
|
|
13
|
+
* any page the operator had open could POST JSON here. Returning no
|
|
14
|
+
* `allow-origin` for an unknown origin makes the browser block the response and
|
|
15
|
+
* — for a preflighted `application/json` POST — never send the request at all.
|
|
16
|
+
*
|
|
17
|
+
* The header is omitted entirely when no `Origin` was sent, which is the
|
|
18
|
+
* curl/server-to-server case and needs no CORS at all.
|
|
19
|
+
*/
|
|
20
|
+
export declare function corsHeaders(allowedOrigin: string, requestOrigin: string | undefined): Record<string, string>;
|
|
21
|
+
export type AuthVerdict = {
|
|
22
|
+
ok: true;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
status: number;
|
|
26
|
+
error: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Only mutating requests need the token.
|
|
30
|
+
*
|
|
31
|
+
* GETs are read-only and the bind is loopback, so they are reachable only from
|
|
32
|
+
* the machine itself. OPTIONS must pass or the browser never reaches the real
|
|
33
|
+
* request and the dashboard breaks for a reason nobody can see.
|
|
34
|
+
*
|
|
35
|
+
* A MISSING token is 503, not 401. They are different problems: 401 tells a
|
|
36
|
+
* caller to go and find a credential, and here there is none to find — the
|
|
37
|
+
* operator has not configured one. Conflating them sends people hunting for a
|
|
38
|
+
* secret that does not exist.
|
|
39
|
+
*/
|
|
40
|
+
export declare function authorize({ method, authHeader, token, }: {
|
|
41
|
+
method: string;
|
|
42
|
+
authHeader: string | undefined;
|
|
43
|
+
token: string | undefined;
|
|
44
|
+
}): AuthVerdict;
|
|
45
|
+
/**
|
|
46
|
+
* An error caused by the CALLER, not by this server.
|
|
47
|
+
*
|
|
48
|
+
* The catch-all returned 500 for everything, so `{"amount":"abc"}` came back as
|
|
49
|
+
* `500 invalid amount "abc"` — verified live against the running adapter.
|
|
50
|
+
*
|
|
51
|
+
* That is not a cosmetic status-code quibble on an agent-facing API. 500 means
|
|
52
|
+
* "the server broke, retrying may help"; 400 means "your request is wrong, fix
|
|
53
|
+
* it". An agent reading 500 on a malformed amount retries the same malformed
|
|
54
|
+
* amount forever. SKILL.md teaches agents exactly this distinction for the
|
|
55
|
+
* contract's reason codes, and the HTTP layer was contradicting it.
|
|
56
|
+
*/
|
|
57
|
+
export declare class BadRequest extends Error {
|
|
58
|
+
constructor(message: string);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Map a thrown value to a response.
|
|
62
|
+
*
|
|
63
|
+
* Takes `unknown` rather than `Error` on purpose: `throw "string"` is legal,
|
|
64
|
+
* and a catch-all that assumes `.message` exists turns a small bug into an
|
|
65
|
+
* unhandled one. Only the message is returned — never a stack, which would
|
|
66
|
+
* hand a caller the server's file paths.
|
|
67
|
+
*/
|
|
68
|
+
export declare function classifyError(e: unknown): {
|
|
69
|
+
status: number;
|
|
70
|
+
body: {
|
|
71
|
+
error: string;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Run caller-supplied parsing, re-labelling any failure as the caller's.
|
|
76
|
+
*
|
|
77
|
+
* Wraps the validation helpers that throw plain `Error` — `toBaseUnits` and
|
|
78
|
+
* friends — so their failures reach the catch-all already classified, instead
|
|
79
|
+
* of being indistinguishable from an RPC outage.
|
|
80
|
+
*/
|
|
81
|
+
export declare function fromCaller<T>(fn: () => T): T;
|