From 050a0a25405edeb5bf93697ef3570aee6934024a Mon Sep 17 00:00:00 2001 From: Pierre Gee Date: Mon, 16 Jan 2023 17:59:27 +0800 Subject: [PATCH] feat(jellyfish-transaction): add burn token in dftx (#1929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What this PR does / why we need it: - [x] Add CTokenBurn in jellyfish transaction to be used in bridge - [x] Create txn builder for burntoken - [x] Address PR feedbacks #### Which issue(s) does this PR fixes?: Fixes #1823 #### Additional comments?: - Big thanks to @canonbrother for the help! 🙏 💪 Co-authored-by: Isaac Yong --- apps/playground-api/src/setups/setup.gov.ts | 2 +- packages/jellyfish-testing/src/token.ts | 13 + .../txn/txn_builder_token_burn_token.test.ts | 245 ++++++++++++++++++ .../src/index.ts | 3 + .../src/txn/txn_builder_token.ts | 20 ++ .../script/dftx/dftx_token/TokenBurn.test.ts | 128 +++++++++ .../src/script/dftx/dftx.ts | 4 + .../src/script/dftx/dftx_token.ts | 56 ++++ .../src/script/mapping.ts | 10 + 9 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 packages/jellyfish-transaction-builder/__tests__/txn/txn_builder_token_burn_token.test.ts create mode 100644 packages/jellyfish-transaction-builder/src/txn/txn_builder_token.ts create mode 100644 packages/jellyfish-transaction/__tests__/script/dftx/dftx_token/TokenBurn.test.ts diff --git a/apps/playground-api/src/setups/setup.gov.ts b/apps/playground-api/src/setups/setup.gov.ts index c60e320030..25b5341d8b 100644 --- a/apps/playground-api/src/setups/setup.gov.ts +++ b/apps/playground-api/src/setups/setup.gov.ts @@ -68,7 +68,7 @@ export class SetupGov extends PlaygroundSetup> { 'v0/consortium/1/mint_limit': '50', 'v0/consortium/1/mint_limit_daily': '5', - // // Set a consortium member for dBTC + // Set a consortium member for dBTC 'v0/consortium/1/members': { '01': { name: 'Waves HQ', diff --git a/packages/jellyfish-testing/src/token.ts b/packages/jellyfish-testing/src/token.ts index 05b1958e72..9cbfc205cd 100644 --- a/packages/jellyfish-testing/src/token.ts +++ b/packages/jellyfish-testing/src/token.ts @@ -48,6 +48,12 @@ export class TestingToken { const tokenInfo = await this.rpc.token.getToken(symbol) return Object.keys(tokenInfo)[0] } + + async burn (options: TestingTokenBurn): Promise { + const { amount, symbol, from, context } = options + const account = `${new BigNumber(amount).toFixed(8)}@${symbol}` + return await this.rpc.token.burnTokens(account, from, context) + } } interface TestingTokenCreate { @@ -74,3 +80,10 @@ interface TestingTokenSend { amount: number | string symbol: string } + +interface TestingTokenBurn { + amount: number | string + symbol: string + from: string + context?: string +} diff --git a/packages/jellyfish-transaction-builder/__tests__/txn/txn_builder_token_burn_token.test.ts b/packages/jellyfish-transaction-builder/__tests__/txn/txn_builder_token_burn_token.test.ts new file mode 100644 index 0000000000..d59a18f54e --- /dev/null +++ b/packages/jellyfish-transaction-builder/__tests__/txn/txn_builder_token_burn_token.test.ts @@ -0,0 +1,245 @@ +import { DeFiDRpcError, MasterNodeRegTestContainer } from '@defichain/testcontainers' +import { getProviders, MockProviders } from '../provider.mock' +import { P2WPKHTransactionBuilder } from '../../src' +import { fundEllipticPair, sendTransaction } from '../test.utils' +import BigNumber from 'bignumber.js' +import { Testing } from '@defichain/jellyfish-testing' +import { RegTest } from '@defichain/jellyfish-network' +import { P2WPKH } from '@defichain/jellyfish-address' +import { OP_CODES, TokenBurn } from '@defichain/jellyfish-transaction' +import { Bech32 } from '@defichain/jellyfish-crypto' + +const attributeKey = 'ATTRIBUTES' +const symbolDBTC = 'BTC' + +const container = new MasterNodeRegTestContainer() +let providers: MockProviders +let builder: P2WPKHTransactionBuilder + +const testing = Testing.create(container) + +let wavesConsortiumAddress: string +let idBTC: string + +async function setupGovs (): Promise { + await testing.rpc.masternode.setGov({ + [attributeKey]: + { + // Enable consortium + 'v0/params/feature/consortium': 'true', + + // Set a consortium global limit for dBTC + [`v0/consortium/${idBTC}/mint_limit`]: '50', + [`v0/consortium/${idBTC}/mint_limit_daily`]: '5', + + // Set a consortium member for dBTC + [`v0/consortium/${idBTC}/members`]: { + '01': { + name: 'Waves HQ', + ownerAddress: wavesConsortiumAddress, + backingId: 'backing_address_btc_1_c', + mintLimitDaily: '5.00000000', + mintLimit: '50.00000000' + } + } + } + }) +} + +describe('burnToken', () => { + beforeEach(async () => { + await testing.container.start() + await testing.container.waitForWalletCoinbaseMaturity() + wavesConsortiumAddress = await testing.generateAddress() + + await testing.token.dfi({ address: wavesConsortiumAddress, amount: 12 }) + await testing.generate(1) + await testing.token.create({ symbol: 'BTC', collateralAddress: wavesConsortiumAddress }) + await testing.generate(1) + + await testing.token.mint({ symbol: 'BTC', amount: 100 }) + await testing.generate(1) + + idBTC = await testing.token.getTokenId(symbolDBTC) + providers = await getProviders(container) + + await setupGovs() + + // Fund 10 DFI UTXO + await fundEllipticPair(testing.container, providers.ellipticPair, 100) + await providers.setupMocks() + + builder = new P2WPKHTransactionBuilder(providers.fee, providers.prevout, providers.elliptic, RegTest) + }) + + afterEach(async () => { + await testing.container.stop() + }) + + it('should reject if the amount is negative', async () => { + const script = await providers.elliptic.script() + const promise = builder.tokens.burn({ + amounts: [{ token: Number(idBTC), amount: new BigNumber(-2) }], + burnType: 0, + from: script, + variantContext: { + variant: 0, + context: { + stack: [] + } + } + }, script) + + await expect(promise).rejects.toThrow('The value of "value" is out of range. It must be >= 0 and <= 4294967295. Received -200000000') + }) + + it('should throw an error if not enough tokens are available to burn', async () => { + const script = await providers.elliptic.script() + const txn = await builder.tokens.burn({ + amounts: [{ token: Number(idBTC), amount: new BigNumber(15) }], + burnType: 0, + from: script, + variantContext: { + variant: 0, + context: { + stack: [] + } + } + }, script) + + // Ensure the created txn is correct + const promise = sendTransaction(testing.container, txn) + + await expect(promise).rejects.toThrow(DeFiDRpcError) + await expect(promise).rejects.toThrow("DeFiDRpcError: 'BurnTokenTx: amount 0.00000000 is less than 15.00000000 (code 16)', code: -26") + }) + + it('should burnToken without context', async () => { + // Fund 100 BTC TOKEN + await testing.token.send({ + address: await providers.getAddress(), + amount: 100, + symbol: 'BTC' + }) + await testing.generate(1) + + const script = await providers.elliptic.script() + const tokenBurn: TokenBurn = { + // Burn 15 BTC + amounts: [{ token: Number(idBTC), amount: new BigNumber(15) }], + burnType: 0, + from: script, + variantContext: { + variant: 0, + context: script + } + } + + const txn = await builder.tokens.burn(tokenBurn, script) + + // Ensure the created txn is correct + const outs = await sendTransaction(testing.container, txn) + const encoded: string = OP_CODES.OP_DEFI_TX_TOKEN_BURN(tokenBurn).asBuffer().toString('hex') + const pubKey = await providers.ellipticPair.publicKey() + const address = Bech32.fromPubKey(pubKey, 'bcrt') + expect(outs).toStrictEqual([{ + n: 0, + scriptPubKey: { + asm: expect.stringMatching(/^OP_RETURN 4466547846/), + hex: `6a${encoded}`, + type: 'nulldata' + }, + tokenId: 0, + value: 0 + }, { + n: 1, + scriptPubKey: { + addresses: [address], + asm: expect.any(String), + hex: expect.any(String), + reqSigs: 1, + type: 'witness_v0_keyhash' + }, + tokenId: 0, + value: 99.9999904 + }]) + + await testing.generate(1) + + const attributes = await testing.rpc.masternode.getGov(attributeKey) + const burntKeyRegex = /^v0\/live\/economy\/consortium_members\/\d+\/\d+\/burnt$/ + const keys: string[] = Object.keys(attributes.ATTRIBUTES) + + // Verify that the burn action is not tied to any consortium member + expect(keys.every(key => burntKeyRegex.exec(key) === null)).toStrictEqual(true) + + // Verify the token balance is deducted correctly (100 - 15 = 85 BTC) + const accAfter = await testing.rpc.account.getAccount(await providers.getAddress()) + expect(accAfter).toStrictEqual(['85.00000000@BTC']) + }) + + it('should burnToken with context', async () => { + // Fund 100 BTC TOKEN + await testing.token.send({ + address: await providers.getAddress(), + amount: 100, + symbol: 'BTC' + }) + await testing.generate(1) + + const script = await providers.elliptic.script() + const wavesColScript = P2WPKH.fromAddress(RegTest, wavesConsortiumAddress, P2WPKH).getScript() + const tokenBurn: TokenBurn = { + // Burn 30 BTC + amounts: [{ token: Number(idBTC), amount: new BigNumber(30) }], + burnType: 0, + from: script, + variantContext: { + variant: 0, + context: wavesColScript + } + } + const txn = await builder.tokens.burn(tokenBurn, script) + + // Ensure the created txn is correct + const outs = await sendTransaction(testing.container, txn) + const encoded: string = OP_CODES.OP_DEFI_TX_TOKEN_BURN(tokenBurn).asBuffer().toString('hex') + const pubKey = await providers.ellipticPair.publicKey() + const address = Bech32.fromPubKey(pubKey, 'bcrt') + + expect(outs).toStrictEqual([{ + n: 0, + scriptPubKey: { + asm: expect.stringMatching(/^OP_RETURN 4466547846/), + hex: `6a${encoded}`, + type: 'nulldata' + }, + tokenId: 0, + value: 0 + }, { + n: 1, + scriptPubKey: { + addresses: [address], + asm: expect.any(String), + hex: expect.any(String), + reqSigs: 1, + type: 'witness_v0_keyhash' + }, + tokenId: 0, + value: 99.9999904 + }]) + await testing.generate(1) + + const attributes = await testing.rpc.masternode.getGov(attributeKey) + const burntKeyRegex = /^v0\/live\/economy\/consortium_members\/\d+\/\d+\/burnt$/ + const keys: string[] = Object.keys(attributes.ATTRIBUTES) + + // Verify that the burn action is tied to the existing consortium member + expect(keys.some(key => burntKeyRegex.exec(key) === null)).toStrictEqual(true) + expect(attributes.ATTRIBUTES[`v0/live/economy/consortium_members/${idBTC}/01/burnt`]).toStrictEqual(new BigNumber(30)) + + // Verify the token balance is deducted correctly (100 - 30 = 70 BTC) + const accAfter = await testing.rpc.account.getAccount(await providers.getAddress()) + expect(accAfter).toStrictEqual(['70.00000000@BTC']) + }) +}) diff --git a/packages/jellyfish-transaction-builder/src/index.ts b/packages/jellyfish-transaction-builder/src/index.ts index dff1ae261b..0f499c4a07 100644 --- a/packages/jellyfish-transaction-builder/src/index.ts +++ b/packages/jellyfish-transaction-builder/src/index.ts @@ -9,6 +9,7 @@ import { TxnBuilderICXOrderBook } from './txn/txn_builder_icxorderbook' import { TxnBuilderMasternode } from './txn/txn_builder_masternode' import { TxnBuilderLoans } from './txn/txn_builder_loans' import { TxnBuilderVault } from './txn/txn_builder_vault' +import { TxnBuilderTokens } from './txn/txn_builder_token' export * from './provider' export * from './txn/txn_fee' @@ -22,6 +23,7 @@ export * from './txn/txn_builder_vault' export * from './txn/txn_builder_liq_pool' export * from './txn/txn_builder_icxorderbook' export * from './txn/txn_builder_masternode' +export * from './txn/txn_builder_token' /** * All in one transaction builder. @@ -38,4 +40,5 @@ export class P2WPKHTransactionBuilder extends P2WPKHTxnBuilder { public readonly masternode = new TxnBuilderMasternode(this.feeProvider, this.prevoutProvider, this.ellipticPairProvider, this.network) public readonly loans = new TxnBuilderLoans(this.feeProvider, this.prevoutProvider, this.ellipticPairProvider, this.network) public readonly vault = new TxnBuilderVault(this.feeProvider, this.prevoutProvider, this.ellipticPairProvider, this.network) + public readonly tokens = new TxnBuilderTokens(this.feeProvider, this.prevoutProvider, this.ellipticPairProvider, this.network) } diff --git a/packages/jellyfish-transaction-builder/src/txn/txn_builder_token.ts b/packages/jellyfish-transaction-builder/src/txn/txn_builder_token.ts new file mode 100644 index 0000000000..3afdf87b95 --- /dev/null +++ b/packages/jellyfish-transaction-builder/src/txn/txn_builder_token.ts @@ -0,0 +1,20 @@ +import { + OP_CODES, Script, TransactionSegWit, TokenBurn +} from '@defichain/jellyfish-transaction' +import { P2WPKHTxnBuilder } from './txn_builder' + +export class TxnBuilderTokens extends P2WPKHTxnBuilder { + /** + * Burn tokens + * + * @param {TokenBurn} tokenBurn txn to create + * @param {Script} changeScript to send unspent to after deducting the (converted + fees) + * @returns {Promise} + */ + async burn (tokenBurn: TokenBurn, changeScript: Script): Promise { + return await super.createDeFiTx( + OP_CODES.OP_DEFI_TX_TOKEN_BURN(tokenBurn), + changeScript + ) + } +} diff --git a/packages/jellyfish-transaction/__tests__/script/dftx/dftx_token/TokenBurn.test.ts b/packages/jellyfish-transaction/__tests__/script/dftx/dftx_token/TokenBurn.test.ts new file mode 100644 index 0000000000..ee262a5752 --- /dev/null +++ b/packages/jellyfish-transaction/__tests__/script/dftx/dftx_token/TokenBurn.test.ts @@ -0,0 +1,128 @@ +import { SmartBuffer } from 'smart-buffer' +import { OP_DEFI_TX } from '../../../../src/script/dftx' +import { CTokenBurn, TokenBurn } from '../../../../src/script/dftx/dftx_token' +import { OP_CODES } from '../../../../src/script' +import { toBuffer, toOPCodes } from '../../../../src/script/_buffer' +import BigNumber from 'bignumber.js' + +it('should bi-directional buffer-object-buffer', () => { + const fixtures = [ + /** + * TokenBurn : { + amounts: [{'token': 1, 'amount': new BigNumber(1)}], + from: "bcrt1q9t4j95j0whjd4n40frg4csdk73kxuwmygjal4q", + context: "bcrt1q9t4j95j0whjd4n40frg4csdk73kxuwmygjal4q" + } + */ + '6a454466547846010100000000e1f505000000001600142aeb22d24f75e4daceaf48d15c41b6f46c6e3b6400000000001600142aeb22d24f75e4daceaf48d15c41b6f46c6e3b64', + /** + * TokenBurn : { + amounts: [{'token': 1, 'amount': new BigNumber(9)}], + from: "bcrt1qpqphr5kca5urfcqyslk2jyqyh4ljgvp89s6lhn", + context: "bcrt1qpqphr5kca5urfcqyslk2jyqyh4ljgvp89s6lhn" + } + */ + '6a454466547846010100000000e9a43500000000160014080371d2d8ed3834e00487eca91004bd7f2430270000000000160014080371d2d8ed3834e00487eca91004bd7f243027', + /** + * TokenBurn : { + amounts: [{'token': 1, 'amount': new BigNumber(5)}], + from: "bcrt1qeefwuhumyrvsjup0w9xu25kwys3rxlva6pqha0" + } + */ + '6a2f446654784601010000000065cd1d00000000160014ce52ee5f9b20d909702f714dc552ce2422337d9d000000000000' + ] + + fixtures.forEach(hex => { + const stack = toOPCodes( + SmartBuffer.fromBuffer(Buffer.from(hex, 'hex')) + ) + const buffer = toBuffer(stack) + expect(buffer.toString('hex')).toStrictEqual(hex) + expect((stack[1] as OP_DEFI_TX).tx.type).toStrictEqual(0x46) + }) +}) +const tokenBurnData: Array<{ header: string, data: string, tokenBurn: TokenBurn }> = [ + { + // data without context + header: '6a2f4466547846', // OP_RETURN(0x6a) (length 47 = 0x2f) CDfTx.SIGNATURE(0x44665478) CTokenBurn.OP_CODE(0x46) + // TokenBurn.amounts(0x01010000000065cd1d00000000) + // TokenBurn.from[LE](0x160014850e5938570fa2752353e211ab3d880b3ebfe58b) + // TokenBurn.BurnType(0x00) + // TokenBurn.variantContext(0x0000000000) + data: '01010000000065cd1d00000000160014850e5938570fa2752353e211ab3d880b3ebfe58b000000000000', + tokenBurn: { + amounts: [{ token: 1, amount: new BigNumber(5) }], + from: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA_HEX_LE('850e5938570fa2752353e211ab3d880b3ebfe58b') + ] + }, + burnType: 0, + variantContext: { + variant: 0, + context: { + stack: [] + } + } + } + }, + { + // data with context + header: '6a454466547846', // OP_RETURN(0x6a) (length 69 = 0x45) CDfTx.SIGNATURE(0x44665478) CTokenBurn.OP_CODE(0x46) + // TokenBurn.amounts(0x010100000000e1f50500000000) + // TokenBurn.from[LE](0x160014ad54d71e8681e0c990349070cbd17a5c567a9b9e) + // TokenBurn.BurnType(0x00) + // TokenBurn.variantContext(0x00000000160014ad54d71e8681e0c990349070cbd17a5c567a9b9e) + data: '010100000000e1f50500000000160014ad54d71e8681e0c990349070cbd17a5c567a9b9e0000000000160014ad54d71e8681e0c990349070cbd17a5c567a9b9e', + tokenBurn: { + amounts: [{ token: 1, amount: new BigNumber(1) }], + from: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA_HEX_LE('ad54d71e8681e0c990349070cbd17a5c567a9b9e') + ] + }, + burnType: 0, + variantContext: { + variant: 0, + context: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA_HEX_LE('ad54d71e8681e0c990349070cbd17a5c567a9b9e') + ] + } + } + } + } +] + +describe.each(tokenBurnData)('should craft and compose dftx', + ({ header, tokenBurn, data }: { header: string, data: string, tokenBurn: TokenBurn }) => { + it('should craft dftx with OP_CODES._() for burning tokens', () => { + const stack = [ + OP_CODES.OP_RETURN, + OP_CODES.OP_DEFI_TX_TOKEN_BURN(tokenBurn) + ] + + const buffer = toBuffer(stack) + expect(buffer.toString('hex')).toStrictEqual(header + data) + }) + + describe('Composable', () => { + it('should compose from buffer to composable', () => { + const buffer = SmartBuffer.fromBuffer(Buffer.from(data, 'hex')) + const composable = new CTokenBurn(buffer) + + expect(composable.toObject()).toStrictEqual(tokenBurn) + }) + + it('should compose from composable to buffer', () => { + const composable = new CTokenBurn(tokenBurn) + const buffer = new SmartBuffer() + composable.toBuffer(buffer) + + expect(buffer.toBuffer().toString('hex')).toStrictEqual(data) + }) + }) + }) diff --git a/packages/jellyfish-transaction/src/script/dftx/dftx.ts b/packages/jellyfish-transaction/src/script/dftx/dftx.ts index 7e22943170..4c001f197f 100644 --- a/packages/jellyfish-transaction/src/script/dftx/dftx.ts +++ b/packages/jellyfish-transaction/src/script/dftx/dftx.ts @@ -36,10 +36,12 @@ import { PoolUpdatePair } from './dftx_pool' import { + CTokenBurn, CTokenCreate, CTokenMint, CTokenUpdate, CTokenUpdateAny, + TokenBurn, TokenCreate, TokenMint, TokenUpdate, @@ -204,6 +206,8 @@ export class CDfTx extends ComposableBuffer> { return compose(CPoolUpdatePair.OP_NAME, d => new CPoolUpdatePair(d)) case CTokenMint.OP_CODE: return compose(CTokenMint.OP_NAME, d => new CTokenMint(d)) + case CTokenBurn.OP_CODE: + return compose(CTokenBurn.OP_NAME, d => new CTokenBurn(d)) case CTokenCreate.OP_CODE: return compose(CTokenCreate.OP_NAME, d => new CTokenCreate(d)) case CTokenUpdate.OP_CODE: diff --git a/packages/jellyfish-transaction/src/script/dftx/dftx_token.ts b/packages/jellyfish-transaction/src/script/dftx/dftx_token.ts index d4b51a5a2f..44f2309ab4 100644 --- a/packages/jellyfish-transaction/src/script/dftx/dftx_token.ts +++ b/packages/jellyfish-transaction/src/script/dftx/dftx_token.ts @@ -1,5 +1,7 @@ import { BufferComposer, ComposableBuffer } from '@defichain/jellyfish-buffer' import { CTokenBalance, TokenBalanceUInt32 } from './dftx_balance' +import { Script } from '../../tx' +import { CScript } from '../../tx_composer' import BigNumber from 'bignumber.js' /** @@ -117,3 +119,57 @@ export class CTokenUpdateAny extends ComposableBuffer { ] } } + +/** + * Known as "std::variant" in cpp. + */ +export type VariantType = 0 + +interface VariantScript { + variant: VariantType // -----| 1 byte + context: Script // ----------| VarUInt{1-9 bytes}, + n bytes +} + +/** + * Known as "std::variant" in cpp. + * + * Composable VariantScript, C stands for Composable. + * Immutable by design, bi-directional fromBuffer, toBuffer deep composer. + */ +class CVariantScript extends ComposableBuffer { + composers (tb: VariantScript): BufferComposer[] { + return [ + ComposableBuffer.uInt32(() => tb.variant, v => tb.variant = v as VariantType), + ComposableBuffer.single