@oracle-agent/oracle 0.3.6 → 0.4.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.
@@ -14,6 +14,7 @@ import fs from "node:fs";
14
14
  import process from "node:process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { env } from "../src/oracle-env.mjs";
17
+ import { listAddresses, lookupAddress, rememberAddress, forgetAddress } from "../src/address-book.mjs";
17
18
 
18
19
  const DATA_URL = (env("ORACLE_DATA_URL", "MAD_DESK_URL", "http://127.0.0.1:8787")).replace(/\/$/, "");
19
20
 
@@ -262,7 +263,7 @@ const tools = [
262
263
  {
263
264
  name: "nft_prepare_list",
264
265
  description:
265
- "Prepare an NFT listing for OpenSea EVM, Magic Eden Solana, or Satflow Bitcoin after explicit user review. Requires userConfirmed=true plus asset, marketplace, price, currency where applicable, and future expiry. Returns approval/signing actions, an unsigned Solana transaction, or an unsigned Bitcoin PSBT. Never signs, submits, or broadcasts.",
266
+ "Prepare an NFT listing for OpenSea EVM, Magic Eden Solana, or Satflow Bitcoin after explicit user review. Requires userConfirmed=true plus asset, marketplace, price, currency where applicable, and future expiry. Magic Eden Solana also requires confirmation exactly: list <tokenMint> on magiceden-sol for <priceSol> SOL until <expiry>. Returns approval/signing actions, an unsigned Solana transaction, or an unsigned Bitcoin PSBT. Never signs, submits, or broadcasts.",
266
267
  inputSchema: {
267
268
  type: "object",
268
269
  required: ["marketplace", "userConfirmed"],
@@ -283,7 +284,8 @@ const tools = [
283
284
  tokenATA: { type: "string" },
284
285
  auctionHouse: { type: "string" },
285
286
  priceSol: { type: "string" },
286
- expiry: { description: "Future ISO 8601 timestamp or Unix seconds." },
287
+ expiry: { description: "Future ISO 8601 timestamp or Unix seconds. Magic Eden Solana listings require finite future expiry; no-expiry is refused." },
288
+ confirmation: { type: "string", description: "Required for Magic Eden Solana listings. Exact phrase: list <tokenMint> on magiceden-sol for <priceSol> SOL until <expiry>." },
287
289
  inscriptionId: { type: "string" },
288
290
  runesOutput: { type: "string" },
289
291
  ordAddress: { type: "string" },
@@ -539,6 +541,57 @@ const tools = [
539
541
  required: ["chainId", "address"],
540
542
  },
541
543
  },
544
+ {
545
+ name: "address_book_remember",
546
+ description:
547
+ "Remember a wallet address with a label. Call this whenever someone states their address " +
548
+ "or an agent/counterparty wallet is discovered, so it survives the conversation. " +
549
+ "Stores labels only — never private keys, seeds, or passphrases.",
550
+ inputSchema: {
551
+ type: "object",
552
+ required: ["address", "label"],
553
+ properties: {
554
+ address: { type: "string", description: "0x address" },
555
+ label: { type: "string", description: "short name, e.g. carlo-agent" },
556
+ who: { type: "string", description: "person or entity the wallet belongs to" },
557
+ role: { type: "string", description: "agent | owner | person | counterparty | venue" },
558
+ chainIds: { type: "array", items: { type: "number" } },
559
+ notes: { type: "string" },
560
+ },
561
+ },
562
+ },
563
+ {
564
+ name: "address_book_list",
565
+ description:
566
+ "List remembered wallets (owner, agent, people, counterparties). Read-only. " +
567
+ "Check this before sending or bridging to a person.",
568
+ inputSchema: {
569
+ type: "object",
570
+ properties: {
571
+ q: { type: "string", description: "search across label, who, address, notes" },
572
+ role: { type: "string" },
573
+ who: { type: "string" },
574
+ },
575
+ },
576
+ },
577
+ {
578
+ name: "address_book_lookup",
579
+ description: "Look up one 0x address in the durable address book. Read-only.",
580
+ inputSchema: {
581
+ type: "object",
582
+ required: ["address"],
583
+ properties: { address: { type: "string" } },
584
+ },
585
+ },
586
+ {
587
+ name: "address_book_forget",
588
+ description: "Remove a remembered address, optionally only one label for it.",
589
+ inputSchema: {
590
+ type: "object",
591
+ required: ["address"],
592
+ properties: { address: { type: "string" }, label: { type: "string" } },
593
+ },
594
+ },
542
595
  {
543
596
  name: "best_swap_route",
544
597
  description:
@@ -817,6 +870,15 @@ async function callTool(name, args = {}) {
817
870
  const token = args.token ? await sc.tokenBalance(args.address, args.token).catch(() => null) : null;
818
871
  return { chainId: Number(args.chainId), address: args.address, native, token };
819
872
  }
873
+ if (name === "address_book_remember") {
874
+ return rememberAddress({
875
+ address: args.address, label: args.label, who: args.who,
876
+ role: args.role, chainIds: args.chainIds, notes: args.notes, source: "oracle-data-mcp",
877
+ });
878
+ }
879
+ if (name === "address_book_list") return listAddresses({ q: args.q, role: args.role, who: args.who });
880
+ if (name === "address_book_lookup") return lookupAddress(args.address);
881
+ if (name === "address_book_forget") return forgetAddress(args.address, args.label || null);
820
882
  if (name === "data_catalog") return httpJson(`${DATA_URL}/data/catalog`);
821
883
  if (name === "data_health") return httpJson(`${DATA_URL}/data/health`);
822
884
  if (name === "data_call") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oracle-agent/oracle",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
4
4
  "description": "Oracle: prepare-only multichain agent control plane. Policy-bounded intents for a user-signed wallet. Self-custody by default — the public package never takes your key. Built for Hermes; no model key required.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -42,6 +42,7 @@
42
42
  "./scanner": "./src/scanner/index.mjs",
43
43
  "./router": "./src/router/index.mjs",
44
44
  "./action-semantics": "./src/action-semantics.mjs",
45
+ "./address-book": "./src/address-book.mjs",
45
46
  "./nft-gas-war": "./src/nft-gas-war-guard.mjs",
46
47
  "./prepare-envelope": "./src/prepare-envelope.mjs"
47
48
  },
@@ -26,6 +26,16 @@ _PROTECTED = {
26
26
  "oracle_operator_sign": "sign",
27
27
  "oracle_operator_send": "send",
28
28
  "oracle_operator_execute": "send",
29
+ "exec_arm": "execute",
30
+ "exec_disarm": "cancel",
31
+ "evm_sign": "sign",
32
+ "evm_send": "send",
33
+ # The address book is durable state a later send can be routed against, so
34
+ # writes are owner-only. Reads stay open to the owner's own sessions.
35
+ "address_book_remember": "owner_write",
36
+ "address_book_forget": "owner_write",
37
+ "address_book_list": "owner_read",
38
+ "address_book_lookup": "owner_read",
29
39
  }
30
40
 
31
41
  _LEADING_INTENT = re.compile(
@@ -181,6 +191,10 @@ def _on_pre_tool_call(tool_name: str = "", session_id: str = "", **_: Any) -> Op
181
191
  return _block(reason)
182
192
  if required == "owner_read":
183
193
  return None
194
+ if required == "owner_write":
195
+ # Owner-authenticated bookkeeping. It records a label; it does not move
196
+ # value and does not need a leading execution verb like `arm`.
197
+ return None
184
198
  intent = turn["intent"]
185
199
  if intent != required:
186
200
  labels = {
@@ -47,6 +47,16 @@ the data plane). If it stays ambiguous, ask. Do not guess a chain.
47
47
  `watch`, `watch this`, and `ping me` always mean `actionMode: alert_only`.
48
48
  Only an explicit `arm` may mean `actionMode: execute`, and only for one exact,
49
49
  bounded owner-authorized action.
50
+
51
+ **Arming is a chat action, not a terminal chore.** When the owner says `arm`,
52
+ create the exact action and confirm it. Never tell them to edit an env file,
53
+ export a variable, or restart a service to enable a trade. A local operator
54
+ ships pre-armed for owner-confirmed actions; the walls that still apply are
55
+ owner identity, the venue/destination allowlist, and the exact-grant bind.
56
+
57
+ **Autonomous trading is the one opt-in.** A trigger that fires a trade with
58
+ nobody watching requires `ORACLE_AUTONOMOUS=1`. Until then such an action
59
+ alerts instead of executing. Say that plainly rather than pretending it fired.
50
60
  2. **RFQ is a route source, not a permission bypass.** Compare solver/RFQ
51
61
  quotes net of gas/spread where configured, enforce expiry, and keep exact
52
62
  artifact kinds separate.
@@ -69,6 +79,15 @@ the data plane). If it stays ambiguous, ask. Do not guess a chain.
69
79
  incomplete whenever a provider, address, price, token/NFT indexer, or chain
70
80
  adapter is missing. Never turn an unavailable historical value into zero.
71
81
 
82
+ ## Address memory
83
+
84
+ Remember who an address belongs to. When someone states a wallet, or an agent /
85
+ counterparty wallet turns up in a read, call `address_book_remember` with a label
86
+ and who it belongs to. Before preparing a send or bridge to a person rather than a
87
+ venue, check `address_book_lookup` / `address_book_list` instead of asking them to
88
+ paste it again. Labels only — never private keys, seeds, or passphrases, and a
89
+ remembered label is a convenience, not an authorization.
90
+
72
91
  ## Confidence
73
92
 
74
93
  State it explicitly: `high` / `moderate` / `low` / `unknown`. A confident wrong
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Durable labeled address book for owner/agent/counterparty wallets.
3
+ *
4
+ * On by default: remembering who an address belongs to is a read/annotate
5
+ * concern, not an execution one. Nothing here signs, and no private key,
6
+ * passphrase, or seed is ever accepted or stored.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { ORACLE_CONFIG_DIR, env } from "./oracle-env.mjs";
11
+
12
+ const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
13
+ const FORBIDDEN_KEYS = new Set([
14
+ "privatekey", "private_key", "secret", "seed", "mnemonic", "passphrase", "signature", "keystore",
15
+ ]);
16
+
17
+ export function addressBookPath() {
18
+ const override = env("ORACLE_ADDRESS_BOOK", "MAD_ADDRESS_BOOK", "");
19
+ if (override) return path.resolve(override);
20
+ const dir = env("ORACLE_CONFIG_DIR", "MAD_CONFIG_DIR", ORACLE_CONFIG_DIR);
21
+ return path.join(dir, "address-book.json");
22
+ }
23
+
24
+ function assertNoSecrets(input) {
25
+ for (const key of Object.keys(input || {})) {
26
+ if (FORBIDDEN_KEYS.has(key.toLowerCase())) {
27
+ throw new Error(`address-book: ${key} is forbidden; the book stores labels, never key material`);
28
+ }
29
+ }
30
+ }
31
+
32
+ function normalizeAddress(value) {
33
+ const address = String(value || "").trim();
34
+ if (!ADDR_RE.test(address)) throw new Error("address-book: address must be 0x + 40 hex characters");
35
+ return address.toLowerCase();
36
+ }
37
+
38
+ function text(value, max) {
39
+ if (value == null || value === "") return null;
40
+ return String(value).slice(0, max);
41
+ }
42
+
43
+ function emptyBook() {
44
+ return { version: 1, updatedAt: null, entries: [] };
45
+ }
46
+
47
+ function normalizeEntry(entry) {
48
+ return {
49
+ address: String(entry.address).toLowerCase(),
50
+ label: text(entry.label, 80) || "unknown",
51
+ who: text(entry.who, 120),
52
+ role: text(entry.role, 40),
53
+ chainIds: Array.isArray(entry.chainIds) ? entry.chainIds.map(Number).filter(Number.isFinite) : [],
54
+ notes: text(entry.notes, 500),
55
+ source: text(entry.source, 80),
56
+ firstSeenAt: entry.firstSeenAt || null,
57
+ lastSeenAt: entry.lastSeenAt || null,
58
+ };
59
+ }
60
+
61
+ export function readAddressBook() {
62
+ try {
63
+ const raw = JSON.parse(fs.readFileSync(addressBookPath(), "utf8"));
64
+ if (!raw || typeof raw !== "object") return emptyBook();
65
+ const entries = Array.isArray(raw.entries) ? raw.entries : [];
66
+ return {
67
+ version: 1,
68
+ updatedAt: raw.updatedAt || null,
69
+ entries: entries
70
+ .filter((entry) => entry && typeof entry === "object" && ADDR_RE.test(String(entry.address || "")))
71
+ .map(normalizeEntry),
72
+ };
73
+ } catch {
74
+ return emptyBook();
75
+ }
76
+ }
77
+
78
+ function writeBook(book) {
79
+ const file = addressBookPath();
80
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
81
+ const payload = { ...book, version: 1, updatedAt: new Date().toISOString() };
82
+ const tmp = path.join(path.dirname(file), `.address-book.${process.pid}.tmp`);
83
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
84
+ fs.renameSync(tmp, file);
85
+ try {
86
+ fs.chmodSync(file, 0o600);
87
+ } catch {
88
+ /* best effort on platforms without chmod semantics */
89
+ }
90
+ return payload;
91
+ }
92
+
93
+ export function listAddresses(filter = {}) {
94
+ const book = readAddressBook();
95
+ let entries = book.entries;
96
+ if (filter.role) entries = entries.filter((entry) => entry.role === String(filter.role));
97
+ if (filter.who) {
98
+ const query = String(filter.who).toLowerCase();
99
+ entries = entries.filter(
100
+ (entry) => (entry.who || "").toLowerCase().includes(query) || (entry.label || "").toLowerCase().includes(query),
101
+ );
102
+ }
103
+ if (filter.q) {
104
+ const query = String(filter.q).toLowerCase();
105
+ entries = entries.filter(
106
+ (entry) =>
107
+ entry.address.includes(query) ||
108
+ (entry.label || "").toLowerCase().includes(query) ||
109
+ (entry.who || "").toLowerCase().includes(query) ||
110
+ (entry.notes || "").toLowerCase().includes(query),
111
+ );
112
+ }
113
+ return { updatedAt: book.updatedAt, count: entries.length, entries };
114
+ }
115
+
116
+ export function lookupAddress(address) {
117
+ const normalized = normalizeAddress(address);
118
+ const entries = readAddressBook().entries.filter((entry) => entry.address === normalized);
119
+ return { address: normalized, found: entries.length > 0, entries };
120
+ }
121
+
122
+ export function rememberAddress(input = {}) {
123
+ assertNoSecrets(input);
124
+ const address = normalizeAddress(input.address);
125
+ const label = text(input.label ?? input.role, 80) || "wallet";
126
+ const now = new Date().toISOString();
127
+ const book = readAddressBook();
128
+ const index = book.entries.findIndex((entry) => entry.address === address && entry.label === label);
129
+ const entry = normalizeEntry({
130
+ address,
131
+ label,
132
+ who: input.who,
133
+ role: input.role,
134
+ chainIds: input.chainIds,
135
+ notes: input.notes,
136
+ source: input.source || "agent",
137
+ firstSeenAt: index >= 0 ? book.entries[index].firstSeenAt || now : now,
138
+ lastSeenAt: now,
139
+ });
140
+ if (index >= 0) book.entries[index] = entry;
141
+ else book.entries.push(entry);
142
+ const rank = (role) => (role === "agent" ? 0 : role === "owner" ? 1 : 2);
143
+ book.entries.sort(
144
+ (a, b) => rank(a.role) - rank(b.role) || a.label.localeCompare(b.label) || a.address.localeCompare(b.address),
145
+ );
146
+ const saved = writeBook(book);
147
+ return { ok: true, entry, count: saved.entries.length };
148
+ }
149
+
150
+ export function forgetAddress(address, label = null) {
151
+ const normalized = normalizeAddress(address);
152
+ const book = readAddressBook();
153
+ const before = book.entries.length;
154
+ book.entries = book.entries.filter((entry) => {
155
+ if (entry.address !== normalized) return true;
156
+ return label == null ? false : entry.label !== String(label);
157
+ });
158
+ writeBook(book);
159
+ return { ok: true, removed: before - book.entries.length, count: book.entries.length };
160
+ }
package/src/data/http.mjs CHANGED
@@ -37,6 +37,35 @@ const IDENTITY_HEADERS = [
37
37
  "proxy-authorization",
38
38
  ];
39
39
 
40
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
41
+ const MAX_REDIRECTS = 3;
42
+
43
+ function credentialHeaderName(name) {
44
+ const h = String(name || "").toLowerCase();
45
+ return IDENTITY_HEADERS.includes(h) || /(^|-)(api[-_]?key|key|token|secret|auth|authorization|session|cookie|signature|access)(-|$)/.test(h);
46
+ }
47
+
48
+ function stripCredentialHeaders(headers) {
49
+ const safe = {};
50
+ for (const [name, value] of Object.entries(headers || {})) {
51
+ if (!credentialHeaderName(name)) safe[name] = value;
52
+ }
53
+ return safe;
54
+ }
55
+
56
+ function redirectedUrl(location, currentUrl) {
57
+ if (!location) throw new Error(`HTTP redirect from ${currentUrl} missing Location`);
58
+ const next = new URL(String(location), String(currentUrl));
59
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
60
+ throw new Error(`HTTP redirect from ${currentUrl} used unsupported protocol ${next.protocol}`);
61
+ }
62
+ return next.toString();
63
+ }
64
+
65
+ function sameOrigin(a, b) {
66
+ return new URL(String(a)).origin === new URL(String(b)).origin;
67
+ }
68
+
40
69
  const inflight = new Map();
41
70
 
42
71
  /**
@@ -77,24 +106,48 @@ async function once(url, { fetchImpl, method, headers, body, timeoutMs }) {
77
106
  const ac = new AbortController();
78
107
  const t = setTimeout(() => ac.abort(), timeoutMs);
79
108
  try {
80
- const res = await fetchImpl(url, { method, headers, body, signal: ac.signal });
81
- const text = await res.text();
82
- let json = null;
83
- if (text) {
84
- try {
85
- json = JSON.parse(text);
86
- } catch {
87
- json = null;
109
+ let currentUrl = String(url);
110
+ let currentHeaders = { ...(headers || {}) };
111
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
112
+ const res = await fetchImpl(currentUrl, {
113
+ method,
114
+ headers: currentHeaders,
115
+ body,
116
+ signal: ac.signal,
117
+ redirect: "manual",
118
+ });
119
+ const text = await res.text();
120
+ let json = null;
121
+ if (text) {
122
+ try {
123
+ json = JSON.parse(text);
124
+ } catch {
125
+ json = null;
126
+ }
88
127
  }
128
+ if (REDIRECT_STATUS.has(res.status)) {
129
+ if (method !== "GET" && method !== "HEAD") {
130
+ const err = new Error(`HTTP ${res.status} ${method} ${currentUrl} redirected non-idempotent request`);
131
+ err.status = res.status;
132
+ err.body = json ?? text.slice(0, 300);
133
+ throw err;
134
+ }
135
+ if (redirects === MAX_REDIRECTS) throw new Error(`HTTP redirect loop for ${url}`);
136
+ const nextUrl = redirectedUrl(res.headers?.get?.("location"), currentUrl);
137
+ if (!sameOrigin(nextUrl, currentUrl)) currentHeaders = stripCredentialHeaders(currentHeaders);
138
+ currentUrl = nextUrl;
139
+ continue;
140
+ }
141
+ if (!res.ok) {
142
+ const err = new Error(`HTTP ${res.status} ${method} ${currentUrl}`);
143
+ err.status = res.status;
144
+ err.retryAfter = res.headers?.get?.("retry-after") ?? null;
145
+ err.body = json ?? text.slice(0, 300);
146
+ throw err;
147
+ }
148
+ return json ?? text;
89
149
  }
90
- if (!res.ok) {
91
- const err = new Error(`HTTP ${res.status} ${method} ${url}`);
92
- err.status = res.status;
93
- err.retryAfter = res.headers?.get?.("retry-after") ?? null;
94
- err.body = json ?? text.slice(0, 300);
95
- throw err;
96
- }
97
- return json ?? text;
150
+ throw new Error(`HTTP redirect loop for ${url}`);
98
151
  } finally {
99
152
  clearTimeout(t);
100
153
  }
@@ -90,17 +90,33 @@ function lamports(sol) {
90
90
  }
91
91
 
92
92
  function listingExpiry(value) {
93
- if (value == null || value === "") return null;
93
+ if (value == null || value === "") throw new Error("magiceden: listing expiry is required and must be in the future");
94
94
  const expiry = Number(value);
95
- if (!Number.isSafeInteger(expiry) || expiry < 0) {
96
- throw new Error("magiceden: expiry must be a whole Unix timestamp in seconds or 0");
95
+ if (!Number.isSafeInteger(expiry) || expiry <= 0) {
96
+ throw new Error("magiceden: expiry must be a future Unix timestamp in seconds");
97
97
  }
98
- if (expiry !== 0 && expiry <= Math.floor(Date.now() / 1000)) {
99
- throw new Error("magiceden: expiry must be in the future or 0 for no expiry");
98
+ if (expiry <= Math.floor(Date.now() / 1000)) {
99
+ throw new Error("magiceden: expiry must be in the future");
100
100
  }
101
101
  return expiry;
102
102
  }
103
103
 
104
+ function listConfirmation({ tokenMint, priceSol, expiry }) {
105
+ return `list ${tokenMint} on magiceden-sol for ${priceSol} SOL until ${expiry}`;
106
+ }
107
+
108
+ function requireListingConfirmation(args, fields) {
109
+ if (args.userConfirmed !== true) {
110
+ throw new Error("magiceden: listing requires userConfirmed=true after reviewing tokenMint, priceSol, and expiry");
111
+ }
112
+ const expected = listConfirmation(fields);
113
+ const actual = String(args.confirmation || args.confirmationPhrase || "").trim();
114
+ if (actual !== expected) {
115
+ throw new Error(`magiceden: confirmation must equal ${JSON.stringify(expected)}`);
116
+ }
117
+ return expected;
118
+ }
119
+
104
120
  export async function magicEdenSolHealth(opts = {}) {
105
121
  try {
106
122
  const stats = await magicEdenSolStats({ symbol: "mad_lads" }, opts);
@@ -269,6 +285,7 @@ export async function magicEdenSolPrepareList(args = {}, opts = {}) {
269
285
  const auctionHouse = solanaPubkey(args.auctionHouse, "auctionHouse");
270
286
  const priceSol = positiveNumber(args.priceSol ?? args.price, "priceSol");
271
287
  const expiry = listingExpiry(args.expiry);
288
+ const confirmation = requireListingConfirmation(args, { tokenMint, priceSol, expiry });
272
289
  const url = new URL(`${base(opts)}/instructions/sell`);
273
290
  url.searchParams.set("seller", seller);
274
291
  url.searchParams.set("auctionHouseAddress", auctionHouse);
@@ -301,6 +318,7 @@ export async function magicEdenSolPrepareList(args = {}, opts = {}) {
301
318
  tokenMint,
302
319
  priceSol,
303
320
  expiry,
321
+ confirmation,
304
322
  transaction,
305
323
  transactionEncoding: "base64",
306
324
  raw,