Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
bun-version: '1.3.14'

- run: npm install --no-package-lock --no-save playwright@1.56.1
- run: npx playwright install --with-deps chromium
- run: npx playwright install --with-deps chromium firefox webkit

# Unit tests, minification, unit tests against the minified artifact, the
# byte budget, and a real Chromium loading the library through
Expand Down
110 changes: 102 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,93 @@ This example outlines the basic structure for utilizing OrdJS within an asynchro
- The pinned CBOR decoder still loses precision on 64-bit integers and collapses
distinct map keys. Fixing that needs a separate audited decoder inscription.

## Endpoint coverage (0.1.3-beta)

Every route below was checked against mainnet `ordinals.com` before being wrapped.

| Method | Route | Notes |
|---|---|---|
| `getBlockhash(height?)` | `/r/blockhash[/<height>]` | |
| `getBlockheight()` | `/r/blockheight` | |
| `getBlocktime()` | `/r/blocktime` | |
| `getBlockInfo(query)` | `/r/blockinfo/<height\|hash>` | `latest` is **not** accepted: ord answers 400. A block hash is an art seed, not secure randomness |
| `getInscription(id)` | `/r/inscription/<id>` | type, length, delegate, sat, location; location is mutable |
| `getMetadata(id)` | `/r/metadata/<id>` | hex CBOR |
| `getDecodedMetadata(id)` | `/r/metadata/<id>` | decodes via the CBOR inscription, loaded lazily |
| `getChildren(id, page?)` | `/r/children/<id>[/<page>]` | IDs only |
| `getChildrenInscriptions(id, page?)` | `/r/children/<id>/inscriptions[/<page>]` | full details; paginates with `page` |
| `getParents(id, page?)` | `/r/parents/<id>[/<page>]` | paginates with `page_index`, not `page` |
| `getSatInscriptions(sat, page?, index?)` | `/r/sat/<sat>[/<page>][/at/<index>]` | page and index are mutually exclusive |
| `getSatLastInscription(sat)` | `/r/sat/<sat>/at/-1` | may answer `{"id": null}` |
| `getSatLastInscriptionContent(sat)` | above, then `/content/<id>` | resolves `null` for an empty sat |
| `getInscriptionContent(id)` | `/content/<id>` | follows a delegate; returns `{mime, base64}` |
| `getUndelegatedContent(id)` | `/r/undelegated-content/<id>` | the inscription's own bytes, delegate not followed |
| `getSatInscriptionContent(sat, index?)` | `/r/sat/<sat>/at/<index>/content` | one request instead of two; needs the sat index; defaults to `-1` |

`request(endpoint)` remains available for any JSON route without a wrapper.

### Errors

Every failing request throws `OrdJS <status> <endpoint>: <ord's own message>` with a
`status` property, for example
`OrdJS 404 /r/sat/1/at/0/content: inscription on sat 1 not found`. Branch on
`error.status`, not on message text. Note that a 404 from
`getSatInscriptionContent` means either an empty sat **or** a server running
without the sat index; ord's message distinguishes them, so it is preserved rather
than collapsed into "not found".

## The metadata decoder

`getDecodedMetadata` loads the inscribed decoder lazily, once. The current one
(`a9f6a9b0…308566i0`, cbor-js) **decodes some metadata incorrectly**:

| Input | Inscribed decoder | Consequence |
|---|---|---|
| `9007199254740993` | `9007199254740992` | integers above 2^53 silently round: nanosecond timestamps, 18-decimal token amounts, snowflake IDs |
| map with key `1` and key `"1"` | `{"1": …}` | one entry silently disappears |
| map with key `__proto__` | no own keys, prototype changed | the value stops being data |
| ~1 MiB CBOR text | `RangeError` | large metadata cannot be decoded at all |

### The replacement candidate

