diff --git a/cashtab/extension/public/manifest.json b/cashtab/extension/public/manifest.json --- a/cashtab/extension/public/manifest.json +++ b/cashtab/extension/public/manifest.json @@ -3,7 +3,7 @@ "name": "Cashtab", "description": "A browser-integrated eCash wallet from Bitcoin ABC", - "version": "3.12.0", + "version": "3.13.0", "content_scripts": [ { "matches": ["file://*/*", "http://*/*", "https://*/*"], diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json --- a/cashtab/package-lock.json +++ b/cashtab/package-lock.json @@ -1,12 +1,12 @@ { "name": "cashtab", - "version": "2.12.3", + "version": "2.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "2.12.3", + "version": "2.13.0", "dependencies": { "@ant-design/icons": "^5.3.0", "@bitgo/utxo-lib": "^9.33.0", diff --git a/cashtab/package.json b/cashtab/package.json --- a/cashtab/package.json +++ b/cashtab/package.json @@ -1,6 +1,6 @@ { "name": "cashtab", - "version": "2.12.3", + "version": "2.13.0", "private": true, "scripts": { "start": "node scripts/start.js", diff --git a/cashtab/src/components/Send/SendToken.js b/cashtab/src/components/Send/SendToken.js --- a/cashtab/src/components/Send/SendToken.js +++ b/cashtab/src/components/Send/SendToken.js @@ -14,7 +14,11 @@ import { Event } from 'components/Common/GoogleAnalytics'; import { getWalletState } from 'utils/cashMethods'; import ApiError from 'components/Common/ApiError'; -import { isValidTokenSendOrBurnAmount, parseAddressInput } from 'validation'; +import { + isValidTokenSendOrBurnAmount, + parseAddressInput, + isValidTokenMintAmount, +} from 'validation'; import { formatDate } from 'utils/formatting'; import styled from 'styled-components'; import TokenIcon from 'components/Etokens/TokenIcon'; @@ -28,6 +32,9 @@ getSendTokenInputs, getSlpSendTargetOutputs, getSlpBurnTargetOutputs, + getMintBatons, + getMintTargetOutputs, + getMaxMintAmount, } from 'slpv1'; import { sendXec } from 'transactions'; import { hasEnoughToken } from 'wallet'; @@ -162,6 +169,7 @@ const [sendTokenAmountError, setSendTokenAmountError] = useState(false); const [showConfirmBurnEtoken, setShowConfirmBurnEtoken] = useState(false); const [burnTokenAmountError, setBurnTokenAmountError] = useState(false); + const [mintAmountError, setMintAmountError] = useState(false); const [burnConfirmationError, setBurnConfirmationError] = useState(false); const [confirmationOfEtokenToBeBurnt, setConfirmationOfEtokenToBeBurnt] = useState(''); @@ -169,18 +177,26 @@ const [showSend, setShowSend] = useState(true); const [showBurn, setShowBurn] = useState(false); const [showAirdrop, setShowAirdrop] = useState(false); + const [showMint, setShowMint] = useState(false); const [showLargeIconModal, setShowLargeIconModal] = useState(false); + // Check if the user has mint batons for this token + // If they don't, disable the mint switch and label why + const mintBatons = getMintBatons(wallet.state.slpUtxos, tokenId); + // Load with QR code open if device is mobile const openWithScanner = settings && settings.autoCameraOn === true && isMobile(navigator); const [isModalVisible, setIsModalVisible] = useState(false); - const [formData, setFormData] = useState({ + const emptyFormData = { amount: '', address: '', burnAmount: '', - }); + mintAmount: '', + }; + + const [formData, setFormData] = useState(emptyFormData); const userLocale = getUserLocale(navigator); @@ -201,11 +217,7 @@ // Clears address and amount fields following a send token notification const clearInputForms = () => { - setFormData({ - amount: '', - address: '', - burnAmount: '', - }); + setFormData(emptyFormData); setAliasInputAddress(false); // clear alias address preview }; @@ -385,6 +397,17 @@ } }; + const onMaxMint = () => { + const maxMintAmount = getMaxMintAmount(decimals); + + handleMintAmountChange({ + target: { + name: 'mintAmount', + value: maxMintAmount, + }, + }); + }; + const checkForConfirmationBeforeSendEtoken = () => { if (settings.sendModal) { setIsModalVisible(settings.sendModal); @@ -421,6 +444,23 @@ })); }; + const handleMintAmountChange = e => { + const { name, value } = e.target; + const isValidMintAmountOrErrorMsg = isValidTokenMintAmount( + value, + decimals, + ); + setMintAmountError( + isValidMintAmountOrErrorMsg === true + ? false + : isValidMintAmountOrErrorMsg, + ); + setFormData(p => ({ + ...p, + [name]: value, + })); + }; + const onMaxBurn = () => { // trigger validation on the inserted max value handleEtokenBurnAmountChange({ @@ -492,6 +532,59 @@ } } + async function handleMint() { + Event('SendToken.js', 'Mint eToken', tokenId); + + try { + // Get targetOutputs for an slpv1 burn tx + // this is NOT like an slpv1 send tx + const mintTargetOutputs = getMintTargetOutputs( + tokenId, + decimals, + formData.mintAmount, + ); + + // We should not be able to get here without at least one mint baton, + // as the mint switch would be disabled + // Still, handle + if (mintBatons.length < 1) { + throw new Error(`Unable to find mint baton for ${tokenName}`); + } + + // Build and broadcast the tx + const { response } = await sendXec( + chronik, + wallet, + mintTargetOutputs, + settings.minFeeSends && + hasEnoughToken( + tokens, + appConfig.vipSettingsTokenId, + appConfig.vipSettingsTokenQty, + ) + ? appConfig.minFee + : appConfig.defaultFee, + chaintipBlockheight, + [mintBatons[0]], // Only use one mint baton + ); + toast( + + ⚗️ Minted {formData.mintAmount} {tokenTicker} + , + { + icon: , + }, + ); + clearInputForms(); + } catch (e) { + toast.error(`${e}`); + } + } + const handleBurnConfirmationInput = e => { const { value } = e.target; @@ -671,9 +764,10 @@ checked={showSend} handleToggle={() => { if (!showSend) { - // If showSend is being set to true here, make sure burn and airdrop are false + // Make sure all other switches are off setShowAirdrop(false); setShowBurn(false); + setShowMint(false); } setShowSend(!showSend); }} @@ -756,9 +850,10 @@ checked={showAirdrop} handleToggle={() => { if (!showAirdrop) { - // If showAirdrop is being set to true here, make sure burn and send are false + // Make sure all other switches are off setShowBurn(false); setShowSend(false); + setShowMint(false); } setShowAirdrop(!showAirdrop); }} @@ -792,9 +887,10 @@ checked={showBurn} handleToggle={() => { if (!showBurn) { - // If showBurn is being set to true here, make sure airdrop and send are false + // Make sure all other switches are off setShowAirdrop(false); setShowSend(false); + setShowMint(false); } setShowBurn(!showBurn); }} @@ -828,6 +924,56 @@ )} + + { + if (!showMint) { + // Make sure all other switches are off + setShowAirdrop(false); + setShowBurn(false); + setShowSend(false); + } + setShowMint(!showMint); + }} + /> + + Mint + {mintBatons.length === 0 + ? ' (disabled, no mint baton in wallet)' + : ''} + + + {showMint && ( + + + + + + Mint {tokenTicker} + + + + )} )} diff --git a/cashtab/src/components/Send/__tests__/SendToken.test.js b/cashtab/src/components/Send/__tests__/SendToken.test.js --- a/cashtab/src/components/Send/__tests__/SendToken.test.js +++ b/cashtab/src/components/Send/__tests__/SendToken.test.js @@ -523,4 +523,144 @@ ), ); }); + it('Mint switch is disabled if no mint batons for this token in the wallet', async () => { + render( + , + ); + + // Wait for element to get token info and load + expect((await screen.findAllByText(/BEAR/))[0]).toBeInTheDocument(); + + // The mint switch is disabled + expect(screen.getByTestId('mint-switch')).toHaveProperty( + 'disabled', + true, + ); + + expect( + screen.getByText(/(disabled, no mint baton in wallet)/), + ).toBeInTheDocument(); + }); + it('We can mint an slpv1 token if we have a mint baton', async () => { + // Mock context with a mint baton utxo + const mockTokenId = + 'aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1'; + const mintBatonUtxo = { + outpoint: { + txid: '4b5b2a0f8bcacf6bccc7ef49e7f82a894c9c599589450eaeaf423e0f5926c38e', + outIdx: 2, + }, + blockHeight: -1, + isCoinbase: false, + value: 546, + isFinal: false, + token: { + tokenId: + 'aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + amount: '0', + isMintBaton: true, + }, + path: 1899, + }; + const balanceUtxo = { + outpoint: { + txid: '4b5b2a0f8bcacf6bccc7ef49e7f82a894c9c599589450eaeaf423e0f5926c38e', + outIdx: 2, + }, + blockHeight: -1, + isCoinbase: false, + value: 546, + isFinal: false, + token: { + tokenId: + 'aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1', + tokenType: { + protocol: 'SLP', + type: 'SLP_TOKEN_TYPE_FUNGIBLE', + number: 1, + }, + amount: '20000', + isMintBaton: false, + }, + path: 1899, + }; + const mintMockedChronik = await initializeCashtabStateForTests( + { + ...walletWithXecAndTokens, + state: { + ...walletWithXecAndTokens.state, + slpUtxos: [ + ...walletWithXecAndTokens.state.slpUtxos, + mintBatonUtxo, + balanceUtxo, + ], + }, + }, + localforage, + ); + // Set mock tokeninfo call + mintMockedChronik.setMock('token', { + input: mockTokenId, + output: { + genesisInfo: { + tokenTicker: 'CACHET', + tokenName: 'Cachet', + tokenDocumentUrl: 'https://cashtab.com/', + tokenDocumentHash: '', + decimals: 2, + tokenId: mockTokenId, + }, + }, + }); + + const hex = + '02000000028ec326590f3e42afae0e458995599c4c892af8e749efc7cc6bcfca8b0f2a5b4b020000006b48304502210095c8181e677c6c6c88c3f0836129531944f88722f156bdeda4928342c5554ee702200addb9f7cc4678cd0d9f8111ab774936e92c893fce05fa783a58135f5a69ba614121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dfffffffffe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006a4730440220168f3738b988e690b2a45d818e69369376cde0e96524c5fe3ab5fdbefa89bffa0220777243d6b5d2c6d8929f95817633094c3f9b792e45ab8e095c763963fef099a74121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff040000000000000000396a04534c50000101044d494e5420aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1010208000000000000273122020000000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac22020000000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac357e0e00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; + const txid = + 'dc12e6d3c5ea7504fdc51c8a713b952214b80ff27227faf2f970af74b9c8685e'; + + mintMockedChronik.setMock('broadcastTx', { + input: hex, + output: { txid }, + }); + render( + , + ); + + // Wait for element to get token info and load + expect((await screen.findAllByText(/CACHET/))[0]).toBeInTheDocument(); + + // The mint switch is enabled + const mintSwitch = screen.getByTestId('mint-switch'); + expect(mintSwitch).toHaveProperty('disabled', false); + + // Click the mint switch + await user.click(mintSwitch); + + // Fill out the form + await user.type(screen.getByPlaceholderText('Mint Amount'), '100.33'); + + // Mint it + await user.click(screen.getByRole('button', { name: /Mint CACHET/ })); + + const burnTokenSuccessNotification = await screen.findByText( + '⚗️ Minted 100.33 CACHET', + ); + await waitFor(() => + expect(burnTokenSuccessNotification).toHaveAttribute( + 'href', + `${explorer.blockExplorerUrl}/tx/${txid}`, + ), + ); + }); }); diff --git a/cashtab/src/slpv1/__tests__/index.test.js b/cashtab/src/slpv1/__tests__/index.test.js --- a/cashtab/src/slpv1/__tests__/index.test.js +++ b/cashtab/src/slpv1/__tests__/index.test.js @@ -9,322 +9,386 @@ getAllSendUtxos, getSendTokenInputs, getExplicitBurnTargetOutputs, + getMintBatons, + getMintTargetOutputs, + getMaxMintAmount, } from 'slpv1'; import vectors from '../fixtures/vectors'; import { SEND_DESTINATION_ADDRESS } from '../fixtures/vectors'; import appConfig from 'config/app'; -describe('Generating etoken genesis tx target outputs', () => { - const { expectedReturns, expectedErrors } = - vectors.getSlpGenesisTargetOutput; - - // Successfully created targetOutputs - expectedReturns.forEach(expectedReturn => { - const { description, genesisConfig, mintAddress, targetOutputs } = - expectedReturn; - it(`getSlpGenesisTargetOutput: ${description}`, () => { - // Output value should be zero for OP_RETURN - const calculatedTargetOutputs = getSlpGenesisTargetOutput( - genesisConfig, - mintAddress, - ); - - // We expect 2 outputs or 3 outputs - expect(calculatedTargetOutputs.length >= 2).toBe(true); - - // The output at the 0-index is the OP_RETURN - expect(calculatedTargetOutputs[0].value).toBe(0); - expect(calculatedTargetOutputs[0].script.toString('hex')).toBe( - targetOutputs[0].script, - ); - // The output at the 1-index is dust to given address - expect(calculatedTargetOutputs[1]).toStrictEqual({ - address: mintAddress, - value: appConfig.etokenSats, - }); - if (calculatedTargetOutputs.length > 2) { - // If we have a mint baton +describe('slpv1 methods', () => { + describe('Generating etoken genesis tx target outputs', () => { + const { expectedReturns, expectedErrors } = + vectors.getSlpGenesisTargetOutput; + + // Successfully created targetOutputs + expectedReturns.forEach(expectedReturn => { + const { description, genesisConfig, mintAddress, targetOutputs } = + expectedReturn; + it(`getSlpGenesisTargetOutput: ${description}`, () => { + // Output value should be zero for OP_RETURN + const calculatedTargetOutputs = getSlpGenesisTargetOutput( + genesisConfig, + mintAddress, + ); - // We will only have 3 outputs in this case - // eslint-disable-next-line jest/no-conditional-expect - expect(calculatedTargetOutputs.length).toBe(3); + // We expect 2 outputs or 3 outputs + expect(calculatedTargetOutputs.length >= 2).toBe(true); - // The mint baton is at index 2 - // eslint-disable-next-line jest/no-conditional-expect - expect(calculatedTargetOutputs[2]).toStrictEqual({ + // The output at the 0-index is the OP_RETURN + expect(calculatedTargetOutputs[0].value).toBe(0); + expect(calculatedTargetOutputs[0].script.toString('hex')).toBe( + targetOutputs[0].script, + ); + // The output at the 1-index is dust to given address + expect(calculatedTargetOutputs[1]).toStrictEqual({ address: mintAddress, value: appConfig.etokenSats, }); - } + if (calculatedTargetOutputs.length > 2) { + // If we have a mint baton + + // We will only have 3 outputs in this case + // eslint-disable-next-line jest/no-conditional-expect + expect(calculatedTargetOutputs.length).toBe(3); + + // The mint baton is at index 2 + // eslint-disable-next-line jest/no-conditional-expect + expect(calculatedTargetOutputs[2]).toStrictEqual({ + address: mintAddress, + value: appConfig.etokenSats, + }); + } + }); }); - }); - // Error cases - expectedErrors.forEach(expectedError => { - const { description, genesisConfig, mintAddress, errorMsg } = - expectedError; - it(`getSlpGenesisTargetOutput throws error for: ${description}`, () => { - expect(() => - getSlpGenesisTargetOutput(genesisConfig, mintAddress), - ).toThrow(errorMsg); + // Error cases + expectedErrors.forEach(expectedError => { + const { description, genesisConfig, mintAddress, errorMsg } = + expectedError; + it(`getSlpGenesisTargetOutput throws error for: ${description}`, () => { + expect(() => + getSlpGenesisTargetOutput(genesisConfig, mintAddress), + ).toThrow(errorMsg); + }); }); }); -}); - -describe('Get all slpv1 SEND utxos from a mixed utxo set from ChronikClientNode', () => { - const { expectedReturns } = vectors.getAllSendUtxos; - expectedReturns.forEach(expectedReturn => { - const { description, utxos, tokenId, tokenUtxos } = expectedReturn; - it(`getAllSendUtxos: ${description}`, () => { - expect(getAllSendUtxos(utxos, tokenId)).toStrictEqual(tokenUtxos); + describe('Get all slpv1 SEND utxos from a mixed utxo set from ChronikClientNode', () => { + const { expectedReturns } = vectors.getAllSendUtxos; + expectedReturns.forEach(expectedReturn => { + const { description, utxos, tokenId, tokenUtxos } = expectedReturn; + it(`getAllSendUtxos: ${description}`, () => { + expect(getAllSendUtxos(utxos, tokenId)).toStrictEqual( + tokenUtxos, + ); + }); }); }); -}); - -describe('Get slpv1 send token inputs and outputs from in-node chronik-client', () => { - const { expectedReturns, expectedErrors } = vectors.getSendTokenInputs; - expectedReturns.forEach(expectedReturn => { - const { - description, - allSendUtxos, - sendQty, - decimals, - tokenId, - tokenInputs, - sendAmounts, - targetOutputs, - } = expectedReturn; - it(`getSendTokenInputs: ${description}`, () => { - const calcTokenInputs = getSendTokenInputs( + describe('Get slpv1 send token inputs and outputs from in-node chronik-client', () => { + const { expectedReturns, expectedErrors } = vectors.getSendTokenInputs; + expectedReturns.forEach(expectedReturn => { + const { + description, allSendUtxos, - tokenId, sendQty, decimals, - ); - expect(calcTokenInputs.tokenInputs).toStrictEqual(tokenInputs); - expect(calcTokenInputs.sendAmounts).toStrictEqual(sendAmounts); - }); - it(`getSlpSendTargetOutputs with in-node inputs: ${description}`, () => { - const calculatedTargetOutputs = getSlpSendTargetOutputs( - { tokenInputs, sendAmounts }, - SEND_DESTINATION_ADDRESS, - ); - - // We will always have the OP_RETURN output at index 0 - expect(calculatedTargetOutputs[0].value).toBe(0); - expect(calculatedTargetOutputs[0].script.toString('hex')).toBe( - targetOutputs[0].script, - ); - - // We will always have the destination output at index 1 - expect(calculatedTargetOutputs[1].value).toBe(appConfig.etokenSats); - expect(calculatedTargetOutputs[1].address).toBe( - SEND_DESTINATION_ADDRESS, - ); - - // If there is a change output it is at index 2 - if (typeof calculatedTargetOutputs[2] !== 'undefined') { - // If we are here, assert the length must be 3 + tokenId, + tokenInputs, + sendAmounts, + targetOutputs, + } = expectedReturn; + it(`getSendTokenInputs: ${description}`, () => { + const calcTokenInputs = getSendTokenInputs( + allSendUtxos, + tokenId, + sendQty, + decimals, + ); + expect(calcTokenInputs.tokenInputs).toStrictEqual(tokenInputs); + expect(calcTokenInputs.sendAmounts).toStrictEqual(sendAmounts); + }); + it(`getSlpSendTargetOutputs with in-node inputs: ${description}`, () => { + const calculatedTargetOutputs = getSlpSendTargetOutputs( + { tokenInputs, sendAmounts }, + SEND_DESTINATION_ADDRESS, + ); - // eslint-disable-next-line jest/no-conditional-expect - expect(calculatedTargetOutputs.length).toBe(3); + // We will always have the OP_RETURN output at index 0 + expect(calculatedTargetOutputs[0].value).toBe(0); + expect(calculatedTargetOutputs[0].script.toString('hex')).toBe( + targetOutputs[0].script, + ); - // assert the expected change output - // eslint-disable-next-line jest/no-conditional-expect - expect(calculatedTargetOutputs[2].value).toBe( + // We will always have the destination output at index 1 + expect(calculatedTargetOutputs[1].value).toBe( appConfig.etokenSats, ); - // eslint-disable-next-line jest/no-conditional-expect - expect('address' in calculatedTargetOutputs[2]).toBe(false); - } else { - // If we are here, assert the length must be 2 + expect(calculatedTargetOutputs[1].address).toBe( + SEND_DESTINATION_ADDRESS, + ); - // eslint-disable-next-line jest/no-conditional-expect - expect(calculatedTargetOutputs.length).toBe(2); - } - }); - }); - expectedErrors.forEach(expectedError => { - const { - description, - allSendUtxos, - tokenId, - sendQty, - decimals, - errorMsg, - } = expectedError; - it(`getSlpBurnTargetOutput throws error for: ${description}`, () => { - expect(() => - getSendTokenInputs(allSendUtxos, tokenId, sendQty, decimals), - ).toThrow(errorMsg); + // If there is a change output it is at index 2 + if (typeof calculatedTargetOutputs[2] !== 'undefined') { + // If we are here, assert the length must be 3 + + // eslint-disable-next-line jest/no-conditional-expect + expect(calculatedTargetOutputs.length).toBe(3); + + // assert the expected change output + // eslint-disable-next-line jest/no-conditional-expect + expect(calculatedTargetOutputs[2].value).toBe( + appConfig.etokenSats, + ); + // eslint-disable-next-line jest/no-conditional-expect + expect('address' in calculatedTargetOutputs[2]).toBe(false); + } else { + // If we are here, assert the length must be 2 + + // eslint-disable-next-line jest/no-conditional-expect + expect(calculatedTargetOutputs.length).toBe(2); + } + }); }); - }); -}); - -describe('Get slpv1 send input utxos from in-node chronik-client', () => { - const { expectedReturns, expectedErrors } = vectors.getSendTokenInputs; - expectedReturns.forEach(expectedReturn => { - const { - description, - allSendUtxos, - sendQty, - tokenId, - decimals, - tokenInputs, - sendAmounts, - targetOutputs, - } = expectedReturn; - it(`getSendTokenInputs with in-node chronik utxos: ${description}`, () => { - const calcTokenInputs = getSendTokenInputs( + expectedErrors.forEach(expectedError => { + const { + description, allSendUtxos, tokenId, sendQty, decimals, - ); - expect(calcTokenInputs.tokenInputs).toStrictEqual(tokenInputs); - expect(calcTokenInputs.sendAmounts).toStrictEqual(sendAmounts); + errorMsg, + } = expectedError; + it(`getSlpBurnTargetOutput throws error for: ${description}`, () => { + expect(() => + getSendTokenInputs( + allSendUtxos, + tokenId, + sendQty, + decimals, + ), + ).toThrow(errorMsg); + }); }); - it(`getSlpSendTargetOutputs with in-node inputs: ${description}`, () => { - const calculatedTargetOutputs = getSlpSendTargetOutputs( - { tokenInputs, sendAmounts }, - SEND_DESTINATION_ADDRESS, - ); - - // We will always have the OP_RETURN output at index 0 - expect(calculatedTargetOutputs[0].value).toBe(0); - expect(calculatedTargetOutputs[0].script.toString('hex')).toBe( - targetOutputs[0].script, - ); - - // We will always have the destination output at index 1 - expect(calculatedTargetOutputs[1].value).toBe(appConfig.etokenSats); - expect(calculatedTargetOutputs[1].address).toBe( - SEND_DESTINATION_ADDRESS, - ); - - // If there is a change output it is at index 2 - if (typeof calculatedTargetOutputs[2] !== 'undefined') { - // If we are here, assert the length must be 3 + }); + describe('Get slpv1 send input utxos from in-node chronik-client', () => { + const { expectedReturns, expectedErrors } = vectors.getSendTokenInputs; + expectedReturns.forEach(expectedReturn => { + const { + description, + allSendUtxos, + sendQty, + tokenId, + decimals, + tokenInputs, + sendAmounts, + targetOutputs, + } = expectedReturn; + it(`getSendTokenInputs with in-node chronik utxos: ${description}`, () => { + const calcTokenInputs = getSendTokenInputs( + allSendUtxos, + tokenId, + sendQty, + decimals, + ); + expect(calcTokenInputs.tokenInputs).toStrictEqual(tokenInputs); + expect(calcTokenInputs.sendAmounts).toStrictEqual(sendAmounts); + }); + it(`getSlpSendTargetOutputs with in-node inputs: ${description}`, () => { + const calculatedTargetOutputs = getSlpSendTargetOutputs( + { tokenInputs, sendAmounts }, + SEND_DESTINATION_ADDRESS, + ); - // eslint-disable-next-line jest/no-conditional-expect - expect(calculatedTargetOutputs.length).toBe(3); + // We will always have the OP_RETURN output at index 0 + expect(calculatedTargetOutputs[0].value).toBe(0); + expect(calculatedTargetOutputs[0].script.toString('hex')).toBe( + targetOutputs[0].script, + ); - // assert the expected change output - // eslint-disable-next-line jest/no-conditional-expect - expect(calculatedTargetOutputs[2].value).toBe( + // We will always have the destination output at index 1 + expect(calculatedTargetOutputs[1].value).toBe( appConfig.etokenSats, ); - // eslint-disable-next-line jest/no-conditional-expect - expect('address' in calculatedTargetOutputs[2]).toBe(false); - } else { - // If we are here, assert the length must be 2 + expect(calculatedTargetOutputs[1].address).toBe( + SEND_DESTINATION_ADDRESS, + ); - // eslint-disable-next-line jest/no-conditional-expect - expect(calculatedTargetOutputs.length).toBe(2); - } + // If there is a change output it is at index 2 + if (typeof calculatedTargetOutputs[2] !== 'undefined') { + // If we are here, assert the length must be 3 + + // eslint-disable-next-line jest/no-conditional-expect + expect(calculatedTargetOutputs.length).toBe(3); + + // assert the expected change output + // eslint-disable-next-line jest/no-conditional-expect + expect(calculatedTargetOutputs[2].value).toBe( + appConfig.etokenSats, + ); + // eslint-disable-next-line jest/no-conditional-expect + expect('address' in calculatedTargetOutputs[2]).toBe(false); + } else { + // If we are here, assert the length must be 2 + + // eslint-disable-next-line jest/no-conditional-expect + expect(calculatedTargetOutputs.length).toBe(2); + } + }); }); - }); - expectedErrors.forEach(expectedError => { - const { - description, - allSendUtxos, - sendQty, - tokenId, - decimals, - errorMsg, - } = expectedError; - it(`getSendTokenInputs with in-node chronik utxos throws error for: ${description}`, () => { - expect(() => - getSendTokenInputs(allSendUtxos, tokenId, sendQty, decimals), - ).toThrow(errorMsg); + expectedErrors.forEach(expectedError => { + const { + description, + allSendUtxos, + sendQty, + tokenId, + decimals, + errorMsg, + } = expectedError; + it(`getSendTokenInputs with in-node chronik utxos throws error for: ${description}`, () => { + expect(() => + getSendTokenInputs( + allSendUtxos, + tokenId, + sendQty, + decimals, + ), + ).toThrow(errorMsg); + }); }); }); -}); + describe('Generating etoken burn tx target outputs', () => { + const { expectedReturns } = vectors.burnTxs; -describe('Generating etoken burn tx target outputs', () => { - const { expectedReturns } = vectors.burnTxs; - - // Successfully created targetOutputs - expectedReturns.forEach(expectedReturn => { - const { - description, - tokenUtxos, - burnQty, - tokenId, - decimals, - tokenInputInfo, - outputScriptHex, - } = expectedReturn; - - it(`getSlpBurnTargetOutputs: ${description}`, () => { - // We get the same tokenInputInfo object for token burns that we do for token sends - const calculatedTokenInputInfo = getSendTokenInputs( + // Successfully created targetOutputs + expectedReturns.forEach(expectedReturn => { + const { + description, tokenUtxos, - tokenId, burnQty, + tokenId, decimals, - ); + tokenInputInfo, + outputScriptHex, + } = expectedReturn; + + it(`getSlpBurnTargetOutputs: ${description}`, () => { + // We get the same tokenInputInfo object for token burns that we do for token sends + const calculatedTokenInputInfo = getSendTokenInputs( + tokenUtxos, + tokenId, + burnQty, + decimals, + ); - expect(calculatedTokenInputInfo.sendAmounts).toStrictEqual( - tokenInputInfo.sendAmounts, - ); + expect(calculatedTokenInputInfo.sendAmounts).toStrictEqual( + tokenInputInfo.sendAmounts, + ); - const targetOutput = getSlpBurnTargetOutputs( - calculatedTokenInputInfo, - ); + const targetOutput = getSlpBurnTargetOutputs( + calculatedTokenInputInfo, + ); - // We will always have the OP_RETURN output at index 0 - expect(targetOutput[0].value).toBe(0); - expect(targetOutput[0].script.toString('hex')).toBe( - outputScriptHex, - ); + // We will always have the OP_RETURN output at index 0 + expect(targetOutput[0].value).toBe(0); + expect(targetOutput[0].script.toString('hex')).toBe( + outputScriptHex, + ); - // BURN txs always have 2 outputs - expect(targetOutput.length).toBe(2); - // assert the expected change output - expect(targetOutput[1].value).toBe(appConfig.etokenSats); - expect('address' in targetOutput[1]).toBe(false); + // BURN txs always have 2 outputs + expect(targetOutput.length).toBe(2); + // assert the expected change output + expect(targetOutput[1].value).toBe(appConfig.etokenSats); + expect('address' in targetOutput[1]).toBe(false); + }); }); }); -}); - -describe('Generating explicit etoken burn tx target output from in-node utxos', () => { - const { expectedReturns } = vectors.explicitBurns; + describe('Generating explicit etoken burn tx target output from in-node utxos', () => { + const { expectedReturns } = vectors.explicitBurns; + + expectedReturns.forEach(expectedReturn => { + const { description, burnUtxos, decimals, outputScriptHex } = + expectedReturn; + it(`getExplicitBurnTargetOutputs: ${description}`, () => { + const targetOutputs = getExplicitBurnTargetOutputs( + burnUtxos, + decimals, + ); + // We get an array of length 1 + expect(targetOutputs.length).toBe(1); + // Output value should be zero for OP_RETURN + expect(targetOutputs[0].value).toBe(0); + // Test vs hex string as cannot store buffer type in vectors + expect(targetOutputs[0].script.toString('hex')).toBe( + outputScriptHex, + ); + }); + }); - expectedReturns.forEach(expectedReturn => { - const { description, burnUtxos, decimals, outputScriptHex } = - expectedReturn; - it(`getExplicitBurnTargetOutputs: ${description}`, () => { - const targetOutputs = getExplicitBurnTargetOutputs( - burnUtxos, - decimals, - ); - // We get an array of length 1 - expect(targetOutputs.length).toBe(1); - // Output value should be zero for OP_RETURN - expect(targetOutputs[0].value).toBe(0); - // Test vs hex string as cannot store buffer type in vectors - expect(targetOutputs[0].script.toString('hex')).toBe( - outputScriptHex, + // We expect an error if in-node utxos are used in a call without specifying the decimals param + it(`getExplicitBurnTargetOutputs throws error if called with in-node utxos and no specified decimals`, () => { + expect(() => + getExplicitBurnTargetOutputs([ + { + value: 546, + token: { + tokenId: + '3333333333333333333333333333333333333333333333333333333333333333', + amount: '100', + }, + }, + ]), + ).toThrow( + 'Invalid decimals -1 for tokenId 3333333333333333333333333333333333333333333333333333333333333333. Decimals must be an integer 0-9.', ); }); }); - - // We expect an error if in-node utxos are used in a call without specifying the decimals param - it(`getExplicitBurnTargetOutputs throws error if called with in-node utxos and no specified decimals`, () => { - expect(() => - getExplicitBurnTargetOutputs([ - { - value: 546, - token: { - tokenId: - '3333333333333333333333333333333333333333333333333333333333333333', - amount: '100', - }, - }, - ]), - ).toThrow( - 'Invalid decimals -1 for tokenId 3333333333333333333333333333333333333333333333333333333333333333. Decimals must be an integer 0-9.', - ); + describe('Get slpv1 mint baton(s)', () => { + const { expectedReturns } = vectors.getMintBatons; + expectedReturns.forEach(vector => { + const { description, utxos, tokenId, returned } = vector; + it(`getMintBatons: ${description}`, () => { + expect(getMintBatons(utxos, tokenId)).toStrictEqual(returned); + }); + }); + }); + describe('Generate target outputs for an slpv1 mint tx', () => { + const { expectedReturns, expectedErrors } = + vectors.getMintTargetOutputs; + expectedReturns.forEach(vector => { + const { description, tokenId, decimals, mintQty, script } = vector; + it(`getMintTargetOutputs: ${description}`, () => { + const mintTargetOutputs = getMintTargetOutputs( + tokenId, + decimals, + mintQty, + ); + expect(mintTargetOutputs[0].script.toString('hex')).toBe( + script, + ); + expect(mintTargetOutputs.length).toBe(3); + expect(mintTargetOutputs.splice(1, 3)).toStrictEqual([ + { value: appConfig.etokenSats }, + { value: appConfig.etokenSats }, + ]); + }); + }); + expectedErrors.forEach(vector => { + const { description, tokenId, decimals, mintQty, error } = vector; + it(`getMintTargetOutputs throws error for: ${description}`, () => { + expect(() => + getMintTargetOutputs(tokenId, decimals, mintQty), + ).toThrow(error); + }); + }); + }); + describe('Gets max mint amount, decimalized', () => { + const { expectedReturns } = vectors.getMaxMintAmount; + expectedReturns.forEach(vector => { + const { description, decimals, returned } = vector; + it(`getMaxMintAmount: ${description}`, () => { + expect(getMaxMintAmount(decimals)).toBe(returned); + }); + }); }); }); diff --git a/cashtab/src/slpv1/fixtures/vectors.js b/cashtab/src/slpv1/fixtures/vectors.js --- a/cashtab/src/slpv1/fixtures/vectors.js +++ b/cashtab/src/slpv1/fixtures/vectors.js @@ -821,4 +821,380 @@ }, ], }, + getMintBatons: { + expectedReturns: [ + { + description: 'We can get a single mint baton', + utxos: [ + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: false, + }, + }, + ], + tokenId: MOCK_TOKEN_ID, + returned: [ + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: true, + }, + }, + ], + }, + { + description: + 'We can get the correct mint baton from from an array including other token utxos, mint batons, and non-token utxos', + utxos: [ + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484', + amount: '4588000000', + isMintBaton: false, + }, + }, + { + value: 546, + token: { + tokenId: + '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484', + amount: '229400000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484', + amount: '229400000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: false, + }, + }, + ], + tokenId: MOCK_TOKEN_ID, + returned: [ + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: true, + }, + }, + ], + }, + { + description: 'We can get multiple mint batons', + utxos: [ + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484', + amount: '4588000000', + isMintBaton: false, + }, + }, + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484', + amount: '229400000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484', + amount: '229400000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: false, + }, + }, + ], + tokenId: MOCK_TOKEN_ID, + returned: [ + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: true, + }, + }, + ], + }, + { + description: + 'We return an empty array if no matches are found from a bad tokenId', + utxos: [ + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: false, + }, + }, + { + value: 546, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: false, + }, + }, + ], + tokenId: 'justsomestring', + returned: [], + }, + { + description: + 'We return an empty array if we have no mint batons for a given tokenId', + utxos: [ + { + value: 546, + token: { + tokenId: MOCK_TOKEN_ID, + amount: '1000', + isMintBaton: false, + }, + }, + { + value: 546, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: true, + }, + }, + { + value: 546, + token: { + tokenId: + 'a6050bea718f77e7964d140c4bb89cd88a1816eed1633f19d097835d5fa48df5', + amount: '1000', + isMintBaton: false, + }, + }, + ], + tokenId: MOCK_TOKEN_ID, + returned: [], + }, + ], + }, + getMintTargetOutputs: { + expectedReturns: [ + { + description: + 'Creates expected mint outputs for a 0-decimal token', + tokenId: MOCK_TOKEN_ID, + decimals: 0, + mintQty: '1000', + script: `6a04534c50000101044d494e5420${MOCK_TOKEN_ID}01020800000000000003e8`, + }, + { + description: + 'Creates expected mint outputs for a 9-decimal token', + tokenId: MOCK_TOKEN_ID, + decimals: 9, + mintQty: '1000.123456789', + script: `6a04534c50000101044d494e5420${MOCK_TOKEN_ID}010208000000e8dc00dd15`, + }, + { + description: + 'Can create a target output for the largest mint qty supported by slpv1', + tokenId: MOCK_TOKEN_ID, + decimals: 0, + mintQty: '18446744073709551615', + script: `6a04534c50000101044d494e5420${MOCK_TOKEN_ID}010208ffffffffffffffff`, + }, + ], + expectedErrors: [ + { + description: + 'Throws expected error if asked to mint 1 more than slpv1 max qty', + tokenId: MOCK_TOKEN_ID, + decimals: 0, + mintQty: '18446744073709551616', + error: 'bn outside of range', + }, + ], + }, + getMaxMintAmount: { + expectedReturns: [ + { + description: '0 decimals', + decimals: 0, + returned: '18446744073709551615', + }, + { + description: '1 decimals', + decimals: 1, + returned: '1844674407370955161.5', + }, + { + description: '2 decimals', + decimals: 2, + returned: '184467440737095516.15', + }, + { + description: '3 decimals', + decimals: 3, + returned: '18446744073709551.615', + }, + { + description: '4 decimals', + decimals: 4, + returned: '1844674407370955.1615', + }, + { + description: '5 decimals', + decimals: 5, + returned: '184467440737095.51615', + }, + { + description: '6 decimals', + decimals: 6, + returned: '18446744073709.551615', + }, + { + description: '7 decimals', + decimals: 7, + returned: '1844674407370.9551615', + }, + { + description: '8 decimals', + decimals: 8, + returned: '184467440737.09551615', + }, + { + description: '9 decimals', + decimals: 9, + returned: '18446744073.709551615', + }, + ], + }, }; diff --git a/cashtab/src/slpv1/index.js b/cashtab/src/slpv1/index.js --- a/cashtab/src/slpv1/index.js +++ b/cashtab/src/slpv1/index.js @@ -7,6 +7,7 @@ import { initializeScript } from 'opreturn'; import { opReturn } from 'config/opreturn'; import * as utxolib from '@bitgo/utxo-lib'; +import { undecimalizeTokenAmount } from 'wallet'; /** * Get targetOutput for a SLP v1 genesis tx @@ -117,7 +118,7 @@ /** * Get all available token utxos for an SLP v1 SEND tx from in-node formatted chronik utxos - * @param {Tx_InNode[]} utxos array of utxos from an in-node instance of chronik + * @param {ScriptUtxo_InNode[]} utxos array of utxos from an in-node instance of chronik * @param {string} tokenId * @returns {array} tokenUtxos, all utxos that can be used for slpv1 send tx * mint batons are intentionally excluded @@ -137,7 +138,7 @@ /** * Get send token inputs from in-node input data - * @param {Tx_inNode[]} utxos + * @param {ScriptUtxo_InNode[]} utxos * @param {string} tokenId tokenId of the token you want to send * @param {string} sendQty * @param {number} decimals 0-9 inclusive, integer. Decimals of this token. @@ -331,3 +332,92 @@ return Buffer.from(h.padStart(16, '0'), 'hex'); }; + +/** + * Get mint baton(s) for a given token + * @param {ScriptUtxo_InNode[]} utxos + * @param {string} tokenId + * @returns {ScriptUtxo_InNode[]} + */ +export const getMintBatons = (utxos, tokenId) => { + // From an array of chronik utxos, return only token utxos related to a given tokenId + return utxos.filter(utxo => { + if ( + utxo.token?.tokenId === tokenId && // UTXO matches the token ID. + utxo.token?.isMintBaton === true // UTXO is a minting baton. + ) { + return true; + } + return false; + }); +}; +/** + * Get targetOutput(s) for a SLP v1 MINT tx + * Note: Cashtab only supports slpv1 mints that preserve the baton at the wallet's address + * Spec: https://github.com/simpleledger/slp-specifications/blob/master/slp-token-type-1.md#mint---extended-minting-transaction + * @param {string} tokenId + * @param {number} decimals decimals for this tokenId + * @param {string} mintQty decimalized string for token qty * + * @throws {error} if invalid input params are passed to TokenType1.mint + * @returns {array} targetOutput(s), e.g. [{value: 0, script: }, {value: 546}, {value: 546}] + * Note: we always return minted qty at index 1 + * Note we always return a mint baton at index 2 + */ +export const getMintTargetOutputs = (tokenId, decimals, mintQty) => { + // slp-mdm expects values in token satoshis, so we must undecimalize mintQty + + // Get undecimalized string, i.e. "token satoshis" + const tokenSatoshis = undecimalizeTokenAmount(mintQty, decimals); + + // Convert to BN as this is what slp-mdm expects + const mintQtyBigNumber = new BN(tokenSatoshis); + + // Cashtab always puts the mint baton at mintBatonVout 2 + const CASHTAB_MINTBATON_VOUT = 2; + + const script = TokenType1.mint( + tokenId, + CASHTAB_MINTBATON_VOUT, + mintQtyBigNumber, + ); + + // Build targetOutputs per slpv1 spec + // Dust output at v1 receives the minted qty (per spec) + // Dust output at v2 for mint baton (per Cashtab) + + // Initialize with OP_RETURN at 0 index, per spec + // Note we do not include an address in outputs + // Cashtab behavior adds the wallet's change address if no output is added + const targetOutputs = [{ value: 0, script }]; + + // Add mint amount at index 1 + targetOutputs.push({ + value: appConfig.etokenSats, + }); + + // Add mint baton at index 2 + targetOutputs.push({ + value: appConfig.etokenSats, + }); + + return targetOutputs; +}; + +export const getMaxMintAmount = decimals => { + // 0xffffffffffffffff + const MAX_MINT_AMOUNT_TOKEN_SATOSHIS = '18446744073709551615'; + // The max amount depends on token decimals + // e.g. if decimals are 0, it's the same + // if decimals are 9, it's 18446744073.709551615 + if (decimals === 0) { + return MAX_MINT_AMOUNT_TOKEN_SATOSHIS; + } + const stringBeforeDecimalPoint = MAX_MINT_AMOUNT_TOKEN_SATOSHIS.slice( + 0, + MAX_MINT_AMOUNT_TOKEN_SATOSHIS.length - decimals, + ); + const stringAfterDecimalPoint = MAX_MINT_AMOUNT_TOKEN_SATOSHIS.slice( + -1 * decimals, + ); + return `${stringBeforeDecimalPoint}.${stringAfterDecimalPoint}`; +}; diff --git a/cashtab/src/validation/__tests__/index.test.js b/cashtab/src/validation/__tests__/index.test.js --- a/cashtab/src/validation/__tests__/index.test.js +++ b/cashtab/src/validation/__tests__/index.test.js @@ -29,6 +29,7 @@ parseAddressInput, isValidCashtabWallet, isValidTokenSendOrBurnAmount, + isValidTokenMintAmount, } from 'validation'; import { validXecAirdropExclusionList, @@ -619,4 +620,13 @@ }); }); }); + describe('Determines if a user input token mint amount is valid', () => { + const { expectedReturns } = vectors.isValidTokenMintAmount; + expectedReturns.forEach(expectedReturn => { + const { description, amount, decimals, returned } = expectedReturn; + it(`isValidTokenMintAmount: ${description}`, () => { + expect(isValidTokenMintAmount(amount, decimals)).toBe(returned); + }); + }); + }); }); diff --git a/cashtab/src/validation/fixtures/vectors.js b/cashtab/src/validation/fixtures/vectors.js --- a/cashtab/src/validation/fixtures/vectors.js +++ b/cashtab/src/validation/fixtures/vectors.js @@ -1650,4 +1650,126 @@ }, ], }, + isValidTokenMintAmount: { + expectedReturns: [ + { + description: + 'A decimalized string with no decimals is valid for a token with no decimals', + amount: '100', + decimals: 0, + returned: true, + }, + { + description: '0 is rejected', + amount: '0', + decimals: 0, + returned: 'Amount must be greater than 0', + }, + { + description: 'Blank input is rejected', + amount: '', + decimals: 0, + returned: 'Amount is required', + }, + { + description: 'Rejects non-string input', + amount: 50, + decimals: 0, + returned: 'Amount must be a string', + }, + { + description: + 'Rejects input including a decimal marker other than "."', + amount: '95,1', + decimals: 1, + returned: + 'Amount must be a non-empty string containing only decimal numbers and optionally one decimal point "."', + }, + { + description: 'Rejects input with multiple decimal points', + amount: '95.1.23', + decimals: 1, + returned: + 'Amount must be a non-empty string containing only decimal numbers and optionally one decimal point "."', + }, + { + description: + 'Rejects input multiple consecutive decimal points', + amount: '95..23', + decimals: 1, + returned: + 'Amount must be a non-empty string containing only decimal numbers and optionally one decimal point "."', + }, + { + description: 'Rejects input containing non-decimal characters', + amount: '100.a', + decimals: 1, + returned: + 'Amount must be a non-empty string containing only decimal numbers and optionally one decimal point "."', + }, + { + description: + 'We get non-plural error msg if token supports only 1 decimal place', + amount: '99.12', + decimals: 1, + returned: 'This token supports no more than 1 decimal place', + }, + { + description: + 'We cannot specify more decimal places than supported by the token', + amount: '99.123', + decimals: 2, + returned: 'This token supports no more than 2 decimal places', + }, + { + description: + 'We cannot have decimals for a token supporting 0 decimals', + amount: '99.1', + decimals: 0, + returned: 'This token does not support decimal places', + }, + { + description: + 'We can specify fewer decimal places than supported by the token', + amount: '99.123', + decimals: 9, + returned: true, + }, + { + description: + 'We can specify the exact decimal places supported by the token', + amount: '99.123456789', + decimals: 9, + returned: true, + }, + { + description: + 'We can include a decimal point at the end of the string and no decimal places', + amount: '99.', + decimals: 9, + returned: true, + }, + { + description: + 'We can include a decimal point at the end of the string and no decimal places, even if the token supports 0 decimals', + amount: '99.', + decimals: 0, + returned: true, + }, + { + description: 'We accept the max mint amount', + amount: '18446744073709551615', + decimals: 0, + returned: true, + }, + { + description: + 'We reject one token satoshi more than the max mint amount', + amount: '18446744073709551616', + decimals: 0, + returned: + 'Amount 18446744073709551616 exceeds max mint amount for this token (18446744073709551615)', + }, + ], + }, }; diff --git a/cashtab/src/validation/index.js b/cashtab/src/validation/index.js --- a/cashtab/src/validation/index.js +++ b/cashtab/src/validation/index.js @@ -20,6 +20,7 @@ import { fiatToSatoshis } from 'wallet'; import { UNKNOWN_TOKEN_ID } from 'config/CashtabCache'; import { STRINGIFIED_DECIMALIZED_REGEX } from 'wallet'; +import { getMaxMintAmount } from 'slpv1'; /** * Checks whether the instantiated sideshift library object has loaded @@ -828,3 +829,46 @@ } return true; }; + +/** + * Validate a token mint qty + * Same as isValidTokenSendOrBurnAmount except we do not care about baalnce + * @param {string} amount decimalized token string of mint amount, from user input, e.g. 100.123 + * @param {number} decimals 0, 1, 2, 3, 4, 5, 6, 7, 8, or 9 + */ +export const isValidTokenMintAmount = (amount, decimals) => { + if (typeof amount !== 'string') { + return 'Amount must be a string'; + } + if (amount === '') { + return 'Amount is required'; + } + if (amount === '0') { + return `Amount must be greater than 0`; + } + if (!STRINGIFIED_DECIMALIZED_REGEX.test(amount) || amount.length === 0) { + return `Amount must be a non-empty string containing only decimal numbers and optionally one decimal point "."`; + } + // Note: we do not validate decimals, as this is coming from token cache, which is coming from chronik + // The user is not inputting decimals + + if (amount.includes('.')) { + if (amount.toString().split('.')[1].length > decimals) { + if (decimals === 0) { + return `This token does not support decimal places`; + } + return `This token supports no more than ${decimals} decimal place${ + decimals === 1 ? '' : 's' + }`; + } + } + // Amount must be <= 0xffffffffffffffff in token satoshis for this token decimals + const amountBN = new BN(amount); + // Returns 1 if greater, -1 if less, 0 if the same, null if n/a + const maxMintAmount = getMaxMintAmount(decimals); + if (amountBN.gt(maxMintAmount)) { + return `Amount ${amount} exceeds max mint amount for this token (${maxMintAmount})`; + } + + return true; +};