Skip to content

Commit 2c1f37e

Browse files
committed
[ecash-lib] Add SLP support
Summary: Adds SLP support to ecash-lib for all the token types supported by Chronik (Fungible, Mint Vault, NFT1 Group, NFT1 Child). Mint Vault tokens require us to mine in tests, so we add the `'generate'` cmd to `SetupFramework`. Test Plan: `npm run integration-tests` Reviewers: bytesofman, #bitcoin_abc Reviewed By: bytesofman, #bitcoin_abc Differential Revision: https://reviews.bitcoinabc.org/D16081
1 parent 3dc2f5f commit 2c1f37e

File tree

7 files changed

+1415
-0
lines changed

7 files changed

+1415
-0
lines changed

modules/ecash-lib/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ export * from './io/varsize.js';
1919
export * from './io/writer.js';
2020
export * from './io/writerbytes.js';
2121
export * from './io/writerlength.js';
22+
export * from './token/alp.js';
23+
export * from './token/common.js';
24+
export * from './token/empp.js';
25+
export * from './token/slp.js';

modules/ecash-lib/src/test/testRunner.ts

+4
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ export class TestRunner {
181181
return (await this.chronik.broadcastTx(setupTx.ser())).txid;
182182
}
183183

184+
public generate() {
185+
this.runner.send('generate');
186+
}
187+
184188
public stop() {
185189
this.runner.send('stop');
186190
}

