diff --git a/cashtab/src/components/Etokens/Token/index.js b/cashtab/src/components/Etokens/Token/index.js --- a/cashtab/src/components/Etokens/Token/index.js +++ b/cashtab/src/components/Etokens/Token/index.js @@ -42,6 +42,7 @@ getNftParentFanTxTargetOutputs, getNft, getNftChildSendTargetOutputs, + getAgoraAdFuelSats, } from 'slpv1'; import { sendXec } from 'transactions'; import { @@ -907,10 +908,6 @@ }; const listNft = async () => { - // To guarantee we have no utxo conflicts while sending a chain of 2 txs - // We ensure that the target output of the ad setup tx will include enough XEC - // to cover the offer tx - const AD_SETUP_SATOSHIS = 3000; const listPriceSatoshis = selectedCurrency === appConfig.ticker ? toSatoshis(formData.nftListPrice) @@ -968,6 +965,32 @@ const agoraAdScript = agoraOneshot.adScript(); const agoraAdP2sh = Script.p2sh(shaRmd160(agoraAdScript.bytecode)); + // We need to calculate the fee of the offer tx before we build the + // "ad prep" tx + + // Determine the offerTx parameters before building txs, so we can + // accurately calculate its fee + const agoraScript = agoraOneshot.script(); + const agoraP2sh = Script.p2sh(shaRmd160(agoraScript.bytecode)); + + const offerTargetOutputs = [ + { + value: 0, + script: slpSend(tokenId, SLP_NFT1_CHILD, [1]), + }, + { value: appConfig.dustSats, script: agoraP2sh }, + ]; + const offerTxFuelSats = getAgoraAdFuelSats( + agoraAdScript, + AgoraOneshotAdSignatory(sellerSk), + offerTargetOutputs, + satsPerKb, + ); + + // So, the ad prep tx must include an output with an input that covers this fee + // This will be dust + fee + const adFuelOutputSats = appConfig.dustSats + offerTxFuelSats; + // Input needs to be the child NFT utxo with appropriate signData // Get the NFT utxo from Cashtab wallet const [thisNftUtxo] = getNft(tokenId, wallet.state.slpUtxos); @@ -992,7 +1015,7 @@ value: 0, script: slpSend(tokenId, SLP_NFT1_CHILD, [1]), }, - { value: AD_SETUP_SATOSHIS, script: agoraAdP2sh }, + { value: adFuelOutputSats, script: agoraAdP2sh }, ]; // Broadcast the ad setup tx @@ -1029,10 +1052,6 @@ return; } - // Seller finishes offer setup + sends NFT to the advertised P2SH - const agoraScript = agoraOneshot.script(); - const agoraP2sh = Script.p2sh(shaRmd160(agoraScript.bytecode)); - const offerInputs = [ // The actual NFT { @@ -1044,20 +1063,13 @@ outIdx: 1, }, signData: { - value: AD_SETUP_SATOSHIS, + value: adFuelOutputSats, redeemScript: agoraAdScript, }, }, signatory: AgoraOneshotAdSignatory(sellerSk), }, ]; - const offerTargetOutputs = [ - { - value: 0, - script: slpSend(tokenId, SLP_NFT1_CHILD, [1]), - }, - { value: appConfig.dustSats, script: agoraP2sh }, - ]; let offerTxid; try { diff --git a/cashtab/src/components/Etokens/__tests__/TokenActions.test.js b/cashtab/src/components/Etokens/__tests__/TokenActions.test.js --- a/cashtab/src/components/Etokens/__tests__/TokenActions.test.js +++ b/cashtab/src/components/Etokens/__tests__/TokenActions.test.js @@ -660,9 +660,9 @@ // NFT ad prep const adPrepHex = - '0200000002268322a2a8e67fe9efdaf15c9eb7397fb640ae32d8a245c2933f9eb967ff9b5d010000006441645e5d4141c2e9207f5a743a0f672f0710a352347df32954c3a24a19c64e8d9b95007335bf5491d4f69eac89a389fb1cbc3b868f3342ac38a7e23b5c8976c72e4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffffef76d01776229a95c45696cf68f2f98c8332d0c53e3f24e73fd9c6deaf7926180300000064418a094d8cccbff0073e0b97fb84dfb849766545cbd4a764ad1d68b9a8ca5822feea93833c4df54fc5d7b52a73f0ba0fdf987a78f7481a5c55d351472f152931d04121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff030000000000000000376a04534c500001410453454e44205d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326080000000000000001b80b00000000000017a91407d2b0e6ec7b96cbfbe4a7d54e28d28fbcf65e4087f2290f00000000001976a91400549451e5c22b18686cacdf34dce649e5ec3be288ac00000000'; + '0200000002268322a2a8e67fe9efdaf15c9eb7397fb640ae32d8a245c2933f9eb967ff9b5d010000006441e4365d350b1dfee55e60cc2600ba094ed0e05c1d6a297bd3fe3f0721b88d9ec09b7d114cf0aab08a3b264153858f1a48f839f3639a8a8f9b11214038080cb9e34121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffffef76d01776229a95c45696cf68f2f98c8332d0c53e3f24e73fd9c6deaf7926180300000064411e9913b28017832fa38944675eb8815411fd210f9dfc8f0aa806bed055f52b6592488fdd1f9be942c19dcb98d7ddd7c55bc8b1233a64ad3dfa1c65eebbd48f254121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff030000000000000000376a04534c500001410453454e44205d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a22283260800000000000000019a0400000000000017a91407d2b0e6ec7b96cbfbe4a7d54e28d28fbcf65e408710310f00000000001976a91400549451e5c22b18686cacdf34dce649e5ec3be288ac00000000'; const adPrepTxid = - '6543a807c83363e5d43587dcd15239b9c82a485e2b1ebbd730e82a0f0db4385f'; + '7b4f2b1cf9716ead03f91910bd0c08956c381987e1cb3cd9f9b4d555a7b9ba25'; mockedChronik.setMock('broadcastTx', { input: adPrepHex, @@ -671,9 +671,9 @@ // NFT ad list const adListHex = - '02000000015f38b40d0f2ae830d7bb1e2b5e482ac8b93952d1dc8735d4e56333c807a8436501000000a70441475230074f4e4553484f54416c8c3c5fb1d038dcce630a1504a04c2333c50fddfbb35b37c01ad4e89f0e2aae103703f2a2cf94cef51cbeff6c442feb961f4ec2fc948d59dc61b6ee83b3f262414c56222b50fe00000000001976a91400549451e5c22b18686cacdf34dce649e5ec3be288ac7521031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dad074f4e4553484f5488044147523087ffffffff030000000000000000376a04534c500001410453454e44205d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326080000000000000001220200000000000017a914729833ae294590bbcf28bfbb9ad54c01b6cdb62887da060000000000001976a91400549451e5c22b18686cacdf34dce649e5ec3be288ac00000000'; + '020000000125bab9a755d5b4f9d93ccbe18719386c95080cbd1019f903ad6e71f91c2b4f7b01000000a70441475230074f4e4553484f544106bd7c3cc4f6aca45a7f97644b8cb5e745dee224246f38605171e8f9e0d6e036af3ea4853b08e1baa92e091bd0ceabf83d4a246e07e6b0b008a3e091b111f22a414c56222b50fe00000000001976a91400549451e5c22b18686cacdf34dce649e5ec3be288ac7521031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dad074f4e4553484f5488044147523087ffffffff020000000000000000376a04534c500001410453454e44205d9bff67b99e3f93c245a2d832ae40b67f39b79e5cf1daefe97fe6a8a2228326080000000000000001220200000000000017a914729833ae294590bbcf28bfbb9ad54c01b6cdb6288700000000'; const adListTxid = - 'd732c995d4071786a689dff175c60982c57a14a3e2f6362ee0418fba6a5eca7f'; + '97cf0fed5062419ad456f22457cfeb3b15909f1de2350be48c53b24944e0de89'; mockedChronik.setMock('broadcastTx', { input: adListHex, 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 @@ -20,11 +20,49 @@ getNft, getNftChildSendTargetOutputs, isTokenDustChangeOutput, + getAgoraAdFuelSats, } from 'slpv1'; import vectors from '../fixtures/vectors'; -import { SEND_DESTINATION_ADDRESS } from '../fixtures/vectors'; +import { SEND_DESTINATION_ADDRESS, MOCK_TOKEN_ID } from '../fixtures/vectors'; +import { AgoraOneshot, AgoraOneshotAdSignatory } from 'ecash-agora'; +import { + initWasm, + slpSend, + SLP_NFT1_CHILD, + shaRmd160, + Script, + fromHex, +} from 'ecash-lib'; +import appConfig from 'config/app'; + +const MOCK_WALLET_HASH = fromHex('12'.repeat(20)); +const MOCK_PK = fromHex( + '03000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', +); describe('slpv1 methods', () => { + let MOCK_AGORA_P2SH, MOCK_ONESHOT; + beforeAll(async () => { + // Initialize web assembly + await initWasm(); + + MOCK_ONESHOT = new AgoraOneshot({ + enforcedOutputs: [ + { + value: 0, + script: slpSend(MOCK_TOKEN_ID, SLP_NFT1_CHILD, [0, 1]), + }, + { + value: 10000, // list price satoshis + script: Script.p2pkh(MOCK_WALLET_HASH), + }, + ], + cancelPk: MOCK_PK, + }); + MOCK_AGORA_P2SH = Script.p2sh( + shaRmd160(MOCK_ONESHOT.script().bytecode), + ); + }); describe('Generating etoken genesis tx target outputs', () => { const { expectedReturns, expectedErrors } = vectors.getSlpGenesisTargetOutput; @@ -363,4 +401,46 @@ }); }); }); + describe.only('getAgoraAdFuelSats correctly determines one-input fee for an agora offer tx', () => { + const MOCK_WALLET_SK = fromHex('33'.repeat(32)); + const SATS_PER_KB_MIN = 1000; + const SATS_PER_KB_ALT = 2000; + + // Note: for NFT listings, this is more or less constant, at least in Cashtab + // maybe you could have a case where sendAmounts array is not [1], mb you have a weird + // NFT with "change" ... will not see this in Cashtab + // So, arguably this function could be a constant. However, we will extend to support + // partial agora offers, and we may change how these offers are made in the future + const mockOfferOutputs = [ + { + value: 0, + script: slpSend(MOCK_TOKEN_ID, SLP_NFT1_CHILD, [1]), + }, + { + value: appConfig.dustSats, + script: MOCK_AGORA_P2SH, + }, + ]; + + it(`getAgoraAdFuelSats for minimum eCash fee`, () => { + expect( + getAgoraAdFuelSats( + MOCK_ONESHOT.adScript(), + AgoraOneshotAdSignatory(MOCK_WALLET_SK), + mockOfferOutputs, + SATS_PER_KB_MIN, + ), + ).toEqual(314); + }); + it(`getAgoraAdFuelSats for a different fee level`, () => { + expect( + getAgoraAdFuelSats( + MOCK_ONESHOT.adScript(), + AgoraOneshotAdSignatory(MOCK_WALLET_SK), + mockOfferOutputs, + SATS_PER_KB_ALT, + ), + ).toEqual(628); + }); + }); }); 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 @@ -4,7 +4,14 @@ import appConfig from 'config/app'; import { undecimalizeTokenAmount, decimalizeTokenAmount } from 'wallet'; -import { Script, slpGenesis, slpSend, slpMint } from 'ecash-lib'; +import { + Script, + slpGenesis, + slpSend, + slpMint, + TxBuilder, + EccDummy, +} from 'ecash-lib'; // Constants for SLP 1 token types as returned by chronik-client export const SLP_1_PROTOCOL_NUMBER = 1; @@ -13,6 +20,9 @@ // 0xffffffffffffffff export const MAX_MINT_AMOUNT_TOKEN_SATOSHIS = '18446744073709551615'; +const DUMMY_TXID = + '1111111111111111111111111111111111111111111111111111111111111111'; + // Cashtab spec // This is how Cashtab defines a token utxo to be received // by the wallet broadcasting this transaction. @@ -611,3 +621,52 @@ targetOutput.value === appConfig.dustSats ); }; + +/** + * For ecash-agora SLP1 listings txs, an "ad setup tx" is required before + * we can actually broadcast the offer + * + * We want to minimize the amount of XEC we need to make these two required txs + * + * So, we calculate the fee needed to send the 2nd tx (the offer tx) + * We will then use this fee to size the output of the first tx to exactly + * cover the 2nd tx + */ +export const getAgoraAdFuelSats = ( + redeemScript, + signatory, + offerOutputs, + satsPerKb, +) => { + // First, get the size of the listing tx + const dummyOfferTx = new TxBuilder({ + inputs: [ + { + input: { + prevOut: { + // Use a placeholder 32-byte txid + txid: DUMMY_TXID, + // The outIdx will always be 1 in Cashtab + // In practice, this does not impact the tx size calculation + outIdx: 1, + }, + signData: { + // Arbitrary value that we know will cover the fee for this tx, + // which will always have only one input in Cashtab + value: 10000, + redeemScript, + }, + }, + signatory, + }, + ], + outputs: offerOutputs, + }); + const measureTx = dummyOfferTx.sign(new EccDummy()); + + const dummyOfferTxSats = Math.ceil( + (measureTx.serSize() * satsPerKb) / 1000, + ); + + return dummyOfferTxSats; +}; diff --git a/cashtab/src/transactions/index.js b/cashtab/src/transactions/index.js --- a/cashtab/src/transactions/index.js +++ b/cashtab/src/transactions/index.js @@ -185,6 +185,7 @@ // Otherwise, broadcast the tx const txSer = tx.ser(); const hex = toHex(txSer); + console.log(`hex`, hex); // Will throw error on node failing to broadcast tx // e.g. 'txn-mempool-conflict (code 18)' const response = await chronik.broadcastTx(hex, isBurn);