`vendor/cbor2-decoder.js` is the proposed replacement, built by
`node scripts/build-decoder.mjs` from `cbor2@2.3.0` (MIT, notice retained in the
artifact). `vendor/cbor2-decoder.json` records its bytes and sha256, and the gate
verifies the file still matches them. **It is not inscribed yet**, so
`inscription_id` is `null`.

The library asks for its decoder at a fixed `/content/<id>` path — no configuration
hook is inscribed for this. To try the candidate, serve it at that path, which is
what `scripts/decoder-smoke.mjs` does.

That script decodes the vendored RFC 8949 Appendix A fixture in Chromium through
the real recursive loader: **59 of 59 decodable vectors correct**. Upstream
Appendix A has 82 entries; 23 are diagnostic-only and carry no expected value, so
they are not in the fixture and **59/59 is not full Appendix A conformance**. The
same run against the currently inscribed decoder reports 55/59 with eight problems,
so the check discriminates rather than decorates.

### Migration cost, measured

More than the broken cases change shape. The decoder smoke prints this table on
every run; these are all cases the current decoder handles *acceptably*, so they
are migration cost rather than fixes:

| CBOR | Inscribed decoder | Candidate |
|---|---|---|
| `{a:1, b:2}` string keys | plain object | plain object — unchanged |
| `{1:2, 3:4}` integer keys | plain object, `meta[1]` works | **`Map`**, `meta[1]` is `undefined` |
| tag 0 / tag 1 date | string / number | **`Date`** |
| tag 32 URI | string | **`URL`** (adds a trailing slash) |
| tags 23 / 24 | `Uint8Array` | **`Tag` wrapper** |
| integers > 2^53 | rounded number | **`BigInt`** |

So: ordinary string-keyed metadata keeps working as `meta.foo`, but a consumer
using integer keys, tags, or raw byte strings must be updated. That is the real
cost of the swap, and it is why the decision is a migration rather than a drop-in.



## Tests and the release gate

`test/` runs on Node's built-in runner with no dependencies:
Expand All @@ -87,21 +174,28 @@ node --test test/ordjs.test.mjs

Before inscribing, run the full gate. It runs the tests, minifies with a pinned
configuration, re-runs the tests against the **minified** artifact, enforces a byte
budget against the size of the live inscription, drives a real Chromium that loads
the library through `/content/<id>` exactly as an inscription does, and prints the
byte counts, hash and dependency inscription IDs a release record needs:
budget against the size of the live inscription, drives Chromium, Firefox and
WebKit through the real `/content/<id>` recursive load, checks the decoder
candidate against the RFC 8949 fixture and against its own manifest, computes the
commit/reveal fee from the serialised transaction, and prints the byte counts, hash
and dependency inscription IDs a release record needs:

```sh
bun install # playwright, dev-only
npx playwright install chromium
node scripts/gate.mjs # --no-browser skips only the Chromium stage
bun install # playwright, dev-only
npx playwright install chromium firefox webkit
node scripts/gate.mjs # --no-browser drops every browser stage
```

`--no-browser` skips all three engines **and** the decoder conformance stage, so a
run with that flag is not release evidence.

CI runs the same gate on every push and pull request.

The gate does not cover, and a release still requires: a real ord server with the
sat index both enabled and disabled, Firefox and WebKit, and a commit/reveal fee
dry-run at the chosen rate.
sat index both enabled and disabled, and a wallet dry-run of the actual
commit/reveal transaction. The fee stage serialises the envelope, tapscript and
witness exactly, but it assumes a standard commit shape (1 P2TR input, 2 P2TR
outputs) rather than your wallet's real input selection.

Tests, examples, tooling and this README are repository files only; inscribing the
library uses a minified `src/content/OrdJS.js` and nothing else.
Expand Down
127 changes: 73 additions & 54 deletions scripts/browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@
// recursive script load, the real fetch/btoa/document, and the decoder inscription
// being pulled in lazily by a second <script> tag.
//
// Usage: node scripts/browser-smoke.mjs [path-to-library.js]
// Usage: node scripts/browser-smoke.mjs [path-to-library.js] [--engine chromium|firefox|webkit|all]

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import * as playwright from 'playwright';

