diff --git a/apps/token-server/package-lock.json b/apps/token-server/package-lock.json --- a/apps/token-server/package-lock.json +++ b/apps/token-server/package-lock.json @@ -22,6 +22,7 @@ "multer": "^1.4.5-lts.1", "node-telegram-bot-api": "^0.65.1", "sharp": "^0.33.2", + "slp-mdm": "^0.0.7", "tiny-secp256k1": "^2.2.3" }, "devDependencies": { @@ -1937,6 +1938,14 @@ "node": ">=0.6" } }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -6212,6 +6221,14 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/slp-mdm": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/slp-mdm/-/slp-mdm-0.0.7.tgz", + "integrity": "sha512-XlnDS7y8fnEt9I5lw/GqcrDjTGw5vZon83xlliLyihz2EvjE135ubIBzq4bmjVOrM669G9A6bkqEp5Y1trPV3A==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8535,6 +8552,11 @@ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.36.tgz", "integrity": "sha512-t70bfa7HYEA1D9idDbmuv7YbsbVkQ+Hp+8KFSul4aE5e/i1bjCNIRYJZlA8Q8p0r9T8cF/RVvwUgRA//FydEyg==" }, + "bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==" + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -11725,6 +11747,14 @@ "is-arrayish": "^0.3.1" } }, + "slp-mdm": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/slp-mdm/-/slp-mdm-0.0.7.tgz", + "integrity": "sha512-XlnDS7y8fnEt9I5lw/GqcrDjTGw5vZon83xlliLyihz2EvjE135ubIBzq4bmjVOrM669G9A6bkqEp5Y1trPV3A==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/apps/token-server/package.json b/apps/token-server/package.json --- a/apps/token-server/package.json +++ b/apps/token-server/package.json @@ -61,6 +61,7 @@ "multer": "^1.4.5-lts.1", "node-telegram-bot-api": "^0.65.1", "sharp": "^0.33.2", + "slp-mdm": "^0.0.7", "tiny-secp256k1": "^2.2.3" } } diff --git a/apps/token-server/src/transactions.ts b/apps/token-server/src/transactions.ts new file mode 100644 --- /dev/null +++ b/apps/token-server/src/transactions.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +/** + * transactions.ts + * methods for building token reward transtaction + */ + +import { BN, TokenType1 } from 'slp-mdm'; +import { ScriptUtxo_InNode } from 'chronik-client'; + +const DUST_SATS = 546; + +interface TargetOutput { + value: number; + script?: Uint8Array | Buffer; + address?: string; +} + +export interface SlpInputsAndOutputs { + slpInputs: ScriptUtxo_InNode[]; + slpOutputs: TargetOutput[]; +} + +/** + * Get required slp utxo inputs and outputs for a token rewards tx + * @param rewardAmountTokenSats stringified decimal integer in units of "token satoshis" + * @param destinationAddress address of reward recipient + * @param tokenId tokenId of the token you wish to send + * @param utxos array of utxos available to token-server + */ +export function getSlpInputsAndOutputs( + rewardAmountTokenSats: string, + destinationAddress: string, + tokenId: string, + utxos: ScriptUtxo_InNode[], +): SlpInputsAndOutputs { + const slpInputs: ScriptUtxo_InNode[] = []; + + let totalSendQty = BigInt(0); + let change = BigInt(0); + let sufficientTokenUtxos = false; + for (const utxo of utxos) { + if ( + utxo.token?.tokenId === tokenId && + utxo.token?.isMintBaton === false + ) { + totalSendQty += BigInt(utxo.token.amount); + slpInputs.push(utxo); + change = totalSendQty - BigInt(rewardAmountTokenSats); + if (change >= BigInt(0)) { + sufficientTokenUtxos = true; + break; + } + } + } + + if (!sufficientTokenUtxos) { + // TODO notify admin to top up the server + throw new Error('Insufficient token utxos'); + } + + // slp-mdm requires sendAmounts to be BN[]; + const sendAmounts: BN[] = [new BN(rewardAmountTokenSats)]; + + if (change > 0) { + sendAmounts.push(new BN(change.toString())); + } + + // Build target output(s) per spec + const script = TokenType1.send(tokenId, sendAmounts); + + const slpOutputs: TargetOutput[] = [{ script, value: 0 }]; + // Add first 'to' amount to 1 index. This could be any index between 1 and 19. + slpOutputs.push({ + value: DUST_SATS, + address: destinationAddress, + }); + + // On token-server, sendAmounts can only be length 1 or 2 + // For now, we do not batch reward txs + if (sendAmounts.length > 1) { + // Add another targetOutput + // Note that change addresses are added after ecash-coinselect by wallet + // Change output is denoted by lack of address key + slpOutputs.push({ + value: DUST_SATS, + // Note that address: is intentionally omitted + // We will add change address to any outputs with no address or script when the tx is built + }); + } + + return { slpInputs, slpOutputs }; +} diff --git a/apps/token-server/test/transactions.test.ts b/apps/token-server/test/transactions.test.ts new file mode 100644 --- /dev/null +++ b/apps/token-server/test/transactions.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import * as assert from 'assert'; +import { getSlpInputsAndOutputs } from '../src/transactions'; +import vectors from './vectors'; + +describe('transactions.ts', function () { + describe('We can get slpInputs and slpOutputs for a token rewards tx to one destinationAddress', function () { + const { returns, errors } = vectors.getSlpInputsAndOutputs; + returns.forEach(vector => { + const { + description, + rewardAmountTokenSats, + destinationAddress, + tokenId, + utxos, + returned, + } = vector; + it(description, function () { + assert.deepEqual( + getSlpInputsAndOutputs( + rewardAmountTokenSats, + destinationAddress, + tokenId, + utxos, + ), + returned, + ); + }); + }); + errors.forEach(vector => { + const { + description, + rewardAmountTokenSats, + destinationAddress, + tokenId, + utxos, + error, + } = vector; + it(description, function () { + assert.throws( + () => + getSlpInputsAndOutputs( + rewardAmountTokenSats, + destinationAddress, + tokenId, + utxos, + ), + error, + ); + }); + }); + }); +}); diff --git a/apps/token-server/test/vectors.ts b/apps/token-server/test/vectors.ts --- a/apps/token-server/test/vectors.ts +++ b/apps/token-server/test/vectors.ts @@ -10,17 +10,25 @@ Tx_InNode, BlockMetadata_InNode, ScriptUtxo_InNode, + TokenType, } from 'chronik-client'; import { ServerWallet } from '../src/wallet'; +import { SlpInputsAndOutputs } from '../src/transactions'; import { Request } from 'express'; const IFP_ADDRESS = 'ecash:prfhcnyqnl5cgrnmlfmms675w93ld7mvvqd0y8lz07'; const IFP_OUTPUTSCRIPT = 'a914d37c4c809fe9840e7bfa77b86bd47163f6fb6c6087'; const MOCK_CHECKED_ADDRESS = 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; +const MOCK_DESTINATION_ADDRESS = + 'ecash:qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7ratqfx'; const MOCK_CHECKED_OUTPUTSCRIPT = '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac'; const MOCK_OTHER_CHECKED_OUTPUTSCRIPT = '76a914a24e2b67689c3753983d3b408bc7690d31b1b74d88ac'; +const MOCK_TOKENID_ONES = + '1111111111111111111111111111111111111111111111111111111111111111'; +const MOCK_TOKENID_TWOS = + '2222222222222222222222222222222222222222222222222222222222222222'; const MOCK_REWARD_TOKENID = 'fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa'; const MOCK_OTHER_TOKENID = @@ -66,6 +74,30 @@ isFinal: true, }; +const MOCK_TOKEN_TYPE: TokenType = { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, +}; +const MOCK_UTXO_TOKEN: Token_InNode = { + tokenId: MOCK_TOKENID_ONES, + tokenType: MOCK_TOKEN_TYPE, + amount: '1', + isMintBaton: false, +}; +const MOCK_SPENDABLE_TOKEN_UTXO: ScriptUtxo_InNode = { + ...MOCK_SCRIPT_UTXO, + token: MOCK_UTXO_TOKEN, +}; +const MOCK_MINT_BATON_TOKEN_UTXO: ScriptUtxo_InNode = { + ...MOCK_SPENDABLE_TOKEN_UTXO, + token: { + ...MOCK_UTXO_TOKEN, + amount: '0', + isMintBaton: true, + }, +}; + const MOCK_BLOCK_METADATA_INNODE: BlockMetadata_InNode = { hash: '0000000000000000115e051672e3d4a6c523598594825a1194862937941296fe', height: 800000, @@ -201,6 +233,29 @@ error: Error; } +interface GetSlpInputsAndOutputsVector { + returns: GetSlpInputsAndOutputsReturn[]; + errors: GetSlpInputsAndOutputsError[]; +} + +interface GetSlpInputsAndOutputsReturn { + description: string; + rewardAmountTokenSats: string; + destinationAddress: string; + tokenId: string; + utxos: ScriptUtxo_InNode[]; + returned: SlpInputsAndOutputs; +} + +interface GetSlpInputsAndOutputsError { + description: string; + rewardAmountTokenSats: string; + destinationAddress: string; + tokenId: string; + utxos: ScriptUtxo_InNode[]; + error: Error; +} + interface TestVectors { hasInputsFromOutputScript: HasInputsFromOutputScriptVector; addressReceivedToken: AddressReceivedTokenReturnVector; @@ -210,6 +265,7 @@ isTokenImageRequest: IsTokenImageRequestVector; getWalletFromSeed: GetWalletFromSeedVector; syncWallet: SyncWalletVector; + getSlpInputsAndOutputs: GetSlpInputsAndOutputsVector; } const vectors: TestVectors = { @@ -748,6 +804,151 @@ }, ], }, + getSlpInputsAndOutputs: { + returns: [ + { + description: + 'We get expected inputs and outputs if we have sufficient token utxos to exactly cover the reward amount', + rewardAmountTokenSats: '3', + destinationAddress: MOCK_DESTINATION_ADDRESS, + tokenId: MOCK_TOKENID_ONES, + utxos: [ + MOCK_SPENDABLE_TOKEN_UTXO, + MOCK_SPENDABLE_TOKEN_UTXO, + MOCK_SPENDABLE_TOKEN_UTXO, + ], + returned: { + slpInputs: [ + MOCK_SPENDABLE_TOKEN_UTXO, + MOCK_SPENDABLE_TOKEN_UTXO, + MOCK_SPENDABLE_TOKEN_UTXO, + ], + slpOutputs: [ + { + script: new Uint8Array([ + 106, 4, 83, 76, 80, 0, 1, 1, 4, 83, 69, 78, 68, + 32, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 17, 17, 8, 0, 0, 0, + 0, 0, 0, 0, 3, + ]), + value: 0, + }, + { + address: + 'ecash:qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7ratqfx', + value: 546, + }, + ], + }, + }, + { + description: + 'We get expected inputs and outputs if we have sufficient token utxos to cover the reward amount with change', + rewardAmountTokenSats: '3', + destinationAddress: MOCK_DESTINATION_ADDRESS, + tokenId: MOCK_TOKENID_ONES, + utxos: [ + { + ...MOCK_SPENDABLE_TOKEN_UTXO, + token: { + ...MOCK_UTXO_TOKEN, + amount: '5', + }, + }, + ], + returned: { + slpInputs: [ + { + ...MOCK_SPENDABLE_TOKEN_UTXO, + token: { + ...MOCK_UTXO_TOKEN, + amount: '5', + }, + }, + ], + slpOutputs: [ + { + script: new Uint8Array([ + 106, 4, 83, 76, 80, 0, 1, 1, 4, 83, 69, 78, 68, + 32, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 17, 17, 8, 0, 0, 0, + 0, 0, 0, 0, 3, 8, 0, 0, 0, 0, 0, 0, 0, 2, + ]), + value: 0, + }, + { + address: + 'ecash:qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7ratqfx', + value: 546, + }, + { + value: 546, + }, + ], + }, + }, + ], + errors: [ + { + description: 'We have insufficient utxos if we have no utxos', + rewardAmountTokenSats: '1', + destinationAddress: MOCK_DESTINATION_ADDRESS, + tokenId: MOCK_TOKENID_ONES, + utxos: [], + error: new Error('Insufficient token utxos'), + }, + { + description: + 'We have insufficient utxos if we have utxos of total amount one less than rewardAmountTokenSats', + rewardAmountTokenSats: '3', + destinationAddress: MOCK_DESTINATION_ADDRESS, + tokenId: MOCK_TOKENID_ONES, + utxos: [MOCK_SPENDABLE_TOKEN_UTXO, MOCK_SPENDABLE_TOKEN_UTXO], + error: new Error('Insufficient token utxos'), + }, + { + description: + 'We have insufficient utxos if we have mint batons, eCash utxos, and spendable token utxos of other tokenIds, but not enough spendable utxos for the right token', + rewardAmountTokenSats: '5', + destinationAddress: MOCK_DESTINATION_ADDRESS, + tokenId: MOCK_TOKENID_ONES, + utxos: [ + MOCK_MINT_BATON_TOKEN_UTXO, + MOCK_SPENDABLE_TOKEN_UTXO, + MOCK_MINT_BATON_TOKEN_UTXO, + { + ...MOCK_SPENDABLE_TOKEN_UTXO, + token: { + ...MOCK_UTXO_TOKEN, + tokenId: MOCK_TOKENID_TWOS, + amount: '5', + }, + }, + ], + error: new Error('Insufficient token utxos'), + }, + { + description: + 'We have insufficient utxos if we have only mint batons, even if they are (somehow) of enough quantity', + rewardAmountTokenSats: '1', + destinationAddress: MOCK_DESTINATION_ADDRESS, + tokenId: MOCK_TOKENID_ONES, + utxos: [ + { + ...MOCK_MINT_BATON_TOKEN_UTXO, + token: { + ...MOCK_UTXO_TOKEN, + isMintBaton: true, + amount: '5', + }, + }, + ], + error: new Error('Insufficient token utxos'), + }, + ], + }, }; export default vectors;