diff --git a/package-lock.json b/package-lock.json index e6d30f3..cb77fc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@sniptt/monads": "^0.5.10", "bip86": "^0.0.3", "bitcoinjs-lib": "^6.1.5", "ecpair": "^2.1.0", @@ -1287,6 +1288,15 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sniptt/monads": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@sniptt/monads/-/monads-0.5.10.tgz", + "integrity": "sha512-+agDOv9DpDV+9e2zN/Vmdk+XaqGx5Sykl0fqhqgiJ90r18nsBkxe44DmZ2sA1HYK+MSsBeZBiAr6pq4w+5uhfw==", + "engines": { + "node": ">=14", + "npm": ">=7" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7046,6 +7056,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "@sniptt/monads": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@sniptt/monads/-/monads-0.5.10.tgz", + "integrity": "sha512-+agDOv9DpDV+9e2zN/Vmdk+XaqGx5Sykl0fqhqgiJ90r18nsBkxe44DmZ2sA1HYK+MSsBeZBiAr6pq4w+5uhfw==" + }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 2b97827..d253bf0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typescript": "^5.3.3" }, "dependencies": { + "@sniptt/monads": "^0.5.10", "bip86": "^0.0.3", "bitcoinjs-lib": "^6.1.5", "ecpair": "^2.1.0", diff --git a/src/constants.ts b/src/constants.ts index 9123467..0065232 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,3 +4,4 @@ export const MAX_DIVISIBILITY = 38; export const MAX_LIMIT = u128(1n << 64n); export const RESERVED = u128(6402364363415443603228541259936211926n); export const SUBSIDY_HALVING_INTERVAL = 210_000; +export const MAX_SCRIPT_ELEMENT_SIZE = 520; diff --git a/src/etching.ts b/src/etching.ts index 0261d7d..1070aba 100644 --- a/src/etching.ts +++ b/src/etching.ts @@ -1,10 +1,22 @@ +import { None, Option, Some } from '@sniptt/monads'; import { Mint } from './mint'; import { Rune } from './rune'; -export type Etching = { - divisibility: number; - mint?: Mint; - rune?: Rune; - spacers: number; - symbol?: string; -}; +export class Etching { + readonly symbol: Option; + + constructor( + readonly divisibility: number, + readonly rune: Option, + readonly spacers: number, + symbol: Option, + readonly mint: Option + ) { + this.symbol = symbol.andThen((value) => { + const codePoint = value.codePointAt(0); + return codePoint !== undefined + ? Some(String.fromCodePoint(codePoint)) + : None; + }); + } +} diff --git a/src/flag.ts b/src/flag.ts new file mode 100644 index 0000000..fef659e --- /dev/null +++ b/src/flag.ts @@ -0,0 +1,23 @@ +import { u128 } from './u128'; + +export enum Flag { + ETCH = 0, + MINT = 1, + BURN = 127, +} + +export namespace Flag { + export function mask(flag: Flag): u128 { + return u128(1n << BigInt(flag)); + } + + export function take(flags: u128, flag: Flag): { set: boolean; flags: u128 } { + const mask = Flag.mask(flag); + const set = (flags & mask) !== 0n; + return { set, flags: u128(flags - (set ? mask : 0n)) }; + } + + export function set(flags: u128, flag: Flag): u128 { + return u128(flags | Flag.mask(flag)); + } +} diff --git a/src/mint.ts b/src/mint.ts index 4437abd..60df86b 100644 --- a/src/mint.ts +++ b/src/mint.ts @@ -1,7 +1,8 @@ +import { Option } from '@sniptt/monads'; import { u128 } from './u128'; export type Mint = { - deadline?: number; - limit?: u128; - term?: number; + deadline: Option; + limit: Option; + term: Option; }; diff --git a/src/runeid.ts b/src/runeid.ts index 8f28754..4bf7d01 100644 --- a/src/runeid.ts +++ b/src/runeid.ts @@ -4,7 +4,7 @@ export class RuneId { constructor(readonly height: number, readonly index: number) {} toU128() { - return u128((this.height << 16) | this.index); + return u128((BigInt(this.height) << 16n) | BigInt(this.index)); } toString() { diff --git a/src/runestone.ts b/src/runestone.ts new file mode 100644 index 0000000..6b87b1b --- /dev/null +++ b/src/runestone.ts @@ -0,0 +1,310 @@ +import { + MAX_DIVISIBILITY, + MAX_LIMIT, + MAX_SCRIPT_ELEMENT_SIZE, +} from './constants'; +import { Edict } from './edict'; +import { Etching } from './etching'; +import { SeekBuffer } from './seekbuffer'; +import { Tag } from './tag'; +import { u128 } from './u128'; +import * as bitcoin from 'bitcoinjs-lib'; +import _ from 'lodash'; +import { Option, Some, None, isSome } from '@sniptt/monads'; +import { Rune } from './rune'; +import { Flag } from './flag'; +import { Instruction, decompileScriptAllBuffer } from './utils'; + +export const MAX_SPACERS = 0b00000111_11111111_11111111_11111111; + +export class Runestone { + constructor( + readonly burn: boolean, + readonly claim: Option, + readonly defaultOutput: Option, + readonly edicts: Edict[], + readonly etching: Option + ) {} + + static fromTransaction(transaction: bitcoin.Transaction): Option { + try { + return Runestone.decipher(transaction); + } catch (e) { + return None; + } + } + + static decipher(transaction: bitcoin.Transaction): Option { + const payload = Runestone.payload(transaction); + if (payload.isNone()) { + return None; + } + + const integers = Runestone.integers(payload.unwrap()); + + const { fields, edicts } = Message.fromIntegers(integers); + + const claim = Tag.take(fields, Tag.CLAIM); + + const deadline = Tag.take(fields, Tag.DEADLINE).andThen((value) => + value <= 0xffff_ffffn ? Some(Number(value)) : None + ); + + const defaultOutput = Tag.take(fields, Tag.DEFAULT_OUTPUT).andThen( + (value) => (value <= 0xffff_ffffn ? Some(Number(value)) : None) + ); + + const divisibility = Tag.take(fields, Tag.DIVISIBILITY) + .andThen((value) => (value < 0xffn ? Some(Number(value)) : None)) + .andThen((value) => (value <= MAX_DIVISIBILITY ? Some(value) : None)) + .unwrapOr(0); + + const limit = Tag.take(fields, Tag.LIMIT).map((value) => + value >= MAX_LIMIT ? MAX_LIMIT : value + ); + + const rune = Tag.take(fields, Tag.RUNE).map((value) => new Rune(value)); + + const spacers = Tag.take(fields, Tag.SPACERS) + .andThen((value) => (value < 0xffn ? Some(Number(value)) : None)) + .andThen((value) => (value <= MAX_SPACERS ? Some(value) : None)) + .unwrapOr(0); + + const symbol = Tag.take(fields, Tag.SYMBOL) + .andThen((value) => + value <= 0xffff_ffffn ? Some(Number(value)) : None + ) + .andThen((value) => { + try { + return Some(String.fromCodePoint(value)); + } catch (e) { + return None; + } + }); + + const term = Tag.take(fields, Tag.TERM).andThen((value) => + value <= 0xffff_ffffn ? Some(Number(value)) : None + ); + + let flags = Tag.take(fields, Tag.FLAGS).unwrapOr(u128(0)); + + const etchResult = Flag.take(flags, Flag.ETCH); + const etch = etchResult.set; + flags = etchResult.flags; + + const mintResult = Flag.take(flags, Flag.MINT); + const mint = mintResult.set; + flags = mintResult.flags; + + let etching: Option = etch + ? Some( + new Etching( + divisibility, + rune, + spacers, + symbol, + mint + ? Some({ + deadline, + limit, + term, + }) + : None + ) + ) + : None; + + return Some( + new Runestone( + flags !== 0n || + [...fields.keys()].find((tag) => tag % 2n === 0n) !== undefined, + claim, + defaultOutput, + edicts, + etching + ) + ); + } + + encipher(): Buffer { + const payloads: Buffer[] = []; + + if (this.etching.isSome()) { + const etching = this.etching.unwrap(); + let flags = u128(0); + flags = Flag.set(flags, Flag.ETCH); + + if (etching.mint.isSome()) { + flags = Flag.set(flags, Flag.MINT); + } + + payloads.push(Tag.encode(Tag.FLAGS, flags)); + + if (etching.rune.isSome()) { + const rune = etching.rune.unwrap(); + payloads.push(Tag.encode(Tag.RUNE, rune.value)); + } + + if (etching.divisibility !== 0) { + payloads.push(Tag.encode(Tag.DIVISIBILITY, u128(etching.divisibility))); + } + + if (etching.spacers !== 0) { + payloads.push(Tag.encode(Tag.SPACERS, u128(etching.spacers))); + } + + if (etching.symbol.isSome()) { + const symbol = etching.symbol.unwrap(); + payloads.push(Tag.encode(Tag.SYMBOL, u128(symbol.codePointAt(0)!))); + } + + if (etching.mint.isSome()) { + const mint = etching.mint.unwrap(); + + if (mint.deadline.isSome()) { + const deadline = mint.deadline.unwrap(); + payloads.push(Tag.encode(Tag.DEADLINE, u128(deadline))); + } + + if (mint.limit.isSome()) { + const limit = mint.limit.unwrap(); + payloads.push(Tag.encode(Tag.LIMIT, limit)); + } + + if (mint.term.isSome()) { + const term = mint.term.unwrap(); + payloads.push(Tag.encode(Tag.TERM, u128(term))); + } + } + } + + if (this.claim.isSome()) { + const claim = this.claim.unwrap(); + payloads.push(Tag.encode(Tag.CLAIM, claim)); + } + + if (this.defaultOutput.isSome()) { + const defaultOutput = this.defaultOutput.unwrap(); + payloads.push(Tag.encode(Tag.DEFAULT_OUTPUT, u128(defaultOutput))); + } + + if (this.burn) { + payloads.push(Tag.encode(Tag.BURN, u128(0))); + } + + if (this.edicts.length) { + payloads.push(u128.encodeVarInt(u128(Tag.BODY))); + + const edicts = _.sortBy(this.edicts, (edict) => edict.id); + + let id = u128(0); + for (const edict of edicts) { + payloads.push(u128.encodeVarInt(u128(edict.id - id))); + payloads.push(u128.encodeVarInt(edict.amount)); + payloads.push(u128.encodeVarInt(edict.output)); + id = edict.id; + } + } + + const stack: bitcoin.Stack = []; + stack.push(bitcoin.opcodes.OP_RETURN); + stack.push(Buffer.from('RUNE_TEST')); + + const payload = Buffer.concat(payloads); + let i = 0; + for (let i = 0; i < payload.length; i += MAX_SCRIPT_ELEMENT_SIZE) { + stack.push(payload.subarray(i, i + MAX_SCRIPT_ELEMENT_SIZE)); + } + + return bitcoin.script.compile(stack); + } + + static payload(transaction: bitcoin.Transaction): Option { + for (const output of transaction.outs) { + const instructions = decompileScriptAllBuffer(output.script); + if (instructions === null) { + throw new Error('unable to decompile'); + } + + let nextInstruction: Instruction | undefined; + + nextInstruction = instructions.shift(); + if (nextInstruction !== bitcoin.opcodes.OP_RETURN) { + continue; + } + + nextInstruction = instructions.shift(); + if ( + !nextInstruction || + Instruction.isNumber(nextInstruction) || + Buffer.compare(nextInstruction, Buffer.from('RUNE_TEST')) !== 0 + ) { + continue; + } + + let payloads: Buffer[] = []; + + for (const result of instructions) { + if (Instruction.isBuffer(result)) { + payloads.push(result); + } + } + + return Some(Buffer.concat(payloads)); + } + + return None; + } + + static integers(payload: Buffer): u128[] { + const integers: u128[] = []; + + const seekBuffer = new SeekBuffer(payload); + while (!seekBuffer.isFinished()) { + integers.push(u128.readVarInt(seekBuffer)); + } + + return integers; + } +} + +export class Message { + constructor(readonly fields: Map, readonly edicts: Edict[]) {} + + static fromIntegers(payload: u128[]): Message { + const edicts: Edict[] = []; + const fields = new Map(); + + for (const i of _.range(0, payload.length, 2)) { + const tag = payload[i]; + + if (u128(Tag.BODY) === tag) { + let id = u128(0); + for (const chunk of _.chunk(payload.slice(i + 1), 3)) { + if (chunk.length !== 3) { + break; + } + + id = u128.saturatingAdd(id, chunk[0]); + edicts.push({ + id, + amount: chunk[1], + output: chunk[2], + }); + } + break; + } + + const value = payload[i + 1]; + if (value === undefined) { + break; + } + + if (!fields.has(tag)) { + fields.set(tag, value); + } + } + + return new Message(fields, edicts); + } +} diff --git a/src/tag.ts b/src/tag.ts index 0e25a9a..8c6d196 100644 --- a/src/tag.ts +++ b/src/tag.ts @@ -1,3 +1,4 @@ +import { None, Option, Some } from '@sniptt/monads'; import { u128 } from './u128'; export enum Tag { @@ -18,11 +19,11 @@ export enum Tag { } export namespace Tag { - export function take(fields: Map, tag: Tag): u128 | undefined { + export function take(fields: Map, tag: Tag): Option { const key = u128(tag); const value = fields.get(key); fields.delete(key); - return value; + return value ? Some(value) : None; } export function encode(tag: Tag, value: u128): Buffer { diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..555ba7a --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,59 @@ +import * as bitcoin from 'bitcoinjs-lib'; + +export type Instruction = number | Buffer; + +export namespace Instruction { + export function isNumber(instruction: Instruction): instruction is number { + return typeof instruction === 'number'; + } + + export function isBuffer(instruction: Instruction): instruction is Buffer { + return typeof instruction !== 'number'; + } +} + +export function decompileScriptAllBuffer(script: Buffer) { + const instructions = bitcoin.script.decompile(script); + if (instructions === null) { + return null; + } + + const result: Instruction[] = []; + for (const instruction of instructions) { + if (Instruction.isNumber(instruction)) { + switch (instruction) { + case bitcoin.opcodes.OP_0: + result.push(Buffer.alloc(0)); + break; + case bitcoin.opcodes.OP_1: + case bitcoin.opcodes.OP_2: + case bitcoin.opcodes.OP_3: + case bitcoin.opcodes.OP_4: + case bitcoin.opcodes.OP_5: + case bitcoin.opcodes.OP_6: + case bitcoin.opcodes.OP_7: + case bitcoin.opcodes.OP_8: + case bitcoin.opcodes.OP_9: + case bitcoin.opcodes.OP_10: + case bitcoin.opcodes.OP_11: + case bitcoin.opcodes.OP_12: + case bitcoin.opcodes.OP_13: + case bitcoin.opcodes.OP_14: + case bitcoin.opcodes.OP_15: + case bitcoin.opcodes.OP_16: + result.push(Buffer.from([instruction - bitcoin.opcodes.OP_1 + 1])); + break; + case bitcoin.opcodes.OP_1NEGATE: + result.push(Buffer.from([0x80])); + break; + default: + result.push(instruction); + break; + } + } else { + result.push(instruction); + } + } + + return result; +} diff --git a/test/flag.test.ts b/test/flag.test.ts new file mode 100644 index 0000000..d8a33b5 --- /dev/null +++ b/test/flag.test.ts @@ -0,0 +1,31 @@ +import { Flag } from '../src/flag'; +import { u128 } from '../src/u128'; + +describe('flag', () => { + test('mask', () => { + expect(Flag.mask(Flag.ETCH)).toBe(0b1n); + expect(Flag.mask(Flag.BURN)).toBe(1n << 127n); + }); + + test('take', () => { + { + const flags = u128(1); + const { set, flags: updatedFlags } = Flag.take(flags, Flag.ETCH); + expect(set).toBe(true); + expect(updatedFlags).toBe(0n); + } + + { + const flags = u128(0); + const { set, flags: updatedFlags } = Flag.take(flags, Flag.ETCH); + expect(set).toBe(false); + expect(updatedFlags).toBe(0n); + } + }); + + test('set', () => { + const flags = u128(0); + const updatedFlags = Flag.set(flags, Flag.ETCH); + expect(updatedFlags).toBe(1n); + }); +}); diff --git a/test/runestone.test.ts b/test/runestone.test.ts new file mode 100644 index 0000000..c5a4826 --- /dev/null +++ b/test/runestone.test.ts @@ -0,0 +1,1078 @@ +import * as bitcoin from 'bitcoinjs-lib'; +import _ from 'lodash'; +import { MAX_SPACERS, Runestone } from '../src/runestone'; +import { u128 } from '../src/u128'; +import { None, Option, Some } from '@sniptt/monads'; +import { Tag } from '../src/tag'; +import { Flag } from '../src/flag'; +import { MAX_DIVISIBILITY } from '../src/constants'; +import { Rune } from '../src/rune'; +import { SpacedRune } from '../src/spacedrune'; +import { decompileScriptAllBuffer } from '../src/utils'; +import { Edict } from '../src/edict'; +import { Etching } from '../src/etching'; +import { RuneId } from '../src/runeid'; +import { Mint } from '../src/mint'; + +describe('runestone', () => { + function decipher(integers: u128[]): Runestone { + return Runestone.decipher( + getSimpleTransaction([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + getPayload(integers), + ]) + ).unwrap(); + } + + function getPayload(integers: u128[]) { + const payloads: Buffer[] = []; + + for (const integer of integers) { + payloads.push(u128.encodeVarInt(integer)); + } + + return Buffer.concat(payloads); + } + + function getSimpleTransaction(stack: bitcoin.Stack): bitcoin.Transaction { + const transaction = new bitcoin.Transaction(); + transaction.addOutput(bitcoin.script.compile(stack), 0); + return transaction; + } + + test('from_transaction_returns_none_if_decipher_returns_error', () => { + expect( + Runestone.fromTransaction( + getSimpleTransaction([bitcoin.opcodes.OP_PUSHBYTES_4]) + ).isNone() + ).toBe(true); + }); + + test('deciphering_transaction_with_no_outputs_returns_none', () => { + expect(Runestone.decipher(new bitcoin.Transaction()).isNone()).toBe(true); + }); + + test('deciphering_transaction_with_non_op_return_output_returns_none', () => { + expect( + Runestone.decipher(getSimpleTransaction([Buffer.alloc(0)])).isNone() + ).toBe(true); + }); + + test('deciphering_transaction_with_bare_op_return_returns_none', () => { + expect( + Runestone.decipher( + getSimpleTransaction([bitcoin.opcodes.OP_RETURN]) + ).isNone() + ).toBe(true); + }); + + test('deciphering_transaction_with_non_matching_op_return_returns_none', () => { + expect( + Runestone.decipher( + getSimpleTransaction([bitcoin.opcodes.OP_RETURN, Buffer.from('FOOO')]) + ).isNone() + ).toBe(true); + }); + + test('deciphering_valid_runestone_with_invalid_script_returns_script_error', () => { + expect( + Runestone.decipher( + getSimpleTransaction([bitcoin.opcodes.OP_PUSHBYTES_4]) + ).isNone() + ).toBe(true); + }); + + test('deciphering_valid_runestone_with_invalid_script_postfix_returns_script_error', () => { + const transaction = getSimpleTransaction([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + ]); + + transaction.outs[0].script = Buffer.concat([ + transaction.outs[0].script, + Buffer.from([4]), + ]); + + expect(() => Runestone.decipher(transaction)).toThrow(); + }); + + test('deciphering_runestone_with_truncated_varint_succeeds', () => { + expect( + Runestone.decipher( + getSimpleTransaction([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + Buffer.from([128]), + ]) + ).isSome() + ).toBe(true); + }); + + test('non_push_opcodes_in_runestone_are_ignored', () => { + expect( + Runestone.decipher( + getSimpleTransaction([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + Buffer.from([0, 1]), + bitcoin.opcodes.OP_VERIFY, + Buffer.from([2, 3]), + ]) + ).unwrap() + ).toMatchObject({ + edicts: [ + { + id: u128(1), + amount: u128(2), + output: u128(3), + }, + ], + }); + }); + + test('deciphering_empty_runestone_is_successful', () => { + expect( + Runestone.decipher( + getSimpleTransaction([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + ]) + ).isSome() + ).toBe(true); + }); + + test('error_in_input_aborts_search_for_runestone', () => { + const payload = getPayload([0, 1, 2, 3].map(u128)); + + const transaction = new bitcoin.Transaction(); + let scriptPubKey = bitcoin.script.compile([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + 4, + ]); + scriptPubKey = Buffer.concat([scriptPubKey, Buffer.from([4])]); + transaction.addOutput(bitcoin.script.compile(scriptPubKey), 0); + transaction.addOutput( + bitcoin.script.compile([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + payload, + ]), + 0 + ); + + expect(() => Runestone.decipher(transaction)).toThrow(); + }); + + test('deciphering_non_empty_runestone_is_successful', () => { + expect(decipher([Tag.BODY, 1, 2, 3].map(u128))).toMatchObject({ + edicts: [{ id: 1n, amount: 2n, output: 3n }], + }); + }); + + test('decipher_etching', () => { + const runestone = decipher( + [Tag.FLAGS, Flag.mask(Flag.ETCH), Tag.BODY, 1, 2, 3].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.isNone()).toBe(true); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + expect(etching.mint.isNone()).toBe(true); + }); + + test('decipher_etching_with_rune', () => { + const runestone = decipher( + [Tag.FLAGS, Flag.mask(Flag.ETCH), Tag.RUNE, 4, Tag.BODY, 1, 2, 3].map( + u128 + ) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + expect(etching.mint.isNone()).toBe(true); + }); + + test('etch_flag_is_required_to_etch_rune_even_if_mint_is_set', () => { + const runestone = decipher( + [Tag.FLAGS, Flag.mask(Flag.MINT), Tag.TERM, 4, Tag.BODY, 1, 2, 3].map( + u128 + ) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + expect(runestone.etching.isNone()).toBe(true); + }); + + test('decipher_etching_with_term', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH) | Flag.mask(Flag.MINT), + Tag.TERM, + 4, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.isNone()).toBe(true); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + + const mint = etching.mint.unwrap(); + expect(mint.term.unwrap()).toBe(4); + expect(mint.deadline.isNone()).toBe(true); + expect(mint.limit.isNone()).toBe(true); + }); + + test('decipher_etching_with_limit', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH) | Flag.mask(Flag.MINT), + Tag.LIMIT, + 4, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.isNone()).toBe(true); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + + const mint = etching.mint.unwrap(); + expect(mint.term.isNone()).toBe(true); + expect(mint.deadline.isNone()).toBe(true); + expect(mint.limit.unwrap()).toBe(4n); + }); + + test('duplicate_tags_are_ignored', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH), + Tag.RUNE, + 4, + Tag.RUNE, + 5, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + expect(etching.mint.isNone()).toBe(true); + }); + + test('unrecognized_odd_tag_is_ignored', () => { + const runestone = decipher([Tag.NOP, 100, Tag.BODY, 1, 2, 3].map(u128)); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + }); + + test('unrecognized_even_tag_is_burn', () => { + const runestone = decipher([Tag.BURN, 0, Tag.BODY, 1, 2, 3].map(u128)); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + expect(runestone.burn).toBe(true); + }); + + test('unrecognized_flag_is_burn', () => { + const runestone = decipher( + [Tag.FLAGS, Flag.mask(Flag.BURN), Tag.BODY, 1, 2, 3].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + expect(runestone.burn).toBe(true); + }); + + test('tag_with_no_value_is_ignored', () => { + const runestone = decipher([Tag.FLAGS, 1, Tag.BODY, Tag.FLAGS].map(u128)); + + expect(runestone.etching.isSome()).toBe(true); + }); + + test('additional_integers_in_body_are_ignored', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH), + Tag.RUNE, + 4, + Tag.BODY, + 1, + 2, + 3, + 4, + 5, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + expect(etching.mint.isNone()).toBe(true); + }); + + test('decipher_etching_with_divisibility', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH), + Tag.RUNE, + 4, + Tag.DIVISIBILITY, + 5, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(5); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + expect(etching.mint.isNone()).toBe(true); + }); + + test('divisibility_above_max_is_ignored', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH), + Tag.RUNE, + 4, + Tag.DIVISIBILITY, + MAX_DIVISIBILITY + 1, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + expect(etching.mint.isNone()).toBe(true); + }); + + test('symbol_above_max_is_ignored', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH), + Tag.RUNE, + 4, + Tag.SYMBOL, + 0x110000, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + expect(etching.mint.isNone()).toBe(true); + }); + + test('decipher_etching_with_symbol', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH), + Tag.RUNE, + 4, + Tag.SYMBOL, + 97, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(0); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(0); + expect(etching.symbol.unwrap()).toBe('a'); + expect(etching.mint.isNone()).toBe(true); + }); + + test('decipher_etching_with_all_etching_tags', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH) | Flag.mask(Flag.MINT), + Tag.RUNE, + 4, + Tag.DEADLINE, + 7, + Tag.DIVISIBILITY, + 1, + Tag.SPACERS, + 5, + Tag.SYMBOL, + 97, + Tag.TERM, + 2, + Tag.LIMIT, + 3, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(1); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(5); + expect(etching.symbol.unwrap()).toBe('a'); + + const mint = etching.mint.unwrap(); + expect(mint.deadline.unwrap()).toBe(7); + expect(mint.limit.unwrap()).toBe(3n); + expect(mint.term.unwrap()).toBe(2); + }); + + test('recognized_even_etching_fields_in_non_etching_are_ignored', () => { + const runestone = decipher( + [ + Tag.RUNE, + 4, + Tag.DIVISIBILITY, + 1, + Tag.SYMBOL, + 97, + Tag.TERM, + 2, + Tag.LIMIT, + 3, + Tag.BODY, + 1, + 2, + 3, + 4, + 5, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + expect(runestone.etching.isNone()).toBe(true); + }); + + test('decipher_etching_with_divisibility_and_symbol', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH), + Tag.RUNE, + 4, + Tag.DIVISIBILITY, + 1, + Tag.SYMBOL, + 97, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(1); + expect(etching.rune.unwrap().value).toBe(4n); + expect(etching.spacers).toBe(0); + expect(etching.symbol.unwrap()).toBe('a'); + }); + + test('tag_values_are_not_parsed_as_tags', () => { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH), + Tag.DIVISIBILITY, + Tag.BODY, + Tag.BODY, + 1, + 2, + 3, + ].map(u128) + ); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + expect(runestone.etching.isSome()).toBe(true); + }); + + test('runestone_may_contain_multiple_edicts', () => { + const runestone = decipher([Tag.BODY, 1, 2, 3, 3, 5, 6].map(u128)); + + expect(runestone.edicts).toEqual([ + { id: 1n, amount: 2n, output: 3n }, + { id: 4n, amount: 5n, output: 6n }, + ]); + }); + + test('id_deltas_saturate_to_max', () => { + const runestone = decipher([Tag.BODY, 1, 2, 3, u128.MAX, 5, 6].map(u128)); + + expect(runestone.edicts).toEqual([ + { id: 1n, amount: 2n, output: 3n }, + { id: u128.MAX, amount: 5n, output: 6n }, + ]); + }); + + test('payload_pushes_are_concatenated', () => { + const runestone = Runestone.decipher( + getSimpleTransaction([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + u128.encodeVarInt(u128(Tag.FLAGS)), + u128.encodeVarInt(Flag.mask(Flag.ETCH)), + u128.encodeVarInt(u128(Tag.DIVISIBILITY)), + u128.encodeVarInt(u128(5)), + u128.encodeVarInt(u128(Tag.BODY)), + u128.encodeVarInt(u128(1)), + u128.encodeVarInt(u128(2)), + u128.encodeVarInt(u128(3)), + ]) + ).unwrap(); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + + const etching = runestone.etching.unwrap(); + expect(etching.divisibility).toBe(5); + expect(etching.rune.isNone()).toBe(true); + expect(etching.spacers).toBe(0); + expect(etching.symbol.isNone()).toBe(true); + }); + + test('runestone_may_be_in_second_output', () => { + const payload = getPayload([0, 1, 2, 3].map(u128)); + + const transaction = new bitcoin.Transaction(); + + transaction.addOutput(Buffer.alloc(0), 0); + transaction.addOutput( + bitcoin.script.compile([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + payload, + ]), + 0 + ); + + const runestone = Runestone.decipher(transaction).unwrap(); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + }); + + test('runestone_may_be_after_non_matching_op_return', () => { + const payload = getPayload([0, 1, 2, 3].map(u128)); + + const transaction = new bitcoin.Transaction(); + + transaction.addOutput( + bitcoin.script.compile([bitcoin.opcodes.OP_RETURN, Buffer.from('FOO')]), + 0 + ); + transaction.addOutput( + bitcoin.script.compile([ + bitcoin.opcodes.OP_RETURN, + Buffer.from('RUNE_TEST'), + payload, + ]), + 0 + ); + + const runestone = Runestone.decipher(transaction).unwrap(); + + expect(runestone.edicts).toEqual([{ id: 1n, amount: 2n, output: 3n }]); + }); + + test('runestone_size', () => { + function testcase(edicts: Edict[], etching: Option, size: number) { + expect( + new Runestone(false, None, None, edicts, etching).encipher().length - + 1 - + 'RUNE_TEST'.length + ).toBe(size); + } + + testcase([], None, 1); + + testcase( + [], + Some(new Etching(0, Some(new Rune(u128(0))), 0, None, None)), + 6 + ); + + testcase( + [], + Some( + new Etching(MAX_DIVISIBILITY, Some(new Rune(u128(0))), 0, None, None) + ), + 8 + ); + + testcase( + [], + Some( + new Etching( + MAX_DIVISIBILITY, + Some(new Rune(u128(0))), + 1, + Some('$'), + Some({ + deadline: Some(10000), + limit: Some(u128(1)), + term: Some(1), + }) + ) + ), + 19 + ); + + testcase( + [], + Some(new Etching(0, Some(new Rune(u128.MAX)), 0, None, None)), + 24 + ); + + testcase( + [ + { + amount: u128(0), + id: new RuneId(0, 0).toU128(), + output: u128(0), + }, + ], + Some( + new Etching(MAX_DIVISIBILITY, Some(new Rune(u128.MAX)), 0, None, None) + ), + 30 + ); + + testcase( + [ + { + amount: u128.MAX, + id: new RuneId(0, 0).toU128(), + output: u128(0), + }, + ], + Some( + new Etching(MAX_DIVISIBILITY, Some(new Rune(u128.MAX)), 0, None, None) + ), + 48 + ); + + testcase( + [ + { + amount: u128(0), + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + }, + ], + None, + 11 + ); + + testcase( + [ + { + amount: u128.MAX, + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + }, + ], + None, + 29 + ); + + testcase( + [ + { + amount: u128.MAX, + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + }, + { + amount: u128.MAX, + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + }, + ], + None, + 50 + ); + + testcase( + [ + { + amount: u128.MAX, + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + }, + { + amount: u128.MAX, + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + }, + { + amount: u128.MAX, + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + }, + ], + None, + 71 + ); + + testcase( + _.range(4).map(() => ({ + amount: u128(0xffff_ffff_ffff_ffffn), + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + })), + None, + 56 + ); + + testcase( + _.range(5).map(() => ({ + amount: u128(0xffff_ffff_ffff_ffffn), + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + })), + None, + 68 + ); + + testcase( + _.range(5).map(() => ({ + amount: u128(0xffff_ffff_ffff_ffffn), + id: new RuneId(0, 0xffff).toU128(), + output: u128(0), + })), + None, + 65 + ); + + testcase( + _.range(5).map(() => ({ + amount: u128(1_000_000_000_000_000_000n), + id: new RuneId(1_000_000, 0xffff).toU128(), + output: u128(0), + })), + None, + 63 + ); + }); + + // TODO: update unit test in ord + test('etching_with_term_greater_than_maximum_is_ignored', () => { + { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH) | Flag.mask(Flag.MINT), + Tag.TERM, + 0xffff_ffffn, + ].map(u128) + ); + + const etching = runestone.etching.unwrap(); + const mint = etching.mint.unwrap(); + expect(mint.term.unwrap()).toBe(0xffff_ffff); + } + + { + const runestone = decipher( + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH) | Flag.mask(Flag.MINT), + Tag.TERM, + 0xffff_ffffn + 1n, + ].map(u128) + ); + + const etching = runestone.etching.unwrap(); + const mint = etching.mint.unwrap(); + expect(mint.term.isNone()).toBe(true); + } + }); + + test('encipher', () => { + function testcase(runestone: Runestone, expected: (number | bigint)[]) { + const scriptPubKey = runestone.encipher(); + + const transaction = new bitcoin.Transaction(); + transaction.addOutput(scriptPubKey, 0); + + const payload = Runestone.payload(transaction).unwrap(); + + expect(Runestone.integers(payload)).toEqual(expected.map(u128)); + + const txnRunestone = Runestone.fromTransaction(transaction).unwrap(); + + expect(txnRunestone.burn).toBe(runestone.burn); + expect(txnRunestone.claim.isSome()).toBe(runestone.claim.isSome()); + if (txnRunestone.claim.isSome()) { + expect(txnRunestone.claim.unwrap()).toBe(runestone.claim.unwrap()); + } + + expect(txnRunestone.defaultOutput.isSome()).toBe( + runestone.defaultOutput.isSome() + ); + if (txnRunestone.defaultOutput.isSome()) { + expect(txnRunestone.defaultOutput.unwrap()).toBe( + runestone.defaultOutput.unwrap() + ); + } + + expect(_.sortBy(txnRunestone.edicts, (edict) => edict.id)).toEqual( + _.sortBy(runestone.edicts, (edict) => edict.id) + ); + + expect(txnRunestone.etching.isSome()).toBe(runestone.etching.isSome()); + if (txnRunestone.etching.isSome()) { + const txnEtching = txnRunestone.etching.unwrap(); + const etching = runestone.etching.unwrap(); + + expect(txnEtching.divisibility).toBe(etching.divisibility); + expect(txnEtching.mint.isSome()).toBe(etching.mint.isSome()); + if (txnEtching.mint.isSome()) { + const txnMint = txnEtching.mint.unwrap(); + const mint = etching.mint.unwrap(); + + expect(txnMint.deadline.isSome()).toBe(mint.deadline.isSome()); + if (txnMint.deadline.isSome()) { + expect(txnMint.deadline.unwrap()).toBe(mint.deadline.unwrap()); + } + + expect(txnMint.limit.isSome()).toBe(mint.limit.isSome()); + if (txnMint.limit.isSome()) { + expect(txnMint.limit.unwrap()).toBe(mint.limit.unwrap()); + } + + expect(txnMint.term.isSome()).toBe(mint.term.isSome()); + if (txnMint.term.isSome()) { + expect(txnMint.term.unwrap()).toBe(mint.term.unwrap()); + } + } + + expect( + txnEtching.rune.map((value) => value.toString()).unwrapOr('') + ).toBe(etching.rune.map((value) => value.toString()).unwrapOr('')); + expect(txnEtching.spacers).toBe(etching.spacers); + expect(txnEtching.symbol.unwrapOr('')).toBe( + etching.symbol.unwrapOr('') + ); + } + } + + testcase(new Runestone(false, None, None, [], None), []); + + testcase( + new Runestone( + true, + Some(u128(12)), + Some(11), + [ + { + amount: u128(8), + id: u128(9), + output: u128(10), + }, + { + amount: u128(5), + id: u128(6), + output: u128(7), + }, + ], + Some( + new Etching( + 1, + Some(new Rune(u128(4))), + 6, + Some('@'), + Some({ + deadline: Some(2), + limit: Some(u128(3)), + term: Some(5), + }) + ) + ) + ), + [ + Tag.FLAGS, + Flag.mask(Flag.ETCH) | Flag.mask(Flag.MINT), + Tag.RUNE, + 4, + Tag.DIVISIBILITY, + 1, + Tag.SPACERS, + 6, + Tag.SYMBOL, + '@'.codePointAt(0)!, + Tag.DEADLINE, + 2, + Tag.LIMIT, + 3, + Tag.TERM, + 5, + Tag.CLAIM, + 12, + Tag.DEFAULT_OUTPUT, + 11, + Tag.BURN, + 0, + Tag.BODY, + 6, + 5, + 7, + 3, + 8, + 10, + ] + ); + + testcase( + new Runestone( + false, + None, + None, + [], + Some(new Etching(0, Some(new Rune(u128(3))), 0, None, None)) + ), + [Tag.FLAGS, Flag.mask(Flag.ETCH), Tag.RUNE, 3] + ); + + testcase( + new Runestone( + false, + None, + None, + [], + Some(new Etching(0, None, 0, None, None)) + ), + [Tag.FLAGS, Flag.mask(Flag.ETCH)] + ); + + testcase(new Runestone(true, None, None, [], None), [Tag.BURN, 0]); + }); + + test('runestone_payload_is_chunked', () => { + { + const script = new Runestone( + false, + None, + None, + _.range(173).map((i) => ({ + id: u128(0), + amount: u128(0), + output: u128(0), + })), + None + ).encipher(); + + const instructions = decompileScriptAllBuffer(script); + expect(instructions?.length).toBe(3); + } + + { + const script = new Runestone( + false, + None, + None, + _.range(174).map((i) => ({ + id: u128(0), + amount: u128(0), + output: u128(0), + })), + None + ).encipher(); + + const instructions = decompileScriptAllBuffer(script); + expect(instructions?.length).toBe(4); + } + }); + + test('max_spacers', () => { + let rune = ''; + + const maxRune = new Rune(u128.MAX).toString(); + for (const i of _.range(maxRune.length)) { + if (i > 0) { + rune += '•'; + } + + rune += maxRune.charAt(i); + } + + expect(SpacedRune.fromString(rune).spacers).toBe(MAX_SPACERS); + }); +}); diff --git a/test/tag.test.ts b/test/tag.test.ts index 493b034..ebbd7bd 100644 --- a/test/tag.test.ts +++ b/test/tag.test.ts @@ -11,9 +11,9 @@ describe('tag', () => { const fields = new Map(); fields.set(u128(2), u128(3)); - expect(Tag.take(fields, Tag.FLAGS)).toBe(3n); + expect(Tag.take(fields, Tag.FLAGS).unwrap()).toBe(3n); expect(fields.size).toBe(0); - expect(Tag.take(fields, Tag.FLAGS)).toBeUndefined(); + expect(Tag.take(fields, Tag.FLAGS).isNone()).toBe(true); }); test('encode', () => {