@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.
- package/bin/oracle-data-mcp.mjs +64 -2
- package/package.json +2 -1
- package/plugins/oracle-owner-gate/__init__.py +14 -0
- package/profiles/oracle/SOUL.md +19 -0
- package/src/address-book.mjs +160 -0
- package/src/data/http.mjs +69 -16
- package/src/data/providers/magiceden-sol.mjs +23 -5
package/bin/oracle-data-mcp.mjs
CHANGED
|
@@ -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
|
+
"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 = {
|
package/profiles/oracle/SOUL.md
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
let
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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 === "")
|
|
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
|
|
96
|
-
throw new Error("magiceden: expiry must be a
|
|
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
|
|
99
|
-
throw new Error("magiceden: expiry must be in the future
|
|
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,
|