@crewhaus/tool-token 0.7.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/README.md +172 -0
- package/dist/fixtures.d.ts +117 -0
- package/dist/fixtures.js +222 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1045 -0
- package/dist/lib/chain.d.ts +127 -0
- package/dist/lib/chain.js +202 -0
- package/dist/lib/erc.d.ts +122 -0
- package/dist/lib/erc.js +238 -0
- package/dist/lib/lists.d.ts +78 -0
- package/dist/lib/lists.js +290 -0
- package/dist/lib/onchain.d.ts +26 -0
- package/dist/lib/onchain.js +48 -0
- package/dist/lib/uri.d.ts +66 -0
- package/dist/lib/uri.js +203 -0
- package/package.json +48 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type MetadataResponse = {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
readonly contentType: string | null;
|
|
4
|
+
readonly bytes: Uint8Array;
|
|
5
|
+
};
|
|
6
|
+
export type MetadataFetch = (req: {
|
|
7
|
+
readonly url: string;
|
|
8
|
+
readonly maxBytes: number;
|
|
9
|
+
readonly signal?: AbortSignal;
|
|
10
|
+
}) => Promise<MetadataResponse>;
|
|
11
|
+
/** Bind the metadata fetcher. `undefined` restores the unbound state. */
|
|
12
|
+
export declare function _setMetadataFetch(fn: MetadataFetch | undefined): void;
|
|
13
|
+
export declare function hasMetadataFetch(): boolean;
|
|
14
|
+
/** 256 KiB. Metadata is a small JSON document; anything larger is not one. */
|
|
15
|
+
export declare const DEFAULT_MAX_METADATA_BYTES: number;
|
|
16
|
+
export type UriPlan = {
|
|
17
|
+
readonly action: "inline";
|
|
18
|
+
readonly scheme: "data";
|
|
19
|
+
readonly mediaType: string;
|
|
20
|
+
} | {
|
|
21
|
+
readonly action: "fetch";
|
|
22
|
+
readonly scheme: "ipfs" | "https";
|
|
23
|
+
/** The URL that would actually be requested, after any rewrite. */
|
|
24
|
+
readonly url: string;
|
|
25
|
+
} | {
|
|
26
|
+
readonly action: "skip";
|
|
27
|
+
readonly scheme: string;
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
};
|
|
30
|
+
export type UriPolicy = {
|
|
31
|
+
/** A URL PREFIX such as "https://cloudflare-ipfs.com/ipfs/". Nothing is guessed. */
|
|
32
|
+
readonly ipfsGateway?: string;
|
|
33
|
+
/** Hosts an https URI may be fetched from. Exact host match, case-insensitive. */
|
|
34
|
+
readonly allowedHosts?: ReadonlyArray<string>;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Check a caller-supplied gateway before anything is joined onto it. A
|
|
38
|
+
* gateway carrying a query string or missing its trailing slash silently
|
|
39
|
+
* produces a different URL than the caller intends.
|
|
40
|
+
*/
|
|
41
|
+
export declare function checkIpfsGateway(gateway: string): string;
|
|
42
|
+
/** Decide what, if anything, may be done with one URI. Decides; does not act. */
|
|
43
|
+
export declare function planUri(rawUri: string, policy: UriPolicy): UriPlan;
|
|
44
|
+
/** Decode a `data:` URI's payload in-process. */
|
|
45
|
+
export declare function decodeDataUri(uri: string, maxBytes: number): Uint8Array;
|
|
46
|
+
export type FetchedDocument = {
|
|
47
|
+
readonly bytes: Uint8Array;
|
|
48
|
+
readonly status: number;
|
|
49
|
+
readonly contentType: string | null;
|
|
50
|
+
};
|
|
51
|
+
/** Fetch through the seam, then enforce the cap on what actually came back. */
|
|
52
|
+
export declare function fetchDocument(url: string, maxBytes: number, signal?: AbortSignal): Promise<FetchedDocument>;
|
|
53
|
+
/** A URL a metadata document points at. Reported, never followed. */
|
|
54
|
+
export type ReferencedUrl = {
|
|
55
|
+
readonly field: string;
|
|
56
|
+
readonly scheme: string;
|
|
57
|
+
readonly value: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The URLs inside a metadata document, listed so the caller knows they exist.
|
|
61
|
+
*
|
|
62
|
+
* None of them is fetched. An image URL is the same third-party string the
|
|
63
|
+
* tokenURI was, one level deeper, and following it would undo the whole
|
|
64
|
+
* policy above.
|
|
65
|
+
*/
|
|
66
|
+
export declare function referencedUrls(metadata: unknown): ReadonlyArray<ReferencedUrl>;
|
package/dist/lib/uri.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to do with a URI a contract handed back — which is to say, mostly
|
|
3
|
+
* nothing.
|
|
4
|
+
*
|
|
5
|
+
* `tokenURI()` returns a string chosen by whoever deployed the contract. A
|
|
6
|
+
* tool that fetches it because a contract said so is a server-side request
|
|
7
|
+
* forgery with extra steps: the "token id" is the attacker's, the URL is the
|
|
8
|
+
* attacker's, and the request comes from inside whatever network the harness
|
|
9
|
+
* runs in. So the default here is to fetch NOTHING over the network, and
|
|
10
|
+
* every URI is classified into a plan the answer reports in full — what was
|
|
11
|
+
* read, what was skipped, and why.
|
|
12
|
+
*
|
|
13
|
+
* Three things are readable without trusting the contract with a socket:
|
|
14
|
+
*
|
|
15
|
+
* - `data:` URIs, which are decoded in-process. A great many NFTs are
|
|
16
|
+
* entirely on-chain and this is the whole of their metadata.
|
|
17
|
+
* - `ipfs://`, but only through a gateway the CALLER named. The rewritten
|
|
18
|
+
* URL is checked to still be inside that gateway afterwards, because
|
|
19
|
+
* `ipfs:////evil.example/x` otherwise resolves to somebody else's origin.
|
|
20
|
+
* - `https://`, but only to a host the CALLER allow-listed.
|
|
21
|
+
*
|
|
22
|
+
* Everything else — `http://`, `ar://`, an empty string, a gateway that was
|
|
23
|
+
* never supplied — is skipped with the reason attached.
|
|
24
|
+
*
|
|
25
|
+
* Like the chain seam, there is no dialling code here: `_setMetadataFetch` is
|
|
26
|
+
* the only way a byte arrives, the runtime binds it, and the tests drive it.
|
|
27
|
+
*/
|
|
28
|
+
import { Buffer } from "node:buffer";
|
|
29
|
+
const unbound = async (req) => {
|
|
30
|
+
throw new Error(`no metadata fetcher is bound, so ${req.url} was not read. This package contains no HTTP client of its own: the runtime binds one with _setMetadataFetch(), which is also where redirect, SSRF and timeout policy belongs.`);
|
|
31
|
+
};
|
|
32
|
+
let metadataFetch = unbound;
|
|
33
|
+
/** Bind the metadata fetcher. `undefined` restores the unbound state. */
|
|
34
|
+
export function _setMetadataFetch(fn) {
|
|
35
|
+
metadataFetch = fn ?? unbound;
|
|
36
|
+
}
|
|
37
|
+
export function hasMetadataFetch() {
|
|
38
|
+
return metadataFetch !== unbound;
|
|
39
|
+
}
|
|
40
|
+
/** 256 KiB. Metadata is a small JSON document; anything larger is not one. */
|
|
41
|
+
export const DEFAULT_MAX_METADATA_BYTES = 256 * 1024;
|
|
42
|
+
function schemeOf(uri) {
|
|
43
|
+
const match = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(uri.trim());
|
|
44
|
+
return match === null ? "" : match[1].toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check a caller-supplied gateway before anything is joined onto it. A
|
|
48
|
+
* gateway carrying a query string or missing its trailing slash silently
|
|
49
|
+
* produces a different URL than the caller intends.
|
|
50
|
+
*/
|
|
51
|
+
export function checkIpfsGateway(gateway) {
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(gateway);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error(`the ipfs gateway "${gateway}" is not a URL`);
|
|
58
|
+
}
|
|
59
|
+
if (url.protocol !== "https:")
|
|
60
|
+
throw new Error("an ipfs gateway must be https");
|
|
61
|
+
if (url.search !== "" || url.hash !== "") {
|
|
62
|
+
throw new Error("an ipfs gateway must not carry a query string or a fragment");
|
|
63
|
+
}
|
|
64
|
+
if (!gateway.endsWith("/")) {
|
|
65
|
+
throw new Error(`the ipfs gateway must end with "/" — it is a prefix the CID is joined onto, e.g. https://example.org/ipfs/`);
|
|
66
|
+
}
|
|
67
|
+
return gateway;
|
|
68
|
+
}
|
|
69
|
+
/** Decide what, if anything, may be done with one URI. Decides; does not act. */
|
|
70
|
+
export function planUri(rawUri, policy) {
|
|
71
|
+
const uri = rawUri.trim();
|
|
72
|
+
if (uri === "")
|
|
73
|
+
return { action: "skip", scheme: "", reason: "the contract returned no URI" };
|
|
74
|
+
const scheme = schemeOf(uri);
|
|
75
|
+
if (scheme === "data") {
|
|
76
|
+
const comma = uri.indexOf(",");
|
|
77
|
+
if (comma === -1) {
|
|
78
|
+
return { action: "skip", scheme: "data", reason: "a data: URI with no comma has no payload" };
|
|
79
|
+
}
|
|
80
|
+
const header = uri.slice("data:".length, comma);
|
|
81
|
+
const mediaType = header.split(";")[0] ?? "";
|
|
82
|
+
return {
|
|
83
|
+
action: "inline",
|
|
84
|
+
scheme: "data",
|
|
85
|
+
mediaType: mediaType === "" ? "text/plain" : mediaType,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (scheme === "ipfs") {
|
|
89
|
+
if (policy.ipfsGateway === undefined) {
|
|
90
|
+
return {
|
|
91
|
+
action: "skip",
|
|
92
|
+
scheme: "ipfs",
|
|
93
|
+
reason: "no ipfsGateway was supplied, and this package will not pick one for you",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const gateway = checkIpfsGateway(policy.ipfsGateway);
|
|
97
|
+
// Both `ipfs://<cid>/…` and the `ipfs://ipfs/<cid>/…` form some contracts
|
|
98
|
+
// emit; the gateway prefix already ends in /ipfs/, so the duplicate
|
|
99
|
+
// segment has to come off or the path is wrong.
|
|
100
|
+
let rest = uri.slice("ipfs://".length);
|
|
101
|
+
if (rest.startsWith("ipfs/"))
|
|
102
|
+
rest = rest.slice("ipfs/".length);
|
|
103
|
+
rest = rest.replace(/^\/+/, "");
|
|
104
|
+
if (rest === "") {
|
|
105
|
+
return { action: "skip", scheme: "ipfs", reason: "the ipfs URI has no CID" };
|
|
106
|
+
}
|
|
107
|
+
let joined;
|
|
108
|
+
try {
|
|
109
|
+
joined = new URL(rest, gateway).href;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return { action: "skip", scheme: "ipfs", reason: `"${uri}" does not join onto the gateway` };
|
|
113
|
+
}
|
|
114
|
+
// The contract chose `rest`. `//evil.example/x` and `../..` both escape a
|
|
115
|
+
// naive concatenation, so the result is checked to still be inside the
|
|
116
|
+
// gateway the caller named.
|
|
117
|
+
if (!joined.startsWith(gateway)) {
|
|
118
|
+
return {
|
|
119
|
+
action: "skip",
|
|
120
|
+
scheme: "ipfs",
|
|
121
|
+
reason: `the URI resolves to ${joined}, outside the gateway prefix it was given — refusing to follow a contract off its own gateway`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { action: "fetch", scheme: "ipfs", url: joined };
|
|
125
|
+
}
|
|
126
|
+
if (scheme === "https") {
|
|
127
|
+
let url;
|
|
128
|
+
try {
|
|
129
|
+
url = new URL(uri);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { action: "skip", scheme: "https", reason: "not a URL" };
|
|
133
|
+
}
|
|
134
|
+
const allowed = (policy.allowedHosts ?? []).map((h) => h.toLowerCase());
|
|
135
|
+
if (!allowed.includes(url.hostname.toLowerCase())) {
|
|
136
|
+
return {
|
|
137
|
+
action: "skip",
|
|
138
|
+
scheme: "https",
|
|
139
|
+
reason: allowed.length === 0
|
|
140
|
+
? "https metadata is not fetched unless the caller allow-lists the host; a URL that came out of contract data is not a reason to dial it"
|
|
141
|
+
: `${url.hostname} is not in allowedHosts (${allowed.join(", ")})`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { action: "fetch", scheme: "https", url: url.href };
|
|
145
|
+
}
|
|
146
|
+
if (scheme === "http") {
|
|
147
|
+
return {
|
|
148
|
+
action: "skip",
|
|
149
|
+
scheme: "http",
|
|
150
|
+
reason: "plaintext http is never fetched — the answer would be whatever the network says it is",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
action: "skip",
|
|
155
|
+
scheme,
|
|
156
|
+
reason: scheme === "" ? "the URI has no scheme" : `${scheme}: is not a scheme this reads`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** Decode a `data:` URI's payload in-process. */
|
|
160
|
+
export function decodeDataUri(uri, maxBytes) {
|
|
161
|
+
const comma = uri.indexOf(",");
|
|
162
|
+
const header = uri.slice("data:".length, comma);
|
|
163
|
+
const payload = uri.slice(comma + 1);
|
|
164
|
+
const bytes = /;base64$/i.test(header)
|
|
165
|
+
? new Uint8Array(Buffer.from(payload, "base64"))
|
|
166
|
+
: new TextEncoder().encode(decodeURIComponent(payload));
|
|
167
|
+
if (bytes.length > maxBytes) {
|
|
168
|
+
throw new Error(`the data: URI holds ${bytes.length} bytes, over the ${maxBytes}-byte cap`);
|
|
169
|
+
}
|
|
170
|
+
return bytes;
|
|
171
|
+
}
|
|
172
|
+
/** Fetch through the seam, then enforce the cap on what actually came back. */
|
|
173
|
+
export async function fetchDocument(url, maxBytes, signal) {
|
|
174
|
+
const response = await metadataFetch(signal === undefined ? { url, maxBytes } : { url, maxBytes, signal });
|
|
175
|
+
if (response.bytes.length > maxBytes) {
|
|
176
|
+
// The cap is re-checked here rather than trusted to the injected fetcher,
|
|
177
|
+
// because the cap is this package's promise and the fetcher is somebody
|
|
178
|
+
// else's code.
|
|
179
|
+
throw new Error(`${url} returned ${response.bytes.length} bytes, over the ${maxBytes}-byte cap — refusing to parse it`);
|
|
180
|
+
}
|
|
181
|
+
return { bytes: response.bytes, status: response.status, contentType: response.contentType };
|
|
182
|
+
}
|
|
183
|
+
const REFERENCE_FIELDS = ["image", "image_url", "image_data", "animation_url", "external_url"];
|
|
184
|
+
/**
|
|
185
|
+
* The URLs inside a metadata document, listed so the caller knows they exist.
|
|
186
|
+
*
|
|
187
|
+
* None of them is fetched. An image URL is the same third-party string the
|
|
188
|
+
* tokenURI was, one level deeper, and following it would undo the whole
|
|
189
|
+
* policy above.
|
|
190
|
+
*/
|
|
191
|
+
export function referencedUrls(metadata) {
|
|
192
|
+
if (typeof metadata !== "object" || metadata === null)
|
|
193
|
+
return [];
|
|
194
|
+
const record = metadata;
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const field of REFERENCE_FIELDS) {
|
|
197
|
+
const value = record[field];
|
|
198
|
+
if (typeof value !== "string" || value === "")
|
|
199
|
+
continue;
|
|
200
|
+
out.push({ field, scheme: schemeOf(value), value: value.slice(0, 512) });
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-token",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic token and address resolution: ERC-20 and ERC-721 reads with impostor flagging",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@crewhaus/chain-adapter-base": "0.7.0",
|
|
19
|
+
"@crewhaus/errors": "0.7.0",
|
|
20
|
+
"@crewhaus/tool-builder": "0.7.0",
|
|
21
|
+
"@crewhaus/tool-catalog": "0.7.0",
|
|
22
|
+
"@crewhaus/tool-onchain": "0.7.0",
|
|
23
|
+
"@crewhaus/tool-text": "0.7.0",
|
|
24
|
+
"zod": "^3.23.8"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
28
|
+
},
|
|
29
|
+
"license": "Apache-2.0",
|
|
30
|
+
"author": {
|
|
31
|
+
"name": "Max Meier",
|
|
32
|
+
"email": "max@crewhaus.ai",
|
|
33
|
+
"url": "https://crewhaus.ai"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
38
|
+
"directory": "packages/tool-token"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-token#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
48
|
+
}
|