Skip to content

Commit

Permalink
Merge pull request #47 from trustenterprises/feature/batch-nfts-transfer
Browse files Browse the repository at this point in the history
Batch NFT transfer
  • Loading branch information
mattsmithies authored Nov 17, 2022
2 parents 2e4f266 + 5b67416 commit 4e2f75a
Show file tree
Hide file tree
Showing 7 changed files with 388 additions and 1 deletion.
110 changes: 110 additions & 0 deletions __tests__/e2e/multitransfer_nft_hashgraph.test.js
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 36 additions & 0 deletions app/handler/batchTransferNftHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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
115 changes: 115 additions & 0 deletions app/hashgraph/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -683,6 +684,120 @@ 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<void>}
*/
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
30 changes: 30 additions & 0 deletions app/utils/batchable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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
}
66 changes: 65 additions & 1 deletion app/utils/mirrornode.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +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 MIRRORNODE_WAIT_MS = 500
const MIRRORNODE_TRIES = 5
Expand Down Expand Up @@ -78,6 +83,63 @@ 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<boolean>}
*/
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<boolean>}
*/
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 🐳
Expand Down Expand Up @@ -156,5 +218,7 @@ export default {
checkTreasuryHasNft,
getSerialNumbersOfOwnedNft,
fetchTokenInformation,
ensureClaimableChildNftIsTransferable
ensureClaimableChildNftIsTransferable,
checkTreasuryHasNftAmount,
fetchNftIdsForBatchTransfer
}
Loading

0 comments on commit 4e2f75a

Please sign in to comment.