const LIB_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaai0';
const DECODER_ID = 'a9f6a9b050af3de1a4ce714978c1f2231ba731f1f46731a16d0e411f89308566i0';
const IMAGE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbi0';
const EMPTY_SAT = 100;
const FULL_SAT = 200;

const libPath = process.argv[2] ?? fileURLToPath(new URL('../src/content/OrdJS.js', import.meta.url));
const argv = process.argv.slice(2);
const engineFlag = argv.includes('--engine') ? argv[argv.indexOf('--engine') + 1] : 'chromium';
const engines = engineFlag === 'all' ? ['chromium', 'firefox', 'webkit'] : [engineFlag];
const libPath = argv.find((arg) => arg.endsWith('.js')) ?? fileURLToPath(new URL('../src/content/OrdJS.js', import.meta.url));
const library = await readFile(libPath, 'utf8');

// 1 MiB of deterministic bytes: the payload that used to blow the call stack.
Expand All @@ -40,6 +43,7 @@ const routes = new Map([
[`/r/sat/${FULL_SAT}/at/-1`, { type: 'application/json', body: Buffer.from(`{"id":"${IMAGE_ID}"}`) }],
[`/r/sat/${FULL_SAT}/at/0`, { type: 'application/json', body: Buffer.from(`{"id":"${IMAGE_ID}"}`) }],
[`/r/sat/${FULL_SAT}/0`, { type: 'application/json', body: Buffer.from('{"ids":[],"more":false,"page":0}') }],
[`/r/sat/${FULL_SAT}/at/0/content`, { type: 'image/png', body: bigContent }],
[`/r/metadata/${IMAGE_ID}`, { type: 'application/json', body: Buffer.from('"0102"') }],
]);

