Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Batch NFT transfer #47

Merged
merged 8 commits into from
Nov 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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