From 758438df6b403075483f512e6888728d0aba2217 Mon Sep 17 00:00:00 2001 From: Matt Smithies Date: Thu, 17 Nov 2022 12:06:21 +0000 Subject: [PATCH 1/8] Create multitransfer_nft_hashgraph.test.js --- .../e2e/multitransfer_nft_hashgraph.test.js | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 __tests__/e2e/multitransfer_nft_hashgraph.test.js diff --git a/__tests__/e2e/multitransfer_nft_hashgraph.test.js b/__tests__/e2e/multitransfer_nft_hashgraph.test.js new file mode 100644 index 0000000..7f74b8c --- /dev/null +++ b/__tests__/e2e/multitransfer_nft_hashgraph.test.js @@ -0,0 +1,110 @@ +import HashgraphClient from "app/hashgraph/client" + +const client = new HashgraphClient() + +// NFT is reused for different tests +let nft + +// Account of dummy user claiming NFTs from treasury +let dummyAccount + +// Reused please +const TOKENS_SUPPLY = 200 + +// Utility for sending batch. +const sendBatch = async amount => { + if (!dummyAccount || !nft) { + throw Error('NFT or account state not initialised.') + } + + return await client.multipleNftTransfer({ + token_id: nft.token_id, + receiver_id: dummyAccount.accountId, + serials: Array(amount).fill(1).map((e, i) => i + e) + }) +} + +const sendBatchUsingMirrornode = async (amount = 30) => { + if (!dummyAccount || !nft) { + throw Error('NFT or account state not initialised.') + } + + return await client.batchTransferNft({ + token_id: nft.token_id, + // token_id: '0.0.48905114', + receiver_id: dummyAccount.accountId, + amount + }) + +} + +const mintMoarNfts = async (amount = 10) => { + const mintNfts = { + amount, + token_id: nft.token_id, + cid: 'xxx' + } + + return await client.mintNonFungibleToken(mintNfts) +} + +// This will be generated uniquely per NFT (low-priority test) + +test("This test will create an NFT and mint all tokens", async () => { + + const passTokenData = { + supply: TOKENS_SUPPLY, + collection_name: 'example nft', + symbol: 'te-e2e-nft' + } + + nft = await client.createNonFungibleToken(passTokenData) + + const mint = await mintMoarNfts(5) + + expect(mint.token_id).toBe(nft.token_id) + + console.log(mint) + +}, 30000) + +// Normally this would hit an end point for an integration test +test("This will create an account and attempt to send a multiple transfer to account", async () => { + + dummyAccount = await client.createAccount() + + // This should fail as we have only minted 5 + const badSend = await sendBatch(6) + + expect(badSend.total).toBe(0) + expect(badSend.error).toBeTruthy() + + const goodSend = await sendBatch(5) + + expect(goodSend.total).toBe(5) + expect(goodSend.error).toBeFalsy() + +}, 20000) + + +test("Send a big'ol batch of NFTs!", async () => { + + const send = await sendBatchUsingMirrornode() + + expect(send.error[0]).toBe(`The treasury does not hold the amount of NFTs of id ${nft.token_id} to do the required batch transfer`) + + // Due to mirrornode limitations we can't test this in real time + // await mintMoarNfts() + // await mintMoarNfts() + // await mintMoarNfts() + // await mintMoarNfts() + // await mintMoarNfts() + // await mintMoarNfts() + // await mintMoarNfts() + // + // const sendMoar = await sendBatchUsingMirrornode(38) + // + // console.log(sendMoar) + // expect(sendMoar.results.length).toBeTruthy() + +}, 20000) From b05cf36f41505e4fb5db3c0e42f57203939dee99 Mon Sep 17 00:00:00 2001 From: Matt Smithies Date: Thu, 17 Nov 2022 12:06:32 +0000 Subject: [PATCH 2/8] Create batchTransferNftHandler.js --- app/handler/batchTransferNftHandler.js | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/handler/batchTransferNftHandler.js diff --git a/app/handler/batchTransferNftHandler.js b/app/handler/batchTransferNftHandler.js new file mode 100644 index 0000000..07d7221 --- /dev/null +++ b/app/handler/batchTransferNftHandler.js @@ -0,0 +1,34 @@ +import Response from "app/response" +import transferNftRequest from "app/validators/transferNftRequest" +import batchTransferNftRequest from "../validators/batchTransferNftRequest" + +async function BatchTransferNftHandler(req, res) { + const validationErrors = batchTransferNftRequest(req.body) + + if (validationErrors) { + return Response.unprocessibleEntity(res, validationErrors) + } + + const { receiver_id, token_id, amount } = req.body + + const batchTransferPayload = { + receiver_id, + token_id, + amount + } + + const { hashgraphClient } = req.context + const sendResponse = await hashgraphClient.batchTransferNft(batchTransferPayload) + + if (sendResponse.error) { + return Response.unprocessibleEntity(res, sendResponse.error) + } + + if (sendResponse) { + return Response.json(res, sendResponse) + } + + return Response.badRequest(res) +} + +export default BatchTransferNftHandler From 01e37d742b69340fd571dc692a96bd0a1bdfbc28 Mon Sep 17 00:00:00 2001 From: Matt Smithies Date: Thu, 17 Nov 2022 12:06:39 +0000 Subject: [PATCH 3/8] Update client.js --- app/hashgraph/client.js | 117 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/app/hashgraph/client.js b/app/hashgraph/client.js index 1b626a5..1b52287 100644 --- a/app/hashgraph/client.js +++ b/app/hashgraph/client.js @@ -30,6 +30,7 @@ import Explorer from "app/utils/explorer" import sendWebhookMessage from "app/utils/sendWebhookMessage" import Mirror from "app/utils/mirrornode" import Specification from "app/hashgraph/tokens/specifications" +import Batchable from "app/utils/batchable" class HashgraphClient extends HashgraphClientContract { // Keep a private internal reference to SDK client @@ -683,6 +684,122 @@ class HashgraphClient extends HashgraphClientContract { } } } + + /** + * Given a token id and a receiver, attempt to send tokens based on limit + * return status of transfer. + * + * @param token_id + * @param receiver_id + * @param ser + * @returns {Promise} + */ + multipleNftTransfer = async ({ + token_id, + receiver_id, + serials + }) => { + + const client = this.#client + + const transfer = await new TransferTransaction() + + serials.map(serial => { + transfer.addNftTransfer( + new NftId(token_id, serial), + Config.accountId, + receiver_id + ) + }) + + // We are making the assumption that if the transaction is successful NFTs are sent + try { + const tx = await transfer.execute(client) + + await tx.getReceipt(client) + + return { + serials, + total: serials.length + } + } catch (e) { + return { + error: e.message.toString(), + total: 0 + } + } + } + + /** + * Attempt to transfer a batch of NFTs, of a particular amount + * + * We check that + * + * @param token_id + * @param receiver_id + * @param amount + * @returns {Promise<{error: string}|{transaction_id: string, amount: (number|*), receiver_id}>} + */ + batchTransferNft = async ({ token_id, receiver_id, amount }) => { + const hasNft = await Mirror.checkTreasuryHasNftAmount(token_id, amount) + + if (!hasNft) { + return { + errors: [ + `The treasury does not hold the amount of NFTs of id ${token_id} to do the required batch transfer` + ] + } + } + + const transferCycleLimits = Batchable.nftTransfer(amount) + + // Required recur fn needed for pagination + const sendNftTransaction = async (limit, paginationLink) => { + const nfts = await Mirror.fetchNftIdsForBatchTransfer(token_id, limit, paginationLink) + + const transfer = await this.multipleNftTransfer({ + token_id, + receiver_id, + serials: nfts.serials + }) + + return { + ...nfts, + ...transfer + } + } + + const cycleBatchTransfers = async (cycle, results = [], paginationLink) => { + + if (!cycle.length) { + return results + } + + const limit = cycle.shift() + + const transfer = await sendNftTransaction(limit, paginationLink) + + results.push(transfer) + + return cycleBatchTransfers(cycle, results, transfer.link) + } + + const results = await cycleBatchTransfers(transferCycleLimits) + + const errors = results.map(e => e.errors).filter(e => e) + + if (errors.length) { + return { + errors + } + } + + return { + results, + expected: amount, + actual_sent: results.map(e => e.total).reduce((e, n) => e + n) + } + } } export default HashgraphClient From e48a97ff0871e6861608186d4b0b308071931b32 Mon Sep 17 00:00:00 2001 From: Matt Smithies Date: Thu, 17 Nov 2022 12:06:43 +0000 Subject: [PATCH 4/8] Create batchable.js --- app/utils/batchable.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 app/utils/batchable.js diff --git a/app/utils/batchable.js b/app/utils/batchable.js new file mode 100644 index 0000000..eee2b2d --- /dev/null +++ b/app/utils/batchable.js @@ -0,0 +1,29 @@ + +const BATCH_LIMITS = { + nftTransfers: 10 +} + +/** + * Create an array of cycles of NFT transfers based on an amount and a max limit. + * + * @param amount + * @returns {any[]} + */ +function nftTransfer(amount) { + + // mod rem diff + const rem = amount % BATCH_LIMITS.nftTransfers + + // Basal cycle for batch, as a whole number + const max = (amount - rem) / BATCH_LIMITS.nftTransfers + + // When rem is falsely, remove -- more simple then if + const cycle = Array(max).fill(BATCH_LIMITS.nftTransfers).concat(rem).filter(e => e) + + // For readability + return cycle +} + +export default { + nftTransfer +} From 398c64c6ef8df564356e7008ab5c83282f834dac Mon Sep 17 00:00:00 2001 From: Matt Smithies Date: Thu, 17 Nov 2022 12:06:49 +0000 Subject: [PATCH 5/8] Update mirrornode.js --- app/utils/mirrornode.js | 70 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/app/utils/mirrornode.js b/app/utils/mirrornode.js index d215c69..37b1af4 100644 --- a/app/utils/mirrornode.js +++ b/app/utils/mirrornode.js @@ -10,6 +10,9 @@ const queryNftAccountOwner = (token_id, serial) => `${mirrornode}/api/v1/tokens/${token_id}/nfts/${serial}` const queryNftForOwner = (token_id, account_id) => `${mirrornode}/api/v1/tokens/${token_id}/nfts/?account.id=${account_id}` +const queryTreasuryTokenBalance = (token_id, account_id) => `${mirrornode}/api/v1/tokens/${token_id}/balances/?account.id=${account_id}` +const getNftByLimit = (token_id, account_id, limit = 20) => `${mirrornode}/api/v1/tokens/${token_id}/nfts?account.id=${account_id}&order=asc&limit=${limit}` +const queryReq = (next) => `${mirrornode}${next}` const MIRRORNODE_WAIT_MS = 500 const MIRRORNODE_TRIES = 5 @@ -78,6 +81,69 @@ async function checkTreasuryHasNft( return result.data.account_id === expected } +/** + * The primary purpose of this method is to detect whether the Treasury has enough + * tokens in treasury to satisfy the batch transfer of NFTs + * + * @param nft_id + * @param amount + * @param expected + * @returns {Promise} + */ +async function checkTreasuryHasNftAmount( + nft_id, + amount, + expected = Config.accountId +) { + const result = await retryableMirrorQuery( + queryTreasuryTokenBalance(nft_id, expected) + ) + + const { + balances + } = result.data + + if (!balances.length) { + return false + } + + return balances[0].balance >= amount +} + + +/** + * Fetch the nft ids for a particular NFT tx, include the "next" id + * + * @param nft_id + * @param limit + * @param expected + * @param link + * @returns {Promise} + */ +async function fetchNftIdsForBatchTransfer( + nft_id, + limit, + link, + expected = Config.accountId +) { + const result = await retryableMirrorQuery( + link ? queryReq(link) : getNftByLimit(nft_id, expected, limit) + ) + + const { + nfts, + links + } = result.data + + const serials = nfts.splice(0, limit) + + return { + actual: serials.length, + serials: serials.map(nft => nft.serial_number), + link: links.next + } +} + /** * Check if a given account ID has ownership of an NFT, returned is the list of serial numbers of the * NFT they own, as well as a reference to links for whale owners 🐳 @@ -156,5 +222,7 @@ export default { checkTreasuryHasNft, getSerialNumbersOfOwnedNft, fetchTokenInformation, - ensureClaimableChildNftIsTransferable + ensureClaimableChildNftIsTransferable, + checkTreasuryHasNftAmount, + fetchNftIdsForBatchTransfer } From 17665648c2d07d7dbb60e1131a34bf8ca4793255 Mon Sep 17 00:00:00 2001 From: Matt Smithies Date: Thu, 17 Nov 2022 12:06:53 +0000 Subject: [PATCH 6/8] Create batchTransferNftRequest.js --- app/validators/batchTransferNftRequest.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 app/validators/batchTransferNftRequest.js diff --git a/app/validators/batchTransferNftRequest.js b/app/validators/batchTransferNftRequest.js new file mode 100644 index 0000000..060baff --- /dev/null +++ b/app/validators/batchTransferNftRequest.js @@ -0,0 +1,17 @@ +const Joi = require("@hapi/joi") + +const schema = Joi.object({ + token_id: Joi.string().required(), + receiver_id: Joi.string().required(), + amount: Joi.number().required() +}) + +function batchTransferNftRequest(candidate = {}) { + const validation = schema.validate(candidate || {}) + + if (validation.error) { + return validation.error.details.map(error => error.message) + } +} + +export default batchTransferNftRequest From 2a38d3f141a8a69b06a81d0275fc0ad90323f57d Mon Sep 17 00:00:00 2001 From: Matt Smithies Date: Thu, 17 Nov 2022 12:06:56 +0000 Subject: [PATCH 7/8] Create batch.js --- pages/api/nft/batch.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 pages/api/nft/batch.js diff --git a/pages/api/nft/batch.js b/pages/api/nft/batch.js new file mode 100644 index 0000000..57ea6d1 --- /dev/null +++ b/pages/api/nft/batch.js @@ -0,0 +1,15 @@ +import onlyPost from "app/middleware/onlyPost" +import withAuthentication from "app/middleware/withAuthentication" +import useHashgraphContext from "app/context/useHashgraphContext" +import prepare from "app/utils/prepare" +import ensureEncryptionKey from "app/middleware/ensureEncryptionKey" +import batchTransferNftHandler from "app/handler/batchTransferNftHandler" +import ensureMirrornodeSet from "app/middleware/ensureMirrornodeSet" + +export default prepare( + onlyPost, + ensureEncryptionKey, + ensureMirrornodeSet, + withAuthentication, + useHashgraphContext +)(batchTransferNftHandler) From 5b674160212c4072ea7db59f1f369c2b516e555a Mon Sep 17 00:00:00 2001 From: Matt Smithies Date: Thu, 17 Nov 2022 12:11:47 +0000 Subject: [PATCH 8/8] chore lint --- app/handler/batchTransferNftHandler.js | 4 +++- app/hashgraph/client.js | 14 ++++++-------- app/utils/batchable.js | 9 +++++---- app/utils/mirrornode.js | 18 +++++++----------- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/app/handler/batchTransferNftHandler.js b/app/handler/batchTransferNftHandler.js index 07d7221..89a9ed6 100644 --- a/app/handler/batchTransferNftHandler.js +++ b/app/handler/batchTransferNftHandler.js @@ -18,7 +18,9 @@ async function BatchTransferNftHandler(req, res) { } const { hashgraphClient } = req.context - const sendResponse = await hashgraphClient.batchTransferNft(batchTransferPayload) + const sendResponse = await hashgraphClient.batchTransferNft( + batchTransferPayload + ) if (sendResponse.error) { return Response.unprocessibleEntity(res, sendResponse.error) diff --git a/app/hashgraph/client.js b/app/hashgraph/client.js index 1b52287..3c92ad9 100644 --- a/app/hashgraph/client.js +++ b/app/hashgraph/client.js @@ -694,12 +694,7 @@ class HashgraphClient extends HashgraphClientContract { * @param ser * @returns {Promise} */ - multipleNftTransfer = async ({ - token_id, - receiver_id, - serials - }) => { - + multipleNftTransfer = async ({ token_id, receiver_id, serials }) => { const client = this.#client const transfer = await new TransferTransaction() @@ -755,7 +750,11 @@ class HashgraphClient extends HashgraphClientContract { // Required recur fn needed for pagination const sendNftTransaction = async (limit, paginationLink) => { - const nfts = await Mirror.fetchNftIdsForBatchTransfer(token_id, limit, paginationLink) + const nfts = await Mirror.fetchNftIdsForBatchTransfer( + token_id, + limit, + paginationLink + ) const transfer = await this.multipleNftTransfer({ token_id, @@ -770,7 +769,6 @@ class HashgraphClient extends HashgraphClientContract { } const cycleBatchTransfers = async (cycle, results = [], paginationLink) => { - if (!cycle.length) { return results } diff --git a/app/utils/batchable.js b/app/utils/batchable.js index eee2b2d..a3c4360 100644 --- a/app/utils/batchable.js +++ b/app/utils/batchable.js @@ -1,4 +1,3 @@ - const BATCH_LIMITS = { nftTransfers: 10 } @@ -10,15 +9,17 @@ const BATCH_LIMITS = { * @returns {any[]} */ function nftTransfer(amount) { - // mod rem diff const rem = amount % BATCH_LIMITS.nftTransfers // Basal cycle for batch, as a whole number const max = (amount - rem) / BATCH_LIMITS.nftTransfers - // When rem is falsely, remove -- more simple then if - const cycle = Array(max).fill(BATCH_LIMITS.nftTransfers).concat(rem).filter(e => e) + // When rem is falsely, remove -- more simple then if + const cycle = Array(max) + .fill(BATCH_LIMITS.nftTransfers) + .concat(rem) + .filter(e => e) // For readability return cycle diff --git a/app/utils/mirrornode.js b/app/utils/mirrornode.js index 37b1af4..b840516 100644 --- a/app/utils/mirrornode.js +++ b/app/utils/mirrornode.js @@ -10,9 +10,11 @@ const queryNftAccountOwner = (token_id, serial) => `${mirrornode}/api/v1/tokens/${token_id}/nfts/${serial}` const queryNftForOwner = (token_id, account_id) => `${mirrornode}/api/v1/tokens/${token_id}/nfts/?account.id=${account_id}` -const queryTreasuryTokenBalance = (token_id, account_id) => `${mirrornode}/api/v1/tokens/${token_id}/balances/?account.id=${account_id}` -const getNftByLimit = (token_id, account_id, limit = 20) => `${mirrornode}/api/v1/tokens/${token_id}/nfts?account.id=${account_id}&order=asc&limit=${limit}` -const queryReq = (next) => `${mirrornode}${next}` +const queryTreasuryTokenBalance = (token_id, account_id) => + `${mirrornode}/api/v1/tokens/${token_id}/balances/?account.id=${account_id}` +const getNftByLimit = (token_id, account_id, limit = 20) => + `${mirrornode}/api/v1/tokens/${token_id}/nfts?account.id=${account_id}&order=asc&limit=${limit}` +const queryReq = next => `${mirrornode}${next}` const MIRRORNODE_WAIT_MS = 500 const MIRRORNODE_TRIES = 5 @@ -99,9 +101,7 @@ async function checkTreasuryHasNftAmount( queryTreasuryTokenBalance(nft_id, expected) ) - const { - balances - } = result.data + const { balances } = result.data if (!balances.length) { return false @@ -110,7 +110,6 @@ async function checkTreasuryHasNftAmount( return balances[0].balance >= amount } - /** * Fetch the nft ids for a particular NFT tx, include the "next" id * @@ -130,10 +129,7 @@ async function fetchNftIdsForBatchTransfer( link ? queryReq(link) : getNftByLimit(nft_id, expected, limit) ) - const { - nfts, - links - } = result.data + const { nfts, links } = result.data const serials = nfts.splice(0, limit)