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;
+};