Expand All @@ -64,63 +68,78 @@ const server = createServer((req, res) => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;

const browser = await chromium.launch();
const failures = [];
try {
const page = await browser.newPage();
page.on('pageerror', (error) => failures.push(`page error: ${error.message}`));
await page.goto(`${origin}/`, { waitUntil: 'load' });

const loaded = await page.evaluate(() => typeof OrdJS);
if (loaded !== 'function') {
throw new Error(`library did not define OrdJS through the recursive script path (typeof ${loaded})`);
}
for (const engine of engines) {
const browser = await playwright[engine].launch();
const label = `${engine}`;
const fail = (name, detail) => failures.push(`[${label}] ${name}: ${detail}`);
try {
const page = await browser.newPage();
page.on('pageerror', (error) => fail('page error', error.message));
requested.length = 0;
await page.goto(`${origin}/`, { waitUntil: 'load' });

const loaded = await page.evaluate(() => typeof OrdJS);
if (loaded !== 'function') {
fail('recursive load', `library did not define OrdJS (typeof ${loaded})`);
continue;
}

const result = await page.evaluate(async (ids) => {
const ord = new OrdJS('');
const out = {};
out.blockheight = await ord.getBlockheight();
out.emptySat = await ord.getSatLastInscriptionContent(ids.emptySat);

const content = await ord.getSatLastInscriptionContent(ids.fullSat);
out.mime = content.mime;
const bytes = Uint8Array.from(atob(content.base64), (c) => c.charCodeAt(0));
out.length = bytes.length;
out.exact = bytes.every((b, i) => b === i % 256);

const result = await page.evaluate(async (ids) => {
const ord = new OrdJS('');
const out = {};
out.blockheight = await ord.getBlockheight();
out.emptySat = await ord.getSatLastInscriptionContent(ids.emptySat);

const content = await ord.getSatLastInscriptionContent(ids.fullSat);
out.mime = content.mime;
const bytes = Uint8Array.from(atob(content.base64), (c) => c.charCodeAt(0));
out.length = bytes.length;
out.exact = bytes.every((b, i) => b === i % 256);

out.decoderBefore = globalThis.__decoderLoads ?? 0;
const [a, b] = await Promise.all([
ord.getDecodedMetadata(ids.imageId),
ord.getDecodedMetadata(ids.imageId)
]);
out.decoded = [a.bytes, b.bytes];
out.decoderAfter = globalThis.__decoderLoads ?? 0;
out.scriptTags = document.querySelectorAll('script[src^="/content/"]').length;
return out;
}, { emptySat: EMPTY_SAT, fullSat: FULL_SAT, imageId: IMAGE_ID });

const check = (name, ok, detail) => {
if (!ok) failures.push(`${name}: ${detail}`);
};
check('blockheight', result.blockheight === 850000, `got ${result.blockheight}`);
check('empty sat resolves null', result.emptySat === null, `got ${JSON.stringify(result.emptySat)}`);
check('mime preserved', result.mime?.startsWith('image/png'), `got ${result.mime}`);
check('1 MiB length', result.length === 1024 * 1024, `got ${result.length}`);
check('1 MiB bytes exact', result.exact === true, 'content did not round-trip');
check('decoder not loaded early', result.decoderBefore === 0, `loaded ${result.decoderBefore} times before use`);
check('decoder loaded once', result.decoderAfter === 1, `loaded ${result.decoderAfter} times`);
check('decoded metadata', JSON.stringify(result.decoded) === JSON.stringify([[1, 2], [1, 2]]),
JSON.stringify(result.decoded));
check('no /content/null request', !requested.includes('/content/null'), 'library requested /content/null');
check('recursive script tags', result.scriptTags === 2, `found ${result.scriptTags}`);
} finally {
await browser.close();
server.close();
out.indexed = (await ord.getSatInscriptionContent(ids.fullSat, 0)).base64.length;

// ord's own explanation must survive to the caller, with the status attached.
out.error = await ord.getInscription('missing').then(() => null, (e) => ({ message: e.message, status: e.status }));

out.decoderBefore = globalThis.__decoderLoads ?? 0;
const [a, b] = await Promise.all([
ord.getDecodedMetadata(ids.imageId),
ord.getDecodedMetadata(ids.imageId)
]);
out.decoded = [a.bytes, b.bytes];
out.decoderAfter = globalThis.__decoderLoads ?? 0;
out.scriptTags = document.querySelectorAll('script[src^="/content/"]').length;
return out;
}, { emptySat: EMPTY_SAT, fullSat: FULL_SAT, imageId: IMAGE_ID });

const check = (name, ok, detail) => {
if (!ok) fail(name, detail);
};
check('blockheight', result.blockheight === 850000, `got ${result.blockheight}`);
check('empty sat resolves null', result.emptySat === null, `got ${JSON.stringify(result.emptySat)}`);
check('mime preserved', result.mime?.startsWith('image/png'), `got ${result.mime}`);
check('1 MiB length', result.length === 1024 * 1024, `got ${result.length}`);
check('1 MiB bytes exact', result.exact === true, 'content did not round-trip');
check('indexed sat content', result.indexed === Math.ceil((1024 * 1024) / 3) * 4, `got ${result.indexed} base64 chars`);
check('error carries status', result.error?.status === 404, `got ${JSON.stringify(result.error)}`);
check('error carries ord text', /not found/.test(result.error?.message ?? ''), `got ${result.error?.message}`);
check('decoder not loaded early', result.decoderBefore === 0, `loaded ${result.decoderBefore} times before use`);
check('decoder loaded once', result.decoderAfter === 1, `loaded ${result.decoderAfter} times`);
check('decoded metadata', JSON.stringify(result.decoded) === JSON.stringify([[1, 2], [1, 2]]),
JSON.stringify(result.decoded));
check('no /content/null request', !requested.includes('/content/null'), 'library requested /content/null');
check('recursive script tags', result.scriptTags === 2, `found ${result.scriptTags}`);
console.log(` ${engine}: ok`);
} finally {
await browser.close();
}
}
server.close();

if (failures.length > 0) {
console.error('browser smoke FAILED');
for (const failure of failures) console.error(` - ${failure}`);
process.exit(1);
}
console.log(`browser smoke ok (${libPath})`);
console.log(`browser smoke ok on ${engines.join(', ')} (${libPath})`);
87 changes: 87 additions & 0 deletions scripts/build-decoder.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Builds the CBOR decoder candidate that OrdJS would load recursively.
//
// The output is a browser classic script that defines a global `CBOR` with the
// same `decode(ArrayBuffer)` shape the current inscription exposes, so pointing
// OrdJS at it is a one-line change. It is written to vendor/ so the exact bytes
// that would be inscribed can be reviewed, served locally, and hashed — the
// artifact is the proposal, not the package name.
//
// node scripts/build-decoder.mjs
//
// Requires bun for bundling. The MIT notice of the bundled source is retained in
// the artifact: a licence notice is not optional, and it counts toward inscribed
// bytes.

import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const PACKAGE = 'cbor2';
const VERSION = '2.3.0';

const root = fileURLToPath(new URL('..', import.meta.url));
const outDir = join(root, 'vendor');
const outFile = join(outDir, 'cbor2-decoder.js');
const manifestFile = join(outDir, 'cbor2-decoder.json');

const work = mkdtempSync(join(tmpdir(), 'ordjs-decoder-'));
writeFileSync(join(work, 'package.json'), JSON.stringify({
name: 'decoder-build', private: true, dependencies: { [PACKAGE]: VERSION }
}));

const install = spawnSync('bun', ['install', '--no-save'], { cwd: work, stdio: 'inherit' });
if (install.status !== 0) throw new Error('bun install failed');

// A classic script, not a module: an inscription is loaded with <script src=...>.
// The wrapper accepts an ArrayBuffer and returns plain data, matching the decode
// contract OrdJS already calls.
writeFileSync(join(work, 'entry.js'), `import { decode } from '${PACKAGE}';
globalThis.CBOR = {
decode(buffer, options) {
return decode(new Uint8Array(buffer), options);
}
};
`);

const build = spawnSync('bun', [
'build', '--minify-syntax', '--minify-whitespace', '--target=browser',
'entry.js', '--outfile', 'decoder.js'
], { cwd: work, stdio: 'inherit' });
if (build.status !== 0) throw new Error('bun build failed');

const licence = readFileSync(join(work, 'node_modules', PACKAGE, 'LICENSE.md'), 'utf8')
.trim()
.split('\n')
.map((line) => ` * ${line}`.trimEnd())
.join('\n');

const header = `/*!
* CBOR decoder for OrdJS — bundled from ${PACKAGE}@${VERSION} (decode only).
* Exposes globalThis.CBOR.decode(ArrayBuffer).
*
${licence}
*/
`;

const artifact = header + readFileSync(join(work, 'decoder.js'), 'utf8');
mkdirSync(outDir, { recursive: true });
writeFileSync(outFile, artifact);

const bytes = Buffer.byteLength(artifact);
const manifest = {
source: `${PACKAGE}@${VERSION}`,
license: 'MIT',
homepage: 'https://github.com/hildjj/cbor2',
global: 'CBOR.decode(ArrayBuffer)',
bytes,
sha256: createHash('sha256').update(artifact).digest('hex'),
builder: `bun ${spawnSync('bun', ['--version'], { encoding: 'utf8' }).stdout?.trim()} build --minify-syntax --minify-whitespace --target=browser`,
inscription_id: null,
note: 'Not inscribed. The library loads its decoder from the fixed /content/<id> path with no configuration hook, so to try this candidate serve it at that path, as scripts/decoder-smoke.mjs does.'
};
writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');

console.log(`${outFile}\n${JSON.stringify(manifest, null, 2)}`);
Loading
Loading