modules/ecash-lib/src/token/common.ts

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export interface GenesisInfo {
1919
tokenName?: string;
2020
/** URL of the token */
2121
url?: string;
22+
/** token_document_hash of the token (only on SLP) */
23+
hash?: string;
24+
/** mint_vault_scripthash (only on SLP V2 Mint Vault) */
25+
mintVaultScripthash?: string;
2226
/** Arbitray payload data of the token (only on ALP) */
2327
data?: Uint8Array;
2428
/** auth_pubkey of the token (only on ALP) */
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2024 The Bitcoin developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
import { expect } from 'chai';
6+
7+
import { slpBurn, slpGenesis, slpMint, slpMintVault, slpSend } from './slp.js';
8+
9+
describe('SLP', () => {
10+
it('SLP invalid usage', () => {
11+
expect(() => slpGenesis(99, {}, 0)).to.throw('Unknown token type 99');
12+
expect(() => slpMint('', 77, 0)).to.throw('Unknown token type 77');
13+
expect(() => slpMint('', 1, 0)).to.throw(
14+
'Token ID must be 64 hex characters in length, but got 0',
15+
);
16+
expect(() => slpMint('1'.repeat(64), 1, -1)).to.throw(
17+
'Amount out of range: -1',
18+
);
19+
expect(() => slpMint('1'.repeat(64), 1, 0x10000000000000000n)).to.throw(
20+
'Amount out of range: 18446744073709551616',
21+
);
22+
expect(() => slpMintVault('', [])).to.throw(
23+
'Token ID must be 64 hex characters in length, but got 0',
24+
);
25+
expect(() => slpMintVault('1'.repeat(64), [])).to.throw(
26+
'Send amount cannot be empty',
27+
);
28+
expect(() => slpMintVault('1'.repeat(64), new Array(20))).to.throw(
29+
'Cannot use more than 19 amounts, but got 20',
30+
);
31+
expect(() => slpMintVault('1'.repeat(64), [-1])).to.throw(
32+
'Amount out of range: -1',
33+
);
34+
expect(() =>
35+
slpMintVault('1'.repeat(64), [0x10000000000000000n]),
36+
).to.throw('Amount out of range: 18446744073709551616');
37+
expect(() => slpSend('', 66, [])).to.throw('Unknown token type 66');
38+
expect(() => slpSend('', 1, [])).to.throw(
39+
'Token ID must be 64 hex characters in length, but got 0',
40+
);
41+
expect(() => slpSend('1'.repeat(64), 1, [])).to.throw(
42+
'Send amount cannot be empty',
43+
);
44+
expect(() => slpSend('1'.repeat(64), 1, new Array(20))).to.throw(
45+
'Cannot use more than 19 amounts, but got 20',
46+
);
47+
expect(() => slpSend('1'.repeat(64), 1, [-1])).to.throw(
48+
'Amount out of range: -1',
49+
);
50+
expect(() =>
51+
slpSend('1'.repeat(64), 1, [0x10000000000000000n]),
52+
).to.throw('Amount out of range: 18446744073709551616');
53+
expect(() => slpBurn('', 55, 0)).to.throw('Unknown token type 55');
54+
expect(() => slpBurn('', 1, 0)).to.throw(
55+
'Token ID must be 64 hex characters in length, but got 0',
56+
);
57+
expect(() => slpBurn('1'.repeat(64), 1, -1)).to.throw(
58+
'Amount out of range: -1',
59+
);
60+
expect(() => slpBurn('1'.repeat(64), 1, 0x10000000000000000n)).to.throw(
61+
'Amount out of range: 18446744073709551616',
62+
);
63+
});
64+
});

modules/ecash-lib/src/token/slp.ts

+212
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) 2024 The Bitcoin developers
2+
// Distributed under the MIT software license, see the accompanying
3+
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
4+
5+
import { fromHex } from '../io/hex.js';
6+
import { strToBytes } from '../io/str.js';
7+
import { Op, pushBytesOp } from '../op.js';
8+
import { OP_PUSHDATA1, OP_RETURN } from '../opcode.js';
9+
import { Script } from '../script.js';
10+
import { Amount, BURN, GENESIS, GenesisInfo, MINT, SEND } from './common.js';
11+
12+
/** LOKAD ID for SLP */
13+
export const SLP_LOKAD_ID = strToBytes('SLP\0');
14+
15+
/** SLP fungible token type number */
16+
export const SLP_FUNGIBLE = 1;
17+
/** SLP MINT Vault token type number */
18+
export const SLP_MINT_VAULT = 2;
19+
/** SLP NFT1 Child token type number */
20+
export const SLP_NFT1_CHILD = 0x41;
21+
/** SLP NFT1 Group token type number */
22+
export const SLP_NFT1_GROUP = 0x81;
23+
24+
/** Build an SLP GENESIS OP_RETURN, creating a new SLP token */
25+
export function slpGenesis(
26+
tokenType: number,
27+
genesisInfo: GenesisInfo,
28+
initialQuantity: Amount,
29+
mintBatonOutIdx?: number,
30+
): Script {
31+
verifyTokenType(tokenType);
32+
const data: Uint8Array[] = [];
33+
data.push(SLP_LOKAD_ID);
34+
data.push(new Uint8Array([tokenType]));
35+
data.push(GENESIS);
36+
data.push(strToBytes(genesisInfo.tokenTicker ?? ''));
37+
data.push(strToBytes(genesisInfo.tokenName ?? ''));
38+
data.push(strToBytes(genesisInfo.url ?? ''));
39+
data.push(genesisInfo.hash ? fromHex(genesisInfo.hash) : new Uint8Array());
40+
data.push(new Uint8Array([genesisInfo.decimals ?? 0]));
41+
if (tokenType == SLP_MINT_VAULT) {
42+
if (genesisInfo.mintVaultScripthash === undefined) {
43+
throw new Error('Must set mintVaultScripthash for MINT VAULT');
44+
}
45+
data.push(fromHex(genesisInfo.mintVaultScripthash));
46+
} else {
47+
if (mintBatonOutIdx !== undefined) {
48+
if (mintBatonOutIdx < 2) {
49+
throw new Error('mintBatonOutIdx must be >= 2');
50+
}
51+
data.push(new Uint8Array([mintBatonOutIdx]));
52+
} else {
53+
data.push(new Uint8Array());
54+
}
55+
}
56+
data.push(slpAmount(initialQuantity));
57+
return Script.fromOps([OP_RETURN as Op].concat(data.map(pushdataOpSlp)));
58+
}
59+
60+
/**
61+
* Build an SLP MINT pushdata section, creating new SLP tokens and mint batons
62+
* of the given token ID.
63+
**/
64+
export function slpMint(
65+
tokenId: string,
66+
tokenType: number,
67+
additionalQuantity: Amount,
68+
mintBatonOutIdx?: number,
69+
): Script {
70+
verifyTokenType(tokenType);
71+
verifyTokenId(tokenId);
72+
return Script.fromOps([
73+
OP_RETURN,
74+
pushdataOpSlp(SLP_LOKAD_ID),
75+
pushdataOpSlp(new Uint8Array([tokenType])),
76+
pushdataOpSlp(MINT),
77+
pushdataOpSlp(fromHex(tokenId)),
78+
pushdataOpSlp(
79+
new Uint8Array(
80+
mintBatonOutIdx !== undefined ? [mintBatonOutIdx] : [],
81+
),
82+
),
83+
pushdataOpSlp(slpAmount(additionalQuantity)),
84+
]);
85+
}
86+
87+
/**
88+
* Build an SLP MINT VAULT pushdata section, creating new SLP tokens and mint batons
89+
* of the given token ID.
90+
**/
91+
export function slpMintVault(
92+
tokenId: string,
93+
additionalQuantities: Amount[],
94+
): Script {
95+
verifyTokenId(tokenId);
96+
verifySendAmounts(additionalQuantities);
97+
return Script.fromOps(
98+
[
99+
OP_RETURN,
100+
pushdataOpSlp(SLP_LOKAD_ID),
101+
pushdataOpSlp(new Uint8Array([SLP_MINT_VAULT])),
102+
pushdataOpSlp(MINT),
103+
pushdataOpSlp(fromHex(tokenId)),
104+
].concat(
105+
additionalQuantities.map(qty => pushdataOpSlp(slpAmount(qty))),
106+
),
107+
);
108+
}
109+
110+
/**
111+
* Build an SLP SEND pushdata section, moving SLP tokens to different outputs
112+
**/
113+
export function slpSend(
114+
tokenId: string,
115+
tokenType: number,
116+
sendAmounts: Amount[],
117+
): Script {
118+
verifyTokenType(tokenType);
119+
verifyTokenId(tokenId);
120+
verifySendAmounts(sendAmounts);
121+
return Script.fromOps(
122+
[
123+
OP_RETURN,
124+
pushdataOpSlp(SLP_LOKAD_ID),
125+
pushdataOpSlp(new Uint8Array([tokenType])),
126+
pushdataOpSlp(SEND),
127+
pushdataOpSlp(fromHex(tokenId)),
128+
].concat(sendAmounts.map(qty => pushdataOpSlp(slpAmount(qty)))),
129+
);
130+
}
131+
132+
/**
133+
* Build an SLP BURN pushdata section, intentionally burning SLP tokens.
134+
* See https://github.com/badger-cash/slp-self-mint-protocol/blob/master/token-type1-burn.md
135+
**/
136+
export function slpBurn(
137+
tokenId: string,
138+
tokenType: number,
139+
burnAmount: Amount,
140+
): Script {
141+
verifyTokenType(tokenType);
142+
verifyTokenId(tokenId);
143+
return Script.fromOps([
144+
OP_RETURN,
145+
pushdataOpSlp(SLP_LOKAD_ID),
146+
pushdataOpSlp(new Uint8Array([tokenType])),
147+
pushdataOpSlp(BURN),
148+
pushdataOpSlp(fromHex(tokenId)),
149+
pushdataOpSlp(slpAmount(burnAmount)),
150+
]);
151+
}
152+
153+
function verifyTokenType(tokenType: number) {
154+
switch (tokenType) {
155+
case SLP_FUNGIBLE:
156+
case SLP_MINT_VAULT:
157+
case SLP_NFT1_GROUP:
158+
case SLP_NFT1_CHILD:
159+
return;
160+
default:
161+
throw new Error(`Unknown token type ${tokenType}`);
162+
}
163+
}
164+
165+
function verifyTokenId(tokenId: string) {
166+
if (tokenId.length != 64) {
167+
throw new Error(
168+
`Token ID must be 64 hex characters in length, but got ${tokenId.length}`,
169+
);
170+
}
171+
}
172+
173+
function verifySendAmounts(sendAmounts: Amount[]) {
174+
if (sendAmounts.length == 0) {
175+
throw new Error('Send amount cannot be empty');
176+
}
177+
if (sendAmounts.length > 19) {
178+
throw new Error(
179+
`Cannot use more than 19 amounts, but got ${sendAmounts.length}`,
180+
);
181+
}
182+
}
183+
184+
function pushdataOpSlp(pushdata: Uint8Array): Op {
185+
if (pushdata.length == 0) {
186+
return {
187+
opcode: OP_PUSHDATA1,
188+
data: pushdata,
189+
};
190+
}
191+
if (pushdata.length < OP_PUSHDATA1) {
192+
return {
193+
opcode: pushdata.length,
194+
data: pushdata,
195+
};
196+
}
197+
return pushBytesOp(pushdata);
198+
}
199+
200+
function slpAmount(amount: Amount): Uint8Array {
201+
if (amount < 0 || BigInt(amount) > 0xffffffffffffffffn) {
202+
throw new Error(`Amount out of range: ${amount}`);
203+
}
204+
const amountBytes = new Uint8Array(8);
205+
const view = new DataView(
206+
amountBytes.buffer,
207+
amountBytes.byteOffset,
208+
amountBytes.byteLength,
209+
);
210+
view.setBigUint64(0, BigInt(amount), /*little endian=*/ false);
211+
return amountBytes;
212+
}

0 commit comments

Comments
 (0)