ape-claw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/skills/ape-claw/SKILL.md +322 -0
- package/LICENSE +21 -0
- package/README.md +826 -0
- package/allowlists/opensea-slug-overrides.json +13 -0
- package/allowlists/recommended.apechain.json +322 -0
- package/config/clawbots.example.json +3 -0
- package/config/policy.example.json +27 -0
- package/data/starter-pack-bundle.json +1 -0
- package/data/starter-pack.json +495 -0
- package/docs/ACP_BOUNTIES.md +108 -0
- package/docs/APECLAW_V2_ALPHA.md +206 -0
- package/docs/AUTONOMY_AND_SUBSTRATE.md +69 -0
- package/docs/CLAWBOTS_AND_INVITES.md +102 -0
- package/docs/CLI_GUIDE.md +124 -0
- package/docs/CONTRIBUTING.md +130 -0
- package/docs/DASHBOARD_GUIDE.md +108 -0
- package/docs/GLOBAL_BACKEND.md +145 -0
- package/docs/ONCHAIN_V2_GUIDE.md +140 -0
- package/docs/PRODUCT_OVERVIEW.md +127 -0
- package/docs/README.md +40 -0
- package/docs/SKILLCARDS_AND_IMPORTER.md +147 -0
- package/docs/STARTER_PACK.md +297 -0
- package/docs/SUPPORTED_NETWORKS.md +58 -0
- package/docs/TELEMETRY_AND_EVENTS.md +103 -0
- package/docs/THE_POD_RUNNER.md +198 -0
- package/docs/V1_WORKFLOWS.md +108 -0
- package/docs/V2_ONCHAIN_SKILLS.md +157 -0
- package/docs/WEB4_PLAN_STATUS.md +95 -0
- package/docs/WEB4_SWARM_MODEL.md +104 -0
- package/docs/archive/AUTONOMY_AND_SUBSTRATE.md +66 -0
- package/docs/archive/WEB4_PLAN_STATUS.md +93 -0
- package/docs/archive/WEB4_SWARM_MODEL.md +98 -0
- package/docs/developer/01-architecture.md +345 -0
- package/docs/developer/02-contracts.md +1034 -0
- package/docs/developer/03-writing-modules.md +513 -0
- package/docs/developer/04-skillcard-spec.md +336 -0
- package/docs/developer/05-backend-api.md +1079 -0
- package/docs/developer/06-telemetry.md +798 -0
- package/docs/developer/07-testing.md +546 -0
- package/docs/developer/08-contributing.md +211 -0
- package/docs/operator/01-quickstart.md +49 -0
- package/docs/operator/02-dashboard.md +174 -0
- package/docs/operator/03-cli-reference.md +818 -0
- package/docs/operator/04-skills-library.md +169 -0
- package/docs/operator/05-pod-operations.md +314 -0
- package/docs/operator/06-deployment.md +299 -0
- package/docs/operator/07-safety-and-policy.md +311 -0
- package/docs/operator/08-troubleshooting.md +457 -0
- package/docs/operator/09-env-reference.md +238 -0
- package/docs/social/STARTER_PACK_THREAD.md +209 -0
- package/package.json +77 -0
- package/skillcards/import-sources.json +93 -0
- package/skillcards/seed/acp-bounty-poll.v1.json +38 -0
- package/skillcards/seed/acp-bounty-post.v1.json +55 -0
- package/skillcards/seed/acp-browse.v1.json +41 -0
- package/skillcards/seed/acp-fulfill-and-route.v1.json +56 -0
- package/skillcards/seed/apeclaw-bridge-relay.v1.json +46 -0
- package/skillcards/seed/apeclaw-nft-autobuy.v1.json +60 -0
- package/skillcards/seed/apeclaw-receipt-recorder.v1.json +64 -0
- package/skillcards/seed/humanizer.v1.json +74 -0
- package/skillcards/seed/otherside-navigator.v1.json +116 -0
- package/skillcards/seed/stonkbrokers-launcher.v1.json +280 -0
- package/skillcards/seed/walkie-p2p.v1.json +66 -0
- package/src/cli/index.mjs +8 -0
- package/src/cli.mjs +1929 -0
- package/src/lib/bridge-relay.mjs +294 -0
- package/src/lib/clawbots.mjs +94 -0
- package/src/lib/io.mjs +36 -0
- package/src/lib/market.mjs +233 -0
- package/src/lib/nft-opensea.mjs +159 -0
- package/src/lib/paths.mjs +17 -0
- package/src/lib/pod-init.mjs +40 -0
- package/src/lib/policy.mjs +112 -0
- package/src/lib/rpc.mjs +49 -0
- package/src/lib/telemetry.mjs +92 -0
- package/src/lib/v2-onchain-abi.mjs +294 -0
- package/src/lib/v2-skillcard.mjs +27 -0
- package/src/server/index.mjs +169 -0
- package/src/server/logger.mjs +21 -0
- package/src/server/middleware/auth.mjs +90 -0
- package/src/server/middleware/body-limit.mjs +35 -0
- package/src/server/middleware/cors.mjs +33 -0
- package/src/server/middleware/rate-limit.mjs +44 -0
- package/src/server/routes/chat.mjs +178 -0
- package/src/server/routes/clawbots.mjs +182 -0
- package/src/server/routes/events.mjs +95 -0
- package/src/server/routes/health.mjs +72 -0
- package/src/server/routes/pod.mjs +64 -0
- package/src/server/routes/quotes.mjs +161 -0
- package/src/server/routes/skills.mjs +239 -0
- package/src/server/routes/static.mjs +161 -0
- package/src/server/routes/v2.mjs +48 -0
- package/src/server/sse.mjs +73 -0
- package/src/server/storage/file-backend.mjs +295 -0
- package/src/server/storage/index.mjs +37 -0
- package/src/server/storage/sqlite-backend.mjs +380 -0
- package/src/telemetry-server.mjs +1604 -0
- package/ui/css/dashboard.css +792 -0
- package/ui/css/skills.css +689 -0
- package/ui/docs.html +840 -0
- package/ui/favicon-180.png +0 -0
- package/ui/favicon-192.png +0 -0
- package/ui/favicon-32.png +0 -0
- package/ui/favicon-lobster.png +0 -0
- package/ui/favicon.svg +10 -0
- package/ui/index.html +2957 -0
- package/ui/js/dashboard.js +1766 -0
- package/ui/js/skills.js +1621 -0
- package/ui/pod.html +909 -0
- package/ui/shared/motion.css +286 -0
- package/ui/shared/motion.js +170 -0
- package/ui/shared/sidebar-nav.css +379 -0
- package/ui/shared/sidebar-nav.js +137 -0
- package/ui/skills.html +2879 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createWalletClient, defineChain, http, parseUnits } from "viem";
|
|
2
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
3
|
+
import { resolveRpcUrl } from "./rpc.mjs";
|
|
4
|
+
|
|
5
|
+
const RELAY_API_BASE = process.env.RELAY_API_BASE || "https://api.relay.link";
|
|
6
|
+
const NATIVE_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
7
|
+
|
|
8
|
+
const CHAIN_ID_BY_NAME = {
|
|
9
|
+
ethereum: 1,
|
|
10
|
+
arbitrum: 42161,
|
|
11
|
+
base: 8453,
|
|
12
|
+
optimism: 10,
|
|
13
|
+
polygon: 137,
|
|
14
|
+
apechain: 33139,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function normalizeChainId(input) {
|
|
18
|
+
if (typeof input === "number") return input;
|
|
19
|
+
const s = String(input || "").toLowerCase().trim();
|
|
20
|
+
if (/^\d+$/.test(s)) return Number(s);
|
|
21
|
+
if (CHAIN_ID_BY_NAME[s]) return CHAIN_ID_BY_NAME[s];
|
|
22
|
+
throw new Error(`Unsupported chain: ${input}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toHexPrivateKey(pk) {
|
|
26
|
+
const raw = String(pk || "").trim();
|
|
27
|
+
if (!raw) return "";
|
|
28
|
+
return raw.startsWith("0x") ? raw : `0x${raw}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getAddressFromPrivateKey(privateKey) {
|
|
32
|
+
const hex = toHexPrivateKey(privateKey);
|
|
33
|
+
if (!hex) return "";
|
|
34
|
+
return privateKeyToAccount(hex).address;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeCurrencyAddress(value) {
|
|
38
|
+
if (!value) return NATIVE_ADDRESS;
|
|
39
|
+
const s = String(value).trim();
|
|
40
|
+
if (s.toUpperCase() === "NATIVE" || s.toUpperCase() === "ETH" || s.toUpperCase() === "APE") {
|
|
41
|
+
return NATIVE_ADDRESS;
|
|
42
|
+
}
|
|
43
|
+
if (/^0x[a-fA-F0-9]{40}$/.test(s)) return s;
|
|
44
|
+
throw new Error(`Invalid currency address: ${value}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function relayHeaders(apiKey) {
|
|
48
|
+
const headers = {
|
|
49
|
+
"content-type": "application/json",
|
|
50
|
+
accept: "application/json",
|
|
51
|
+
};
|
|
52
|
+
if (apiKey) headers["x-api-key"] = apiKey;
|
|
53
|
+
return headers;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fetchJson(url, init = {}) {
|
|
57
|
+
const res = await fetch(url, init);
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const body = await res.text().catch(() => "");
|
|
60
|
+
throw new Error(`Relay HTTP ${res.status}${body ? `: ${body.slice(0, 220)}` : ""}`);
|
|
61
|
+
}
|
|
62
|
+
return res.json();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function guessRequestId(quote) {
|
|
66
|
+
return (
|
|
67
|
+
quote?.requestId ||
|
|
68
|
+
quote?.steps?.[0]?.requestId ||
|
|
69
|
+
quote?.steps?.[0]?.items?.[0]?.check?.requestId ||
|
|
70
|
+
quote?.steps?.[0]?.items?.[0]?.metadata?.requestId ||
|
|
71
|
+
""
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function guessCheckEndpoint(quote) {
|
|
76
|
+
return quote?.steps?.[0]?.items?.[0]?.check?.endpoint || "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function guessAmountOut(quote) {
|
|
80
|
+
const out = quote?.details?.currencyOut?.amount;
|
|
81
|
+
if (typeof out === "string" && /^\d+$/.test(out)) return out;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function computeFeeBps(quote) {
|
|
86
|
+
const input = Number(quote?.details?.currencyIn?.amount || 0);
|
|
87
|
+
if (!Number.isFinite(input) || input <= 0) return null;
|
|
88
|
+
const relayer = Number(quote?.fees?.relayer?.amount || 0);
|
|
89
|
+
const relayerGas = Number(quote?.fees?.relayerGas?.amount || 0);
|
|
90
|
+
const app = Number(quote?.fees?.app?.amount || 0);
|
|
91
|
+
const total = relayer + relayerGas + app;
|
|
92
|
+
if (!Number.isFinite(total) || total < 0) return null;
|
|
93
|
+
return Math.round((total / input) * 10000);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findNumericByKeys(obj, keys) {
|
|
97
|
+
if (!obj || typeof obj !== "object") return null;
|
|
98
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
99
|
+
const lk = k.toLowerCase();
|
|
100
|
+
if (keys.some((x) => lk.includes(x)) && Number.isFinite(Number(v))) {
|
|
101
|
+
return Number(v);
|
|
102
|
+
}
|
|
103
|
+
if (v && typeof v === "object") {
|
|
104
|
+
const nested = findNumericByKeys(v, keys);
|
|
105
|
+
if (nested !== null) return nested;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nativeCurrencyForChain(chainId) {
|
|
112
|
+
const id = Number(chainId);
|
|
113
|
+
if (id === 33139) return { name: "ApeCoin", symbol: "APE", decimals: 18 };
|
|
114
|
+
if (id === 137) return { name: "MATIC", symbol: "MATIC", decimals: 18 };
|
|
115
|
+
return { name: "Ether", symbol: "ETH", decimals: 18 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function makeChain(chainId, rpcUrl) {
|
|
119
|
+
return defineChain({
|
|
120
|
+
id: Number(chainId),
|
|
121
|
+
name: `chain-${chainId}`,
|
|
122
|
+
nativeCurrency: nativeCurrencyForChain(chainId),
|
|
123
|
+
rpcUrls: { default: { http: [rpcUrl] } },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractStatus(payload) {
|
|
128
|
+
if (!payload || typeof payload !== "object") return "unknown";
|
|
129
|
+
if (typeof payload.status === "string") return payload.status;
|
|
130
|
+
if (typeof payload?.result?.status === "string") return payload.result.status;
|
|
131
|
+
if (typeof payload?.data?.status === "string") return payload.data.status;
|
|
132
|
+
if (Array.isArray(payload?.intents) && payload.intents[0]?.status) return payload.intents[0].status;
|
|
133
|
+
if (Array.isArray(payload?.data) && payload.data[0]?.status) return payload.data[0].status;
|
|
134
|
+
return "unknown";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractDestinationTxHash(payload) {
|
|
138
|
+
return (
|
|
139
|
+
payload?.destinationTxHash ||
|
|
140
|
+
payload?.txHashes?.[0] ||
|
|
141
|
+
payload?.result?.destinationTxHash ||
|
|
142
|
+
payload?.result?.txHashes?.[0] ||
|
|
143
|
+
payload?.data?.destinationTxHash ||
|
|
144
|
+
payload?.data?.txHashes?.[0] ||
|
|
145
|
+
payload?.intents?.[0]?.destinationTxHash ||
|
|
146
|
+
payload?.intents?.[0]?.txHashes?.[0] ||
|
|
147
|
+
payload?.data?.[0]?.destinationTxHash ||
|
|
148
|
+
payload?.data?.[0]?.txHashes?.[0] ||
|
|
149
|
+
null
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function relayUserAddress({ args = {}, privateKey = "" }) {
|
|
154
|
+
const byArg = String(args.user || "").trim();
|
|
155
|
+
if (byArg) return byArg;
|
|
156
|
+
const fromPk = getAddressFromPrivateKey(privateKey);
|
|
157
|
+
if (fromPk) return fromPk;
|
|
158
|
+
throw new Error("Missing user address. Pass --user <0x...> or set APE_CLAW_PRIVATE_KEY.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function quoteBridgeRelay({
|
|
162
|
+
from,
|
|
163
|
+
to,
|
|
164
|
+
token,
|
|
165
|
+
amount,
|
|
166
|
+
args = {},
|
|
167
|
+
apiKey = "",
|
|
168
|
+
privateKey = "",
|
|
169
|
+
}) {
|
|
170
|
+
const originChainId = normalizeChainId(from);
|
|
171
|
+
const destinationChainId = normalizeChainId(to);
|
|
172
|
+
const user = relayUserAddress({ args, privateKey });
|
|
173
|
+
const decimals = Number(args.decimals || 18);
|
|
174
|
+
const amountBaseUnits = parseUnits(String(amount), decimals).toString();
|
|
175
|
+
const originCurrency = normalizeCurrencyAddress(args.originCurrency || token);
|
|
176
|
+
const destinationCurrency = normalizeCurrencyAddress(args.destinationCurrency || token);
|
|
177
|
+
|
|
178
|
+
const body = {
|
|
179
|
+
user,
|
|
180
|
+
originChainId,
|
|
181
|
+
destinationChainId,
|
|
182
|
+
originCurrency,
|
|
183
|
+
destinationCurrency,
|
|
184
|
+
amount: amountBaseUnits,
|
|
185
|
+
tradeType: "EXACT_INPUT",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const quote = await fetchJson(`${RELAY_API_BASE}/quote/v2`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: relayHeaders(apiKey),
|
|
191
|
+
body: JSON.stringify(body),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const relayRequestId = guessRequestId(quote);
|
|
195
|
+
const checkEndpoint = guessCheckEndpoint(quote);
|
|
196
|
+
const quotedFeeBps = computeFeeBps(quote) ?? findNumericByKeys(quote, ["feebps", "fee_bps", "bps"]);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
requestId: relayRequestId || `relay_${Date.now()}`,
|
|
200
|
+
relayRequestId: relayRequestId || null,
|
|
201
|
+
relayCheckEndpoint: checkEndpoint || null,
|
|
202
|
+
from: String(from),
|
|
203
|
+
to: String(to),
|
|
204
|
+
token: String(token || "NATIVE"),
|
|
205
|
+
amount: Number(amount),
|
|
206
|
+
amountBaseUnits,
|
|
207
|
+
originChainId,
|
|
208
|
+
destinationChainId,
|
|
209
|
+
originCurrency,
|
|
210
|
+
destinationCurrency,
|
|
211
|
+
status: "quoted",
|
|
212
|
+
routeHash: relayRequestId || null,
|
|
213
|
+
minAmountOut: guessAmountOut(quote),
|
|
214
|
+
feeBps: Number.isFinite(quotedFeeBps) ? quotedFeeBps : null,
|
|
215
|
+
expiresAt: new Date(Date.now() + 4 * 60_000).toISOString(),
|
|
216
|
+
relayQuote: quote,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function executeBridgeRelay({
|
|
221
|
+
request,
|
|
222
|
+
privateKey = "",
|
|
223
|
+
policy = {},
|
|
224
|
+
}) {
|
|
225
|
+
const hexPk = toHexPrivateKey(privateKey);
|
|
226
|
+
if (!hexPk) throw new Error("Missing APE_CLAW_PRIVATE_KEY for live bridge execute.");
|
|
227
|
+
const account = privateKeyToAccount(hexPk);
|
|
228
|
+
const quote = request?.relayQuote;
|
|
229
|
+
const steps = Array.isArray(quote?.steps) ? quote.steps : [];
|
|
230
|
+
if (steps.length === 0) throw new Error("Relay quote has no executable steps.");
|
|
231
|
+
|
|
232
|
+
const submittedTxs = [];
|
|
233
|
+
for (const step of steps) {
|
|
234
|
+
const item = step?.items?.[0];
|
|
235
|
+
const kind = String(step?.kind || item?.kind || "").toLowerCase();
|
|
236
|
+
if (!item) throw new Error("Relay step has no items.");
|
|
237
|
+
if (kind !== "transaction") {
|
|
238
|
+
throw new Error(`Unsupported relay step kind '${kind}'. Only transaction steps are supported.`);
|
|
239
|
+
}
|
|
240
|
+
const tx = item?.data || {};
|
|
241
|
+
const chainId = Number(tx.chainId || request.originChainId);
|
|
242
|
+
const rpcUrl = await resolveRpcUrl(chainId, policy);
|
|
243
|
+
const chain = makeChain(chainId, rpcUrl);
|
|
244
|
+
const client = createWalletClient({
|
|
245
|
+
account,
|
|
246
|
+
chain,
|
|
247
|
+
transport: http(rpcUrl),
|
|
248
|
+
});
|
|
249
|
+
const hash = await client.sendTransaction({
|
|
250
|
+
to: tx.to,
|
|
251
|
+
data: tx.data || "0x",
|
|
252
|
+
value: BigInt(tx.value || "0"),
|
|
253
|
+
chain,
|
|
254
|
+
});
|
|
255
|
+
submittedTxs.push({ chainId, hash });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
...request,
|
|
260
|
+
status: "pending",
|
|
261
|
+
originTxHash: submittedTxs[0]?.hash || null,
|
|
262
|
+
submittedTxs,
|
|
263
|
+
submittedAt: new Date().toISOString(),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function getBridgeRelayStatus({ request, apiKey = "" }) {
|
|
268
|
+
const relayRequestId = request?.relayRequestId;
|
|
269
|
+
if (!relayRequestId) {
|
|
270
|
+
return { status: request?.status || "unknown", raw: null };
|
|
271
|
+
}
|
|
272
|
+
const check = String(request?.relayCheckEndpoint || "");
|
|
273
|
+
const url = check
|
|
274
|
+
? (check.startsWith("http") ? check : `${RELAY_API_BASE}${check}`)
|
|
275
|
+
: `${RELAY_API_BASE}/intents/status/v3?requestId=${encodeURIComponent(relayRequestId)}`;
|
|
276
|
+
const raw = await fetchJson(url, {
|
|
277
|
+
method: "GET",
|
|
278
|
+
headers: relayHeaders(apiKey),
|
|
279
|
+
});
|
|
280
|
+
const relayStatus = extractStatus(raw);
|
|
281
|
+
const normalized =
|
|
282
|
+
relayStatus === "success"
|
|
283
|
+
? "confirmed"
|
|
284
|
+
: relayStatus === "pending" || relayStatus === "waiting"
|
|
285
|
+
? "pending"
|
|
286
|
+
: relayStatus;
|
|
287
|
+
return {
|
|
288
|
+
status: normalized,
|
|
289
|
+
relayStatus,
|
|
290
|
+
destinationTxHash: extractDestinationTxHash(raw),
|
|
291
|
+
raw,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readJson, writeJson } from "./io.mjs";
|
|
3
|
+
import { CLAWBOTS_PATH } from "./paths.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Clawbot verification system.
|
|
7
|
+
*
|
|
8
|
+
* Verified bots get access to the shared OpenSea API key so that bot
|
|
9
|
+
* operators do NOT need their own key. The shared key is read from the
|
|
10
|
+
* APE_CLAW_SHARED_OPENSEA_KEY env var (never stored in config files).
|
|
11
|
+
*
|
|
12
|
+
* Config format (config/clawbots.json):
|
|
13
|
+
* {
|
|
14
|
+
* "agents": {
|
|
15
|
+
* "the-clawllector": {
|
|
16
|
+
* "name": "The Clawllector",
|
|
17
|
+
* "tokenHash": "<sha256 of agent token>",
|
|
18
|
+
* "createdAt": "...",
|
|
19
|
+
* "enabled": true
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function resolveSharedOpenseaKey() {
|
|
26
|
+
return String(process.env.APE_CLAW_SHARED_OPENSEA_KEY || "").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hashToken(token) {
|
|
30
|
+
return createHash("sha256").update(String(token)).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadClawbotsConfig() {
|
|
34
|
+
return readJson(CLAWBOTS_PATH, null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function generateAgentToken() {
|
|
38
|
+
return `claw_${randomUUID().replace(/-/g, "")}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register a new clawbot agent. Returns the agent token (shown once).
|
|
43
|
+
*/
|
|
44
|
+
export function registerClawbot({ agentId, displayName }) {
|
|
45
|
+
const config = loadClawbotsConfig() || { agents: {} };
|
|
46
|
+
if (!config.agents) config.agents = {};
|
|
47
|
+
if (config.agents[agentId]) {
|
|
48
|
+
throw new Error(`Agent "${agentId}" already registered. Use a different --agent-id.`);
|
|
49
|
+
}
|
|
50
|
+
const token = generateAgentToken();
|
|
51
|
+
config.agents[agentId] = {
|
|
52
|
+
name: displayName || agentId,
|
|
53
|
+
tokenHash: hashToken(token),
|
|
54
|
+
createdAt: new Date().toISOString(),
|
|
55
|
+
enabled: true,
|
|
56
|
+
};
|
|
57
|
+
writeJson(CLAWBOTS_PATH, config);
|
|
58
|
+
return { agentId, token, displayName: config.agents[agentId].name };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Verify a clawbot's credentials.
|
|
63
|
+
* Returns { verified, agent, sharedOpenseaApiKey } or { verified: false }.
|
|
64
|
+
*/
|
|
65
|
+
export function verifyClawbot({ agentId, agentToken }) {
|
|
66
|
+
if (!agentId || !agentToken) return { verified: false, reason: "missing credentials" };
|
|
67
|
+
const config = loadClawbotsConfig();
|
|
68
|
+
if (!config || !config.agents) return { verified: false, reason: "no clawbots config" };
|
|
69
|
+
const agent = config.agents[agentId];
|
|
70
|
+
if (!agent) return { verified: false, reason: `agent "${agentId}" not registered` };
|
|
71
|
+
if (!agent.enabled) return { verified: false, reason: `agent "${agentId}" is disabled` };
|
|
72
|
+
const expectedHash = agent.tokenHash;
|
|
73
|
+
const gotHash = hashToken(agentToken);
|
|
74
|
+
if (gotHash !== expectedHash) return { verified: false, reason: "invalid token" };
|
|
75
|
+
return {
|
|
76
|
+
verified: true,
|
|
77
|
+
agent: { id: agentId, name: agent.name },
|
|
78
|
+
sharedOpenseaApiKey: resolveSharedOpenseaKey(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* List all registered clawbots (no secrets).
|
|
84
|
+
*/
|
|
85
|
+
export function listClawbots() {
|
|
86
|
+
const config = loadClawbotsConfig();
|
|
87
|
+
if (!config || !config.agents) return [];
|
|
88
|
+
return Object.entries(config.agents).map(([id, a]) => ({
|
|
89
|
+
agentId: id,
|
|
90
|
+
name: a.name,
|
|
91
|
+
enabled: a.enabled,
|
|
92
|
+
createdAt: a.createdAt,
|
|
93
|
+
}));
|
|
94
|
+
}
|
package/src/lib/io.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export function ensureDir(dirPath) {
|
|
6
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readJson(filePath, fallback = null) {
|
|
10
|
+
try {
|
|
11
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeJson(filePath, data) {
|
|
19
|
+
ensureDir(path.dirname(filePath));
|
|
20
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function appendJsonl(filePath, payload) {
|
|
24
|
+
ensureDir(path.dirname(filePath));
|
|
25
|
+
fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function nowIso() {
|
|
29
|
+
return new Date().toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function randomId(prefix = "id") {
|
|
33
|
+
const body = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
34
|
+
return `${prefix}_${body}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
function toSlug(input) {
|
|
2
|
+
return String(input || "")
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.trim()
|
|
5
|
+
.replace(/®/g, "")
|
|
6
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
7
|
+
.replace(/^-+|-+$/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function unique(items) {
|
|
11
|
+
return [...new Set(items.filter(Boolean))];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchJson(url, headers = {}) {
|
|
15
|
+
const res = await fetch(url, { headers });
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const body = await res.text().catch(() => "");
|
|
18
|
+
throw new Error(`HTTP ${res.status} for ${url}${body ? `: ${body.slice(0, 180)}` : ""}`);
|
|
19
|
+
}
|
|
20
|
+
return res.json();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractContractAddress(collectionPayload) {
|
|
24
|
+
const contracts = collectionPayload?.contracts;
|
|
25
|
+
if (Array.isArray(contracts) && contracts.length > 0) {
|
|
26
|
+
const ape = contracts.find((c) => String(c?.chain || "").toLowerCase() === "apechain");
|
|
27
|
+
return (ape?.address || contracts[0]?.address || null);
|
|
28
|
+
}
|
|
29
|
+
const fallback = collectionPayload?.contract || collectionPayload?.primary_contract;
|
|
30
|
+
if (fallback && typeof fallback === "object") return fallback.address || null;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractContractFromNftPayload(nftPayload) {
|
|
35
|
+
const nfts = nftPayload?.nfts || [];
|
|
36
|
+
if (!Array.isArray(nfts) || nfts.length === 0) return null;
|
|
37
|
+
const first = nfts[0] || {};
|
|
38
|
+
const c = first?.contract;
|
|
39
|
+
if (c && typeof c === "object") return c.address || null;
|
|
40
|
+
const contracts = first?.contracts;
|
|
41
|
+
if (Array.isArray(contracts) && contracts.length > 0) return contracts[0]?.address || null;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildSlugCandidates(item, overrides = {}) {
|
|
46
|
+
const raw = String(item.name || "");
|
|
47
|
+
const base = toSlug(raw);
|
|
48
|
+
const fromOverride = overrides[raw] || overrides[base] || [];
|
|
49
|
+
const variants = [
|
|
50
|
+
item.slug,
|
|
51
|
+
base,
|
|
52
|
+
base.replace(/-on-apechain$/, ""),
|
|
53
|
+
base.replace(/-on-ape$/, ""),
|
|
54
|
+
base.replace(/-the-level-up$/, ""),
|
|
55
|
+
base.replace(/-/g, ""),
|
|
56
|
+
toSlug(raw.replace(/_/g, " ")),
|
|
57
|
+
toSlug(raw.replace(/:/g, " ")),
|
|
58
|
+
...(Array.isArray(fromOverride) ? fromOverride : [fromOverride]),
|
|
59
|
+
];
|
|
60
|
+
return unique(variants);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function resolveOpenSeaCollection(item, headers, overrides = {}) {
|
|
64
|
+
const candidates = buildSlugCandidates(item, overrides);
|
|
65
|
+
const notes = [];
|
|
66
|
+
for (const slug of candidates) {
|
|
67
|
+
// 1) Try collection metadata endpoint
|
|
68
|
+
try {
|
|
69
|
+
const data = await fetchJson(
|
|
70
|
+
`https://api.opensea.io/api/v2/collections/${encodeURIComponent(slug)}`,
|
|
71
|
+
headers,
|
|
72
|
+
);
|
|
73
|
+
const collection = data?.collection || data;
|
|
74
|
+
const ca = extractContractAddress(collection);
|
|
75
|
+
if (ca) return { slug, contractAddress: ca, notes: [`resolved via get_collection slug=${slug}`] };
|
|
76
|
+
notes.push(`slug ${slug}: collection found but no contract field`);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
notes.push(`slug ${slug}: get_collection failed (${err.message})`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2) Fallback: try NFTs-by-collection and derive contract from first NFT
|
|
82
|
+
try {
|
|
83
|
+
const nftsData = await fetchJson(
|
|
84
|
+
`https://api.opensea.io/api/v2/collection/${encodeURIComponent(slug)}/nfts?limit=1`,
|
|
85
|
+
headers,
|
|
86
|
+
);
|
|
87
|
+
const ca = extractContractFromNftPayload(nftsData);
|
|
88
|
+
if (ca) return { slug, contractAddress: ca, notes: [`resolved via get_nfts_by_collection slug=${slug}`] };
|
|
89
|
+
notes.push(`slug ${slug}: nfts endpoint returned no contract`);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
notes.push(`slug ${slug}: get_nfts_by_collection failed (${err.message})`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { slug: item.slug || toSlug(item.name), contractAddress: null, notes };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function enrichAllowlistWithOpenSea(allowlist, apiKey, overrides = {}) {
|
|
98
|
+
if (!apiKey) return { allowlist, notes: ["OpenSea key not provided; skipped enrichment."] };
|
|
99
|
+
const headers = { "x-api-key": apiKey, accept: "application/json" };
|
|
100
|
+
const notes = [];
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const item of allowlist) {
|
|
103
|
+
if (item.contractAddress) {
|
|
104
|
+
out.push(item);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const resolved = await resolveOpenSeaCollection(item, headers, overrides);
|
|
109
|
+
const ca = resolved.contractAddress;
|
|
110
|
+
if (ca) {
|
|
111
|
+
out.push({ ...item, slug: resolved.slug, contractAddress: ca });
|
|
112
|
+
notes.push(`resolved ${item.name} -> ${ca} (slug=${resolved.slug})`);
|
|
113
|
+
} else {
|
|
114
|
+
out.push(item);
|
|
115
|
+
notes.push(`no CA found for ${item.name}`);
|
|
116
|
+
notes.push(...resolved.notes.map((n) => ` ${n}`));
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
out.push(item);
|
|
120
|
+
notes.push(`OpenSea lookup failed for ${item.name}: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { allowlist: out, notes };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function getListings({
|
|
127
|
+
collection,
|
|
128
|
+
tokenId,
|
|
129
|
+
maxPrice,
|
|
130
|
+
dataSource,
|
|
131
|
+
apiKey,
|
|
132
|
+
slugOverrides = {},
|
|
133
|
+
}) {
|
|
134
|
+
const source = String(dataSource || "reservoir").toLowerCase();
|
|
135
|
+
if (source !== "opensea") {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Unsupported market data source '${source}' for live mode. Set market.dataSource to 'opensea'.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!apiKey) {
|
|
142
|
+
throw new Error("OpenSea data source selected but OPENSEA_API_KEY is missing.");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const candidates = buildSlugCandidates({ name: collection, slug: toSlug(collection) }, slugOverrides);
|
|
146
|
+
let slug = candidates[0];
|
|
147
|
+
const headers = { "x-api-key": apiKey, accept: "application/json" };
|
|
148
|
+
|
|
149
|
+
let data = null;
|
|
150
|
+
let endpoint = "";
|
|
151
|
+
for (const s of candidates) {
|
|
152
|
+
try {
|
|
153
|
+
const urls = tokenId
|
|
154
|
+
? [
|
|
155
|
+
`https://api.opensea.io/api/v2/listings/collection/${encodeURIComponent(s)}/nfts/${encodeURIComponent(String(tokenId))}/all?limit=20`,
|
|
156
|
+
`https://api.opensea.io/api/v2/listings/collection/${encodeURIComponent(s)}/all?limit=50`,
|
|
157
|
+
]
|
|
158
|
+
: [`https://api.opensea.io/api/v2/listings/collection/${encodeURIComponent(s)}/all?limit=20`];
|
|
159
|
+
for (const listingsUrl of urls) {
|
|
160
|
+
try {
|
|
161
|
+
data = await fetchJson(listingsUrl, headers);
|
|
162
|
+
slug = s;
|
|
163
|
+
endpoint = listingsUrl;
|
|
164
|
+
break;
|
|
165
|
+
} catch {
|
|
166
|
+
// try next endpoint variant
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (data) break;
|
|
170
|
+
} catch {
|
|
171
|
+
// try next candidate slug
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!data) {
|
|
175
|
+
throw new Error(`OpenSea collection not found for any slug candidate: ${candidates.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
const rawListings = data?.listings || data?.orders || [];
|
|
178
|
+
const listings = rawListings.map((l, idx) => {
|
|
179
|
+
const wei =
|
|
180
|
+
l?.price?.current?.value ||
|
|
181
|
+
l?.current_price ||
|
|
182
|
+
l?.price?.value ||
|
|
183
|
+
l?.protocol_data?.parameters?.consideration?.[0]?.startAmount ||
|
|
184
|
+
"0";
|
|
185
|
+
const priceApe = Number((Number(wei) / 1e18).toFixed(6));
|
|
186
|
+
const tid = String(
|
|
187
|
+
l?.protocol_data?.parameters?.offer?.[0]?.identifierOrCriteria ||
|
|
188
|
+
l?.protocol_data?.parameters?.consideration?.[0]?.identifierOrCriteria ||
|
|
189
|
+
l?.asset?.token_id ||
|
|
190
|
+
tokenId ||
|
|
191
|
+
idx + 1,
|
|
192
|
+
);
|
|
193
|
+
return {
|
|
194
|
+
listingId: String(l?.order_hash || l?.id || `os_${idx}`),
|
|
195
|
+
orderHash: String(l?.order_hash || l?.id || `os_${idx}`),
|
|
196
|
+
collection: slug,
|
|
197
|
+
tokenId: tid,
|
|
198
|
+
priceApe,
|
|
199
|
+
currency: "APE",
|
|
200
|
+
source: "opensea",
|
|
201
|
+
protocolAddress: String(
|
|
202
|
+
l?.protocol_address ||
|
|
203
|
+
l?.protocolData?.protocol_address ||
|
|
204
|
+
"0x0000000000000068f116a894984e2db1123eb395",
|
|
205
|
+
),
|
|
206
|
+
assetContractAddress: String(
|
|
207
|
+
l?.protocol_data?.parameters?.offer?.[0]?.token ||
|
|
208
|
+
l?.asset?.asset_contract?.address ||
|
|
209
|
+
"",
|
|
210
|
+
),
|
|
211
|
+
chainSlug: "ape_chain",
|
|
212
|
+
expiresAt: l?.expiration_time
|
|
213
|
+
? new Date(Number(l.expiration_time) * 1000).toISOString()
|
|
214
|
+
: null,
|
|
215
|
+
protocolData: l?.protocol_data || null,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const filteredByToken = tokenId
|
|
220
|
+
? listings.filter((l) => String(l.tokenId) === String(tokenId))
|
|
221
|
+
: listings;
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
source: "opensea",
|
|
225
|
+
listings: filteredByToken.filter((l) => Number(l.priceApe) <= Number(maxPrice)),
|
|
226
|
+
notes: [
|
|
227
|
+
"Listings fetched from OpenSea live listings endpoint.",
|
|
228
|
+
`Resolved slug: ${slug}`,
|
|
229
|
+
`Endpoint: ${endpoint}`,
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|