diff --git a/examples/token-reject.js b/examples/token-reject.js new file mode 100644 index 000000000..d90c4d64c --- /dev/null +++ b/examples/token-reject.js @@ -0,0 +1,241 @@ +import { + AccountCreateTransaction, + PrivateKey, + TokenCreateTransaction, + TransferTransaction, + AccountId, + Client, + TokenType, + TokenMintTransaction, + TokenRejectTransaction, + TokenRejectFlow, + NftId, + AccountBalanceQuery, + TokenSupplyType, +} from "@hashgraph/sdk"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function main() { + if ( + process.env.OPERATOR_ID == null || + process.env.OPERATOR_KEY == null || + process.env.HEDERA_NETWORK == null + ) { + throw new Error( + "Environment variables OPERATOR_ID, HEDERA_NETWORK, and OPERATOR_KEY are required.", + ); + } + const CID = [ + "QmNPCiNA3Dsu3K5FxDPMG5Q3fZRwVTg14EXA92uqEeSRXn", + "QmZ4dgAgt8owvnULxnKxNe8YqpavtVCXmc1Lt2XajFpJs9", + "QmPzY5GxevjyfMUF5vEAjtyRoigzWp47MiKAtLBduLMC1T", + ]; + const operatorId = AccountId.fromString(process.env.OPERATOR_ID); + const operatorKey = PrivateKey.fromStringED25519(process.env.OPERATOR_KEY); + const network = process.env.HEDERA_NETWORK; + const client = Client.forName(network).setOperator(operatorId, operatorKey); + + // create a treasury account + const treasuryPrivateKey = PrivateKey.generateED25519(); + const treasuryAccountId = ( + await ( + await new AccountCreateTransaction() + .setKey(treasuryPrivateKey) + .setMaxAutomaticTokenAssociations(100) + .execute(client) + ).getReceipt(client) + ).accountId; + + // create a receiver account with unlimited max auto associations + const receiverPrivateKey = PrivateKey.generateED25519(); + const receiverAccountId = ( + await ( + await new AccountCreateTransaction() + .setKey(receiverPrivateKey) + .setMaxAutomaticTokenAssociations(-1) + .execute(client) + ).getReceipt(client) + ).accountId; + + // create a nft collection + const nftCreationTx = await ( + await new TokenCreateTransaction() + .setTokenType(TokenType.NonFungibleUnique) + .setTokenName("Example Fungible Token") + .setTokenSymbol("EFT") + .setMaxSupply(CID.length) + .setSupplyType(TokenSupplyType.Finite) + .setSupplyKey(operatorKey) + .setAdminKey(operatorKey) + .setTreasuryAccountId(treasuryAccountId) + .freezeWith(client) + .sign(treasuryPrivateKey) + ).execute(client); + + const nftId = (await nftCreationTx.getReceipt(client)).tokenId; + console.log("NFT ID: ", nftId.toString()); + + // create a fungible token + const ftCreationTx = await ( + await new TokenCreateTransaction() + .setTokenName("Example Fungible Token") + .setTokenSymbol("EFT") + .setInitialSupply(100000000) + .setSupplyKey(operatorKey) + .setAdminKey(operatorKey) + .setTreasuryAccountId(treasuryAccountId) + .freezeWith(client) + .sign(treasuryPrivateKey) + ).execute(client); + + const ftId = (await ftCreationTx.getReceipt(client)).tokenId; + console.log("FT ID: ", ftId.toString()); + + // mint 3 NFTs to treasury + const nftSerialIds = []; + for (let i = 0; i < CID.length; i++) { + const { serials } = await ( + await new TokenMintTransaction() + .setTokenId(nftId) + .addMetadata(Buffer.from(CID[i])) + .execute(client) + ).getReceipt(client); + const [serial] = serials; + nftSerialIds.push(new NftId(nftId, serial)); + } + + // transfer nfts to receiver + await ( + await ( + await new TransferTransaction() + .addNftTransfer( + nftSerialIds[0], + treasuryAccountId, + receiverAccountId, + ) + .addNftTransfer( + nftSerialIds[1], + treasuryAccountId, + receiverAccountId, + ) + .addNftTransfer( + nftSerialIds[2], + treasuryAccountId, + receiverAccountId, + ) + .freezeWith(client) + .sign(treasuryPrivateKey) + ).execute(client) + ).getReceipt(client); + + // transfer fungible tokens to receiver + await ( + await ( + await new TransferTransaction() + .addTokenTransfer(ftId, treasuryAccountId, -1) + .addTokenTransfer(ftId, receiverAccountId, 1) + .freezeWith(client) + .sign(treasuryPrivateKey) + ).execute(client) + ).getReceipt(client); + + console.log("======================="); + console.log("Before Token Reject"); + console.log("======================="); + const receiverFTBalanceBefore = ( + await new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(client) + ).tokens.get(ftId); + const treasuryFTBalanceBefore = ( + await new AccountBalanceQuery() + .setAccountId(treasuryAccountId) + .execute(client) + ).tokens.get(ftId); + const receiverNFTBalanceBefore = ( + await new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(client) + ).tokens.get(nftId); + const treasuryNFTBalanceBefore = ( + await new AccountBalanceQuery() + .setAccountId(treasuryAccountId) + .execute(client) + ).tokens.get(nftId); + console.log("Receiver FT balance: ", receiverFTBalanceBefore.toInt()); + console.log("Treasury FT balance: ", treasuryFTBalanceBefore.toInt()); + console.log( + "Receiver NFT balance: ", + receiverNFTBalanceBefore ? receiverNFTBalanceBefore.toInt() : 0, + ); + console.log("Treasury NFT balance: ", treasuryNFTBalanceBefore.toInt()); + + // reject fungible tokens back to treasury + const tokenRejectResponse = await ( + await ( + await new TokenRejectTransaction() + .setOwnerId(receiverAccountId) + .addTokenId(ftId) + .freezeWith(client) + .sign(receiverPrivateKey) + ).execute(client) + ).getReceipt(client); + + // reject NFTs back to treasury + const rejectFlowResponse = await ( + await ( + await new TokenRejectFlow() + .setOwnerId(receiverAccountId) + .setNftIds(nftSerialIds) + .freezeWith(client) + .sign(receiverPrivateKey) + ).execute(client) + ).getReceipt(client); + + const tokenRejectStatus = tokenRejectResponse.status.toString(); + const tokenRejectFlowStatus = rejectFlowResponse.status.toString(); + + console.log("======================="); + console.log("After Token Reject Transaction and flow"); + console.log("======================="); + + const receiverFTBalanceAfter = ( + await new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(client) + ).tokens.get(ftId); + + const treasuryFTBalanceAfter = ( + await new AccountBalanceQuery() + .setAccountId(treasuryAccountId) + .execute(client) + ).tokens.get(ftId); + + const receiverNFTBalanceAfter = ( + await new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(client) + ).tokens.get(nftId); + + const treasuryNFTBalanceAfter = ( + await new AccountBalanceQuery() + .setAccountId(treasuryAccountId) + .execute(client) + ).tokens.get(nftId); + + console.log("TokenReject response:", tokenRejectStatus); + console.log("TokenRejectFlow response:", tokenRejectFlowStatus); + console.log("Receiver FT balance: ", receiverFTBalanceAfter.toInt()); + console.log("Treasury FT balance: ", treasuryFTBalanceAfter.toInt()); + console.log( + "Receiver NFT balance: ", + receiverNFTBalanceAfter ? receiverNFTBalanceAfter.toInt() : 0, + ); + console.log("Treasury NFT balance: ", treasuryNFTBalanceAfter.toInt()); + + client.close(); +} + +void main(); diff --git a/packages/proto/src/proto b/packages/proto/src/proto index e19bb9758..141302ce2 160000 --- a/packages/proto/src/proto +++ b/packages/proto/src/proto @@ -1 +1 @@ -Subproject commit e19bb9758a3c22b2b6abe5427a58f3a787a2d245 +Subproject commit 141302ce26bd0c2023d4d031ed207d1e05917688 diff --git a/src/exports.js b/src/exports.js index 6c1359424..de6f28266 100644 --- a/src/exports.js +++ b/src/exports.js @@ -128,6 +128,8 @@ export { default as Timestamp } from "./Timestamp.js"; export { default as TokenAllowance } from "./account/TokenAllowance.js"; export { default as TokenAssociateTransaction } from "./token/TokenAssociateTransaction.js"; export { default as TokenBurnTransaction } from "./token/TokenBurnTransaction.js"; +export { default as TokenRejectTransaction } from "./token/TokenRejectTransaction.js"; +export { default as TokenRejectFlow } from "./token/TokenRejectFlow.js"; export { default as TokenCreateTransaction } from "./token/TokenCreateTransaction.js"; export { default as TokenDeleteTransaction } from "./token/TokenDeleteTransaction.js"; export { default as TokenDissociateTransaction } from "./token/TokenDissociateTransaction.js"; diff --git a/src/token/TokenReference.js b/src/token/TokenReference.js new file mode 100644 index 000000000..8955acc09 --- /dev/null +++ b/src/token/TokenReference.js @@ -0,0 +1,40 @@ +import NftId from "./NftId.js"; +import TokenId from "./TokenId.js"; + +/** + * @namespace proto + * @typedef {import("@hashgraph/proto").proto.TokenReference} HashgraphProto.proto.TokenReference + */ + +export default class TokenReference { + constructor() { + /** + * @public + * @type {?TokenId} + */ + this.fungibleToken = null; + /** + * @public + * @type {?NftId} + */ + this.nft = null; + } + + /** + * @public + * @param {HashgraphProto.proto.TokenReference} reference + * @returns {TokenReference} + */ + static _fromProtobuf(reference) { + return { + fungibleToken: + reference.fungibleToken != undefined + ? TokenId._fromProtobuf(reference.fungibleToken) + : null, + nft: + reference.nft != undefined + ? NftId._fromProtobuf(reference.nft) + : null, + }; + } +} diff --git a/src/token/TokenRejectFlow.js b/src/token/TokenRejectFlow.js new file mode 100644 index 000000000..b583582e7 --- /dev/null +++ b/src/token/TokenRejectFlow.js @@ -0,0 +1,279 @@ +/*- + * ‌ + * Hedera JavaScript SDK + * ​ + * Copyright (C) 2020 - 2023 Hedera Hashgraph, LLC + * ​ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ‍ + */ +import TokenRejectTransaction from "../token/TokenRejectTransaction.js"; +import TokenDissociateTransaction from "../token/TokenDissociateTransaction.js"; + +/** + * @typedef {import("../PrivateKey.js").default} PrivateKey + * @typedef {import("../client/Client.js").default<*, *>} Client + * @typedef {import("../Signer.js").default} Signer + * @typedef {import("../transaction/TransactionId.js").default} TransactionId + * @typedef {import("../transaction/Transaction.js").default} Transaction + * @typedef {import("../transaction/TransactionResponse.js").default} TransactionResponse + * @typedef {import("../token/TokenId.js").default} TokenId + * @typedef {import("../token/NftId.js").default} NftId + * @typedef {import("../PublicKey.js").default} PublicKey + * @typedef {import("../account/AccountId.js").default} AccountId + */ + +/** + * Reject undesired token(s) and dissociate in a single flow. + */ +export default class TokenRejectFlow { + constructor() { + /** + * @private + * @type {?AccountId} + */ + this._ownerId = null; + + /** + * @private + * @type {TokenId[]} + */ + this._tokenIds = []; + + /** + * @private + * @type {NftId[]} + */ + this._nftIds = []; + + /** + * @private + * @type {?Client} + */ + this._freezeWithClient = null; + + /** + * @private + * @type {?PrivateKey} + */ + this._signPrivateKey = null; + + /** + * @private + * @type {?PublicKey} + */ + this._signPublicKey = null; + + /** + * @private + * @type {?(message: Uint8Array) => Promise} + */ + this._transactionSigner = null; + } + + /** + * + * @param {AccountId} ownerId + * @returns {this} + */ + setOwnerId(ownerId) { + this.requireNotFrozen(); + this._ownerId = ownerId; + return this; + } + + /** + * @returns {?AccountId} + */ + get ownerId() { + return this._ownerId; + } + + /** + * + * @param {TokenId[]} ids + * @returns {this} + */ + setTokenIds(ids) { + this.requireNotFrozen(); + this._tokenIds = ids; + return this; + } + + /** + * + * @param {TokenId} id + * @returns {this} + */ + addTokenId(id) { + this.requireNotFrozen(); + this._tokenIds.push(id); + return this; + } + + /** + * + * @returns {TokenId[]} + */ + get tokenIds() { + return this._tokenIds; + } + + /** + * + * @param {NftId[]} ids + * @returns {this} + */ + setNftIds(ids) { + this.requireNotFrozen(); + this._nftIds = ids; + return this; + } + + /** + * + * @param {NftId} id + * @returns {this} + */ + addNftId(id) { + this.requireNotFrozen(); + this._nftIds.push(id); + return this; + } + + /** + * + * @returns {NftId[]} + */ + get nftIds() { + return this._nftIds; + } + + /** + * + * @param {PrivateKey} privateKey + * @returns {this} + */ + sign(privateKey) { + this._signPrivateKey = privateKey; + this._signPublicKey = null; + this._transactionSigner = null; + return this; + } + + /** + * + * @param {PublicKey} publicKey + * @param {((message: Uint8Array) => Promise)} signer + * @returns {this} + */ + signWith(publicKey, signer) { + this._signPublicKey = publicKey; + this._transactionSigner = signer; + this._signPrivateKey = null; + return this; + } + + /** + * @param {Client} client + * @returns {this} + */ + signWithOperator(client) { + const operator = client.getOperator(); + if (operator == null) { + throw new Error("Client operator must be set"); + } + this._signPublicKey = operator.publicKey; + this._transactionSigner = operator.transactionSigner; + this._signPrivateKey = null; + return this; + } + + /** + * @private + * @param {Transaction} transaction + */ + fillOutTransaction(transaction) { + if (this._freezeWithClient) { + transaction.freezeWith(this._freezeWithClient); + } + if (this._signPrivateKey) { + void transaction.sign(this._signPrivateKey); + } else if (this._signPublicKey && this._transactionSigner) { + void transaction.signWith( + this._signPublicKey, + this._transactionSigner, + ); + } + } + /** + * + * @param {Client} client + * @returns {this} + */ + freezeWith(client) { + this._freezeWithClient = client; + return this; + } + + /** + * @param {Client} client + * @returns {Promise} + */ + async execute(client) { + const tokenRejectTxn = new TokenRejectTransaction() + .setTokenIds(this.tokenIds) + .setNftIds(this.nftIds); + + if (this.ownerId) { + tokenRejectTxn.setOwnerId(this.ownerId); + } + + this.fillOutTransaction(tokenRejectTxn); + + /* Get all token ids from NFT and remove duplicates as duplicated IDs + will trigger a TOKEN_REFERENCE_REPEATED error. */ + const nftTokenIds = this.nftIds + .map((nftId) => nftId.tokenId) + .filter(function (value, index, array) { + return array.indexOf(value) === index; + }); + + const tokenDissociateTxn = new TokenDissociateTransaction().setTokenIds( + [...this.tokenIds, ...nftTokenIds], + ); + + if (this.ownerId != null) { + tokenDissociateTxn.setAccountId(this.ownerId); + } + + this.fillOutTransaction(tokenDissociateTxn); + + const tokenRejectResponse = await tokenRejectTxn.execute(client); + await tokenRejectResponse.getReceipt(client); + + const tokenDissociateResponse = + await tokenDissociateTxn.execute(client); + await tokenDissociateResponse.getReceipt(client); + + return tokenRejectResponse; + } + + requireNotFrozen() { + if (this._freezeWithClient != null) { + throw new Error( + "Transaction is already frozen and cannot be modified", + ); + } + } +} diff --git a/src/token/TokenRejectTransaction.js b/src/token/TokenRejectTransaction.js new file mode 100644 index 000000000..f3a02b92f --- /dev/null +++ b/src/token/TokenRejectTransaction.js @@ -0,0 +1,282 @@ +/*- + * ‌ + * Hedera JavaScript SDK + * ​ + * Copyright (C) 2020 - 2023 Hedera Hashgraph, LLC + * ​ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ‍ + */ +import AccountId from "../account/AccountId.js"; +import Transaction from "../transaction/Transaction.js"; +import { TRANSACTION_REGISTRY } from "../transaction/Transaction.js"; +import TokenReference from "../token/TokenReference.js"; + +/** + * @namespace proto + * @typedef {import("@hashgraph/proto").proto.ITransaction} HashgraphProto.proto.ITransaction + * @typedef {import("@hashgraph/proto").proto.ISignedTransaction} HashgraphProto.proto.ISignedTransaction + * @typedef {import("@hashgraph/proto").proto.ITransactionBody} HashgraphProto.proto.ITransactionBody + * @typedef {import("@hashgraph/proto").proto.ITransactionResponse} HashgraphProto.proto.ITransactionResponse + * @typedef {import("@hashgraph/proto").proto.TransactionBody} HashgraphProto.proto.TransactionBody + * @typedef {import("@hashgraph/proto").proto.ITokenRejectTransactionBody} HashgraphProto.proto.ITokenRejectTransactionBody + * @typedef {import("@hashgraph/proto").proto.TokenReference} HashgraphProto.proto.TokenReference + */ + +/** + * @typedef {import("../channel/Channel.js").default} Channel + * @typedef {import("../client/Client.js").default<*, *>} Client + * @typedef {import("../transaction/TransactionId.js").default} TransactionId + * @typedef {import("../token/TokenId.js").default} TokenId + * @typedef {import("../token/NftId.js").default} NftId + */ + +/** + * Reject a new Hedera™ crypto-currency token. + */ +export default class TokenRejectTransaction extends Transaction { + /** + * + * @param {object} [props] + * @param {?AccountId} [props.owner] + * @param {NftId[]} [props.nftIds] + * @param {TokenId[]} [props.tokenIds] + */ + constructor(props = {}) { + super(); + + /** + * @private + * @type {?AccountId} + */ + this._owner = null; + + if (props.owner != null) { + this.setOwnerId(props.owner); + } + + /** + * @private + * @type {TokenId[]} + */ + this._tokenIds = []; + + /** + * @private + * @type {NftId[]} + */ + this._nftIds = []; + + if (props.tokenIds != null) { + this.setTokenIds(props.tokenIds); + } + + if (props.nftIds != null) { + this.setNftIds(props.nftIds); + } + } + + /** + * @internal + * @param {HashgraphProto.proto.ITransaction[]} transactions + * @param {HashgraphProto.proto.ISignedTransaction[]} signedTransactions + * @param {TransactionId[]} transactionIds + * @param {AccountId[]} nodeIds + * @param {HashgraphProto.proto.ITransactionBody[]} bodies + * @returns {TokenRejectTransaction} + */ + static _fromProtobuf( + transactions, + signedTransactions, + transactionIds, + nodeIds, + bodies, + ) { + const body = bodies[0]; + const rejectToken = + /** @type {HashgraphProto.proto.ITokenRejectTransactionBody} */ ( + body.tokenReject + ); + + const tokenIds = rejectToken.rejections?.map((rejection) => + TokenReference._fromProtobuf(rejection), + ); + const ftIds = tokenIds + ?.filter((token) => token.fungibleToken) + .map(({ fungibleToken }) => { + if (fungibleToken == null) { + throw new Error("Fungible Token cannot be null"); + } + return fungibleToken; + }); + + const nftIds = tokenIds + ?.filter((token) => token.nft) + .map(({ nft }) => { + if (nft == null) { + throw new Error("Nft cannot be null"); + } + return nft; + }); + + return Transaction._fromProtobufTransactions( + new TokenRejectTransaction({ + owner: + rejectToken.owner != null + ? AccountId._fromProtobuf(rejectToken.owner) + : undefined, + + tokenIds: ftIds, + nftIds: nftIds, + }), + transactions, + signedTransactions, + transactionIds, + nodeIds, + bodies, + ); + } + + /** + * @returns {TokenId[]} + * @readonly + */ + get tokenIds() { + return this._tokenIds; + } + + /** + * @param {TokenId[]} tokenIds + * @returns {this} + */ + setTokenIds(tokenIds) { + this._requireNotFrozen(); + this._tokenIds = tokenIds; + return this; + } + + /** + * @param {TokenId} tokenId + * @returns {this} + */ + addTokenId(tokenId) { + this._requireNotFrozen(); + this._tokenIds?.push(tokenId); + return this; + } + + /** + * @returns {NftId[]} + * @readonly + * + */ + get nftIds() { + return this._nftIds; + } + + /** + * + * @param {NftId[]} nftIds + * @returns {this} + */ + setNftIds(nftIds) { + this._requireNotFrozen(); + this._nftIds = nftIds; + return this; + } + + /** + * @param {NftId} nftId + * @returns {this} + */ + addNftId(nftId) { + this._requireNotFrozen(); + this._nftIds?.push(nftId); + return this; + } + + /** + * @returns {?AccountId} + */ + get ownerId() { + return this._owner; + } + + /** + * @param {AccountId} owner + * @returns {this} + */ + setOwnerId(owner) { + this._requireNotFrozen(); + this._owner = owner; + return this; + } + + /** + * @override + * @internal + * @param {Channel} channel + * @param {HashgraphProto.proto.ITransaction} request + * @returns {Promise} + */ + _execute(channel, request) { + return channel.token.rejectToken(request); + } + + /** + * @override + * @protected + * @returns {NonNullable} + */ + _getTransactionDataCase() { + return "tokenReject"; + } + + /** + * @returns {HashgraphProto.proto.ITokenRejectTransactionBody} + */ + _makeTransactionData() { + /** @type {HashgraphProto.proto.TokenReference[]} */ + const rejections = []; + for (const tokenId of this._tokenIds) { + rejections.push({ + fungibleToken: tokenId._toProtobuf(), + }); + } + + for (const nftId of this._nftIds) { + rejections.push({ + nft: nftId._toProtobuf(), + }); + } + return { + owner: this.ownerId?._toProtobuf() ?? null, + rejections, + }; + } + + /** + * @returns {string} + */ + _getLogId() { + const timestamp = /** @type {import("../Timestamp.js").default} */ ( + this._transactionIds.current.validStart + ); + return `TokenRejectTransaction:${timestamp.toString()}`; + } +} +TRANSACTION_REGISTRY.set( + "tokenReject", + // eslint-disable-next-line @typescript-eslint/unbound-method + TokenRejectTransaction._fromProtobuf, +); diff --git a/test/integration/TokenRejectFlowIntegrationTest.js b/test/integration/TokenRejectFlowIntegrationTest.js new file mode 100644 index 000000000..83ab17586 --- /dev/null +++ b/test/integration/TokenRejectFlowIntegrationTest.js @@ -0,0 +1,209 @@ +import { + AccountBalanceQuery, + AccountCreateTransaction, + Hbar, + NftId, + PrivateKey, + TokenAssociateTransaction, + TokenCreateTransaction, + TokenMintTransaction, + TokenRejectFlow, + TokenType, + TransferTransaction, +} from "../../src/exports.js"; +import IntegrationTestEnv from "./client/NodeIntegrationTestEnv.js"; + +describe("TokenRejectIntegrationTest", function () { + let env; + it("can execute TokenRejectFlow for fungible tokens", async function () { + this.timeout(120000); + env = await IntegrationTestEnv.new(); + const FULL_TREASURY_BALANCE = 1000000; + + // create token + const tokenCreateTx = await new TokenCreateTransaction() + .setTokenName("ffff") + .setTokenSymbol("F") + .setDecimals(3) + .setInitialSupply(FULL_TREASURY_BALANCE) + .setTreasuryAccountId(env.operatorId) + .setPauseKey(env.operatorKey) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client); + + let tokenId1 = (await tokenCreateTx.getReceipt(env.client)).tokenId; + + // create token + const tokenCreateTx2 = await new TokenCreateTransaction() + .setTokenName("ffff") + .setTokenSymbol("F") + .setDecimals(3) + .setInitialSupply(1000000) + .setTreasuryAccountId(env.operatorId) + .setPauseKey(env.operatorKey) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client); + + let tokenId2 = (await tokenCreateTx2.getReceipt(env.client)).tokenId; + // create receiver account + let receiverPrivateKey = await PrivateKey.generateECDSA(); + const receiverCreateAccount = await new AccountCreateTransaction() + .setKey(receiverPrivateKey) + .setInitialBalance(new Hbar(1)) + .execute(env.client); + + let receiverId = (await receiverCreateAccount.getReceipt(env.client)) + .accountId; + + await ( + await new TokenAssociateTransaction() + .setAccountId(receiverId) + .setTokenIds([tokenId1, tokenId2]) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client); + + await ( + await new TransferTransaction() + .addTokenTransfer(tokenId1, env.operatorId, -100) + .addTokenTransfer(tokenId1, receiverId, 100) + .addTokenTransfer(tokenId2, env.operatorId, -100) + .addTokenTransfer(tokenId2, receiverId, 100) + .execute(env.client) + ).getReceipt(env.client); + + await ( + await new TokenRejectFlow() + .setOwnerId(receiverId) + .setTokenIds([tokenId1, tokenId2]) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client); + + const receiverBalanceQuery = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + + const treasuryBalanceQuery = await new AccountBalanceQuery() + .setAccountId(env.operatorId) + .execute(env.client); + + expect(receiverBalanceQuery.tokens.get(tokenId1)).to.be.eq(null); + expect(receiverBalanceQuery.tokens.get(tokenId2)).to.be.eq(null); + expect(treasuryBalanceQuery.tokens.get(tokenId1).toInt()).to.be.eq( + FULL_TREASURY_BALANCE, + ); + expect(treasuryBalanceQuery.tokens.get(tokenId2).toInt()).to.be.eq( + FULL_TREASURY_BALANCE, + ); + + let err; + try { + await ( + await new TransferTransaction() + .addTokenTransfer(tokenId1, receiverId, 100) + .addTokenTransfer(tokenId1, env.operatorId, -100) + .execute(env.client) + ).getReceipt(env.client); + } catch (error) { + err = error.message.includes("TOKEN_NOT_ASSOCIATED_TO_ACCOUNT"); + } + + if (!err) { + throw new Error( + "Token should not be associated with receiver account", + ); + } + }); + + it("can execute TokenRejectFlow for non-fungible tokens", async function () { + this.timeout(120000); + env = await IntegrationTestEnv.new(); + + // create token + const tokenCreateTx = await new TokenCreateTransaction() + .setTokenType(TokenType.NonFungibleUnique) + .setTokenName("ffff") + .setTokenSymbol("F") + .setTreasuryAccountId(env.operatorId) + .setPauseKey(env.operatorKey) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client); + + let { tokenId } = await tokenCreateTx.getReceipt(env.client); + + // create receiver account + let receiverPrivateKey = await PrivateKey.generateECDSA(); + const receiverCreateAccount = await new AccountCreateTransaction() + .setKey(receiverPrivateKey) + .setInitialBalance(new Hbar(1)) + .execute(env.client); + + let { accountId: receiverId } = await receiverCreateAccount.getReceipt( + env.client, + ); + + await ( + await new TokenAssociateTransaction() + .setAccountId(receiverId) + .setTokenIds([tokenId]) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client); + + await new TokenMintTransaction() + .setTokenId(tokenId) + .addMetadata(Buffer.from("=====")) + .execute(env.client); + + const nftId = new NftId(tokenId, 1); + await ( + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client) + ).getReceipt(env.client); + + await ( + await new TokenRejectFlow() + .setOwnerId(receiverId) + .setNftIds([nftId]) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client); + + const receiverBalanceQuery = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + + const treasuryBalanceQuery = await new AccountBalanceQuery() + .setAccountId(env.operatorId) + .execute(env.client); + + expect(receiverBalanceQuery.tokens.get(tokenId)).to.eq(null); + expect(treasuryBalanceQuery.tokens.get(tokenId).toInt()).to.be.eq(1); + + let err; + try { + await ( + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client) + ).getReceipt(env.client); + } catch (error) { + err = error.message.includes("TOKEN_NOT_ASSOCIATED_TO_ACCOUNT"); + } + + if (!err) { + throw new Error( + "Token should not be associated with receiver account", + ); + } + }); + + after(async function () { + await env.close(); + }); +}); diff --git a/test/integration/TokenRejectIntegrationTest.js b/test/integration/TokenRejectIntegrationTest.js new file mode 100644 index 000000000..f08298bcc --- /dev/null +++ b/test/integration/TokenRejectIntegrationTest.js @@ -0,0 +1,1005 @@ +import { expect } from "chai"; +import { + AccountAllowanceApproveTransaction, + AccountBalanceQuery, + AccountCreateTransaction, + AccountUpdateTransaction, + Hbar, + NftId, + PrivateKey, + TokenCreateTransaction, + TokenFreezeTransaction, + TokenMintTransaction, + TokenPauseTransaction, + TokenRejectTransaction, + TokenType, + TransactionId, + TransferTransaction, +} from "../../src/exports.js"; +import IntegrationTestEnv from "./client/NodeIntegrationTestEnv.js"; + +describe("TokenRejectIntegrationTest", function () { + let env, tokenId, receiverId, receiverPrivateKey; + const INITIAL_SUPPLY = 1000000; + + describe("Fungible Tokens", function () { + beforeEach(async function () { + env = await IntegrationTestEnv.new(); + + // create token + const tokenCreateResponse = await new TokenCreateTransaction() + .setTokenName("ffff") + .setTokenSymbol("F") + .setDecimals(3) + .setInitialSupply(INITIAL_SUPPLY) + .setTreasuryAccountId(env.operatorId) + .setPauseKey(env.operatorKey) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .setFreezeKey(env.operatorKey) + .execute(env.client); + + tokenId = (await tokenCreateResponse.getReceipt(env.client)) + .tokenId; + + // create receiver account + receiverPrivateKey = await PrivateKey.generateECDSA(); + const receiverCreateAccount = await new AccountCreateTransaction() + .setKey(receiverPrivateKey) + .setInitialBalance(new Hbar(1)) + .setMaxAutomaticTokenAssociations(-1) + .execute(env.client); + + receiverId = (await receiverCreateAccount.getReceipt(env.client)) + .accountId; + }); + + it("should execute TokenReject Tx", async function () { + this.timeout(120000); + + // create another token + const tokenCreateResponse2 = await new TokenCreateTransaction() + .setTokenName("ffff2") + .setTokenSymbol("F2") + .setDecimals(3) + .setInitialSupply(INITIAL_SUPPLY) + .setTreasuryAccountId(env.operatorId) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client); + + const { tokenId: tokenId2 } = await tokenCreateResponse2.getReceipt( + env.client, + ); + + // transfer tokens of both types to receiver + await ( + await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1) + .addTokenTransfer(tokenId, receiverId, 1) + .addTokenTransfer(tokenId2, env.operatorId, -1) + .addTokenTransfer(tokenId2, receiverId, 1) + .execute(env.client) + ).getReceipt(env.client); + + // reject tokens + await ( + await ( + await new TokenRejectTransaction() + .setTokenIds([tokenId, tokenId2]) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + + const tokenBalanceReceiverQuery = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + + const tokenBalanceReceiver = tokenBalanceReceiverQuery.tokens + .get(tokenId) + .toInt(); + const tokenBalanceReceiver2 = tokenBalanceReceiverQuery.tokens + .get(tokenId2) + .toInt(); + + const tokenBalanceTreasuryQuery = await new AccountBalanceQuery() + .setAccountId(env.operatorId) + .execute(env.client); + + const tokenBalanceTreasury = tokenBalanceTreasuryQuery.tokens + .get(tokenId) + .toInt(); + const tokenBalanceTreasury2 = tokenBalanceTreasuryQuery.tokens + .get(tokenId) + .toInt(); + + expect(tokenBalanceReceiver).to.be.equal(0); + expect(tokenBalanceReceiver2).to.be.equal(0); + + expect(tokenBalanceTreasury).to.be.equal(INITIAL_SUPPLY); + expect(tokenBalanceTreasury2).to.be.equal(INITIAL_SUPPLY); + }); + + it("should return token back when receiver has receiverSigRequired is true", async function () { + this.timeout(120000); + const TREASURY_TOKENS_AMOUNT = 1000000; + + await new AccountUpdateTransaction() + .setAccountId(env.operatorId) + .setReceiverSignatureRequired(true) + .execute(env.client); + + const transferTransactionResponse = await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1) + .addTokenTransfer(tokenId, receiverId, 1) + .execute(env.client); + + await transferTransactionResponse.getReceipt(env.client); + + const tokenRejectResponse = await ( + await new TokenRejectTransaction() + .addTokenId(tokenId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client); + + await tokenRejectResponse.getReceipt(env.client); + + const tokenBalanceTreasuryQuery = await new AccountBalanceQuery() + .setAccountId(env.operatorId) + .execute(env.client); + + const tokenBalanceTreasury = tokenBalanceTreasuryQuery.tokens + .get(tokenId) + .toInt(); + expect(tokenBalanceTreasury).to.be.equal(TREASURY_TOKENS_AMOUNT); + + const tokenBalanceReceiverQuery = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + const tokenBalanceReceiver = tokenBalanceReceiverQuery.tokens + .get(tokenId) + .toInt(); + expect(tokenBalanceReceiver).to.equal(0); + }); + + // temporary disabled until issue re nfts will be resolved on services side + // eslint-disable-next-line mocha/no-skipped-tests + it.skip("should not return spender allowance to zero after owner rejects FT", async function () { + this.timeout(120000); + + const spenderAccountPrivateKey = PrivateKey.generateED25519(); + const spenderAccountResponse = await new AccountCreateTransaction() + .setMaxAutomaticTokenAssociations(-1) + .setInitialBalance(new Hbar(10)) + .setKey(spenderAccountPrivateKey) + .execute(env.client); + + const { accountId: spenderAccountId } = + await spenderAccountResponse.getReceipt(env.client); + + await ( + await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1) + .addTokenTransfer(tokenId, receiverId, 1) + .execute(env.client) + ).getReceipt(env.client); + + await ( + await ( + await new AccountAllowanceApproveTransaction() + .approveTokenAllowance( + tokenId, + receiverId, + spenderAccountId, + 10, + ) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + + await ( + await ( + await new TokenRejectTransaction() + .addTokenId(tokenId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + + // Confirm that token reject transaction has returned funds + const balanceReceiverPre = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + + const balanceTreasuryPre = await new AccountBalanceQuery() + .setAccountId(env.operatorId) + .execute(env.client); + + expect(balanceReceiverPre.tokens.get(tokenId).toInt()).to.eq(0); + expect(balanceTreasuryPre.tokens.get(tokenId).toInt()).to.eq( + INITIAL_SUPPLY, + ); + + // after token reject transaction receiver doesn't have balance + // so we need some tokens back from treasury + await ( + await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1) + .addTokenTransfer(tokenId, receiverId, 1) + .execute(env.client) + ).getReceipt(env.client); + + const transactionId = TransactionId.generate(spenderAccountId); + await ( + await ( + await new TransferTransaction() + .addApprovedTokenTransfer(tokenId, receiverId, -1) + .addTokenTransfer(tokenId, spenderAccountId, 1) + .setTransactionId(transactionId) + .freezeWith(env.client) + .sign(spenderAccountPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + + // Confirm spender has transfered tokens + const tokenBalanceReceiverPost = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + + expect(tokenBalanceReceiverPost.tokens.get(tokenId).toInt()).to.eq( + 0, + ); + + const tokenBalanceSpenderPost = await new AccountBalanceQuery() + .setAccountId(spenderAccountId) + .execute(env.client); + + expect(tokenBalanceSpenderPost.tokens.get(tokenId).toInt()).to.eq( + 1, + ); + }); + + describe("should throw an error", function () { + it("when paused FT", async function () { + this.timeout(120000); + + await ( + await new TokenPauseTransaction() + .setTokenId(tokenId) + .execute(env.client) + ).getReceipt(env.client); + + await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1) + .addTokenTransfer(tokenId, receiverId, 1) + .execute(env.client); + + const tokenRejectTx = await new TokenRejectTransaction() + .addTokenId(tokenId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey); + + try { + await ( + await tokenRejectTx.execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include("TOKEN_IS_PAUSED"); + } + }); + + it("when FT is frozen", async function () { + this.timeout(120000); + // transfer token to receiver + await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1) + .addTokenTransfer(tokenId, receiverId, 1) + .execute(env.client); + + // freeze token + await ( + await new TokenFreezeTransaction() + .setTokenId(tokenId) + .setAccountId(receiverId) + .execute(env.client) + ).getReceipt(env.client); + + try { + // reject token on frozen account for thsi token + await ( + await ( + await new TokenRejectTransaction() + .addTokenId(tokenId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include("ACCOUNT_FROZEN_FOR_TOKEN"); + } + }); + + it("when there's a duplicated token reference", async function () { + await ( + await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1) + .addTokenTransfer(tokenId, receiverId, 1) + .execute(env.client) + ).getReceipt(env.client); + + try { + await new TokenRejectTransaction() + .setTokenIds([tokenId, tokenId]) + .execute(env.client); + } catch (err) { + expect(err.message).to.include("TOKEN_REFERENCE_REPEATED"); + } + }); + + it("when user does not have balance", async function () { + this.timeout(120000); + + // create receiver account + const receiverPrivateKey = PrivateKey.generateED25519(); + const { accountId: emptyBalanceUserId } = await ( + await new AccountCreateTransaction() + .setKey(receiverPrivateKey) + .setMaxAutomaticTokenAssociations(-1) + .execute(env.client) + ).getReceipt(env.client); + + await ( + await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1000) + .addTokenTransfer(tokenId, receiverId, 1000) + .execute(env.client) + ).getReceipt(env.client); + + const transactionId = + await TransactionId.generate(emptyBalanceUserId); + try { + await ( + await ( + await new TokenRejectTransaction() + .setOwnerId(emptyBalanceUserId) + .addTokenId(tokenId) + .setTransactionId(transactionId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include( + "INSUFFICIENT_PAYER_BALANCE", + ); + } + }); + + it("when trasury account rejects token", async function () { + try { + await ( + await new TokenRejectTransaction() + .addTokenId(tokenId) + .execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include("ACCOUNT_IS_TREASURY"); + } + }); + + it("when more than 11 tokens in token list for RejectToken transaction", async function () { + this.timeout(120000); + const tokenIds = []; + + for (let i = 0; i < 11; i++) { + const { tokenId } = await ( + await new TokenCreateTransaction() + .setTokenName("ffff") + .setTokenSymbol("F") + .setTokenType(TokenType.FungibleCommon) + .setInitialSupply(1000) + .setTreasuryAccountId(env.operatorId) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client) + ).getReceipt(env.client); + tokenIds.push(tokenId); + } + try { + await ( + await new TokenRejectTransaction() + .setTokenIds(tokenIds) + .execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include( + "TOKEN_REFERENCE_LIST_SIZE_LIMIT_EXCEEDED", + ); + } + }); + }); + }); + + describe("Non-Fungible Tokens", function () { + let tokenId, receiverPrivateKey, receiverId, nftId; + beforeEach(async function () { + this.timeout(120000); + env = await IntegrationTestEnv.new(); + const tokenCreateResponse = await new TokenCreateTransaction() + .setTokenType(TokenType.NonFungibleUnique) + .setTokenName("ffff") + .setTokenSymbol("F") + .setTreasuryAccountId(env.operatorId) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .setPauseKey(env.operatorKey) + .setFreezeKey(env.operatorKey) + .execute(env.client); + + tokenId = (await tokenCreateResponse.getReceipt(env.client)) + .tokenId; + + receiverPrivateKey = await PrivateKey.generateECDSA(); + receiverId = ( + await ( + await new AccountCreateTransaction() + .setKey(receiverPrivateKey) + .setMaxAutomaticTokenAssociations(-1) + .execute(env.client) + ).getReceipt(env.client) + ).accountId; + + nftId = new NftId(tokenId, 1); + await ( + await new TokenMintTransaction() + .setTokenId(tokenId) + .setMetadata(Buffer.from("-")) + .execute(env.client) + ).getReceipt(env.client); + }); + + it("should execute TokenReject Tx", async function () { + this.timeout(120000); + + const tokenCreateResponse2 = await new TokenCreateTransaction() + .setTokenType(TokenType.NonFungibleUnique) + .setTokenName("ffff2") + .setTokenSymbol("F2") + .setTreasuryAccountId(env.operatorId) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client); + + const { tokenId: tokenId2 } = await tokenCreateResponse2.getReceipt( + env.client, + ); + + const nftId2 = new NftId(tokenId2, 1); + await ( + await new TokenMintTransaction() + .setTokenId(tokenId2) + .setMetadata(Buffer.from("-")) + .execute(env.client) + ).getReceipt(env.client); + + await ( + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .addNftTransfer(nftId2, env.operatorId, receiverId) + .execute(env.client) + ).getReceipt(env.client); + + await ( + await ( + await await new TokenRejectTransaction() + .setNftIds([nftId, nftId2]) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + + const tokenBalanceReceiverQuery = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + + const tokenBalanceTreasuryQuery = await new AccountBalanceQuery() + .setAccountId(env.operatorId) + .execute(env.client); + + const tokenBalanceReceiver = tokenBalanceReceiverQuery.tokens + .get(tokenId) + .toInt(); + const tokenBalanceReceiver2 = tokenBalanceReceiverQuery.tokens + .get(tokenId2) + .toInt(); + + const tokenBalanceTreasury = tokenBalanceTreasuryQuery.tokens + .get(tokenId) + .toInt(); + const tokenBalanceTreasury2 = tokenBalanceTreasuryQuery.tokens + .get(tokenId2) + .toInt(); + + expect(tokenBalanceTreasury).to.be.equal(1); + expect(tokenBalanceTreasury2).to.be.equal(1); + + expect(tokenBalanceReceiver).to.be.equal(0); + expect(tokenBalanceReceiver2).to.be.equal(0); + }); + + it("should return tokens back to treasury receiverSigRequired is true", async function () { + this.timeout(1200000); + + await new AccountUpdateTransaction() + .setAccountId(env.operatorId) + .setReceiverSignatureRequired(true) + .execute(env.client); + + const transferTransactionResponse = await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .freezeWith(env.client) + .execute(env.client); + + await transferTransactionResponse.getReceipt(env.client); + + const tokenRejectResponse = await ( + await new TokenRejectTransaction() + .addNftId(nftId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client); + + await tokenRejectResponse.getReceipt(env.client); + + const tokenBalanceTreasuryQuery = await new AccountBalanceQuery() + .setAccountId(env.operatorId) + .execute(env.client); + + const tokenBalanceTreasury = tokenBalanceTreasuryQuery.tokens + .get(tokenId) + .toInt(); + expect(tokenBalanceTreasury).to.be.equal(1); + + const tokenBalanceReceiverQuery = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + + const tokenBalanceReceiver = tokenBalanceReceiverQuery.tokens + .get(tokenId) + .toInt(); + expect(tokenBalanceReceiver).to.equal(0); + }); + + // temporary disabled until issue re nfts will be resolved on services side + // eslint-disable-next-line mocha/no-skipped-tests + it.skip("should return spender allowance to 0 after owner rejects NFT", async function () { + this.timeout(120000); + + // create spender account + const spenderAccountPrivateKey = PrivateKey.generateED25519(); + const spenderAccountResponse = await new AccountCreateTransaction() + .setMaxAutomaticTokenAssociations(-1) + .setInitialBalance(new Hbar(10)) + .setKey(spenderAccountPrivateKey) + .execute(env.client); + + const { accountId: spenderAccountId } = + await spenderAccountResponse.getReceipt(env.client); + + // transfer nft to receiver + await ( + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client) + ).getReceipt(env.client); + + // approve nft allowance + await ( + await ( + await new AccountAllowanceApproveTransaction() + .approveTokenNftAllowance( + nftId, + receiverId, + spenderAccountId, + ) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + + // reject nft + await ( + await ( + await new TokenRejectTransaction() + .addNftId(nftId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + + // transfer nft from receiver to spender using allowance + try { + const transactionId = TransactionId.generate(spenderAccountId); + await ( + await ( + await new TransferTransaction() + .addApprovedNftTransfer( + nftId, + receiverId, + spenderAccountId, + ) + .setTransactionId(transactionId) + .freezeWith(env.client) + .sign(spenderAccountPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include( + "SPENDER_DOES_NOT_HAVE_ALLOWANCE", + ); + } + }); + + describe("should throw an error", function () { + it("when paused NFT", async function () { + this.timeout(120000); + + await ( + await new TokenPauseTransaction() + .setTokenId(tokenId) + .execute(env.client) + ).getReceipt(env.client); + + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client); + const tokenRejectTx = await new TokenRejectTransaction() + .addTokenId(tokenId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey); + + try { + await ( + await tokenRejectTx.execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include("TOKEN_IS_PAUSED"); + } + }); + + it("when NFT is frozen", async function () { + this.timeout(120000); + + // transfer token to receiver + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client); + + // freeze token + await ( + await new TokenFreezeTransaction() + .setTokenId(tokenId) + .setAccountId(receiverId) + .execute(env.client) + ).getReceipt(env.client); + + try { + // reject token on frozen account for thsi token + await ( + await ( + await new TokenRejectTransaction() + .addTokenId(tokenId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include("ACCOUNT_FROZEN_FOR_TOKEN"); + } + }); + + it("when using Fungible Token id when referencing NFTs", async function () { + this.timeout(120000); + + // transfer to receiver + await ( + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client) + ).getReceipt(env.client); + + try { + // reject nft using addTokenId + await ( + await ( + await new TokenRejectTransaction() + .setOwnerId(receiverId) + .addTokenId(tokenId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include( + "ACCOUNT_AMOUNT_TRANSFERS_ONLY_ALLOWED_FOR_FUNGIBLE_COMMON", + ); + } + + try { + // reject nft using setTokenIds + await ( + await ( + await new TokenRejectTransaction() + .setOwnerId(receiverId) + .setTokenIds([tokenId]) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include( + "ACCOUNT_AMOUNT_TRANSFERS_ONLY_ALLOWED_FOR_FUNGIBLE_COMMON", + ); + } + }); + + it("when there's a duplicated token reference", async function () { + this.timeout(120000); + + // transfer nft to receiver + await ( + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client) + ).getReceipt(env.client); + + // reject nft + try { + await new TokenRejectTransaction() + .setNftIds([nftId, nftId]) + .execute(env.client); + } catch (err) { + expect(err.message).to.include("TOKEN_REFERENCE_REPEATED"); + } + }); + + it("when user does not have balance", async function () { + this.timeout(120000); + + // transfer nft to receiver + await ( + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client) + ).getReceipt(env.client); + const transactionId = await TransactionId.generate(receiverId); + + try { + // reject nft + await ( + await ( + await new TokenRejectTransaction() + .setOwnerId(receiverId) + .addNftId(nftId) + .setTransactionId(transactionId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include( + "INSUFFICIENT_PAYER_BALANCE", + ); + } + }); + + it("when wrong signature of owner", async function () { + // transfer token to receiver + await new TransferTransaction() + .addTokenTransfer(tokenId, env.operatorId, -1000) + .addTokenTransfer(tokenId, receiverId, 1000); + + try { + // reject token with wrong signature + const WRONG_SIGNATURE = PrivateKey.generateED25519(); + await ( + await ( + await new TokenRejectTransaction() + .addTokenId(tokenId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(WRONG_SIGNATURE) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include("INVALID_SIGNATURE"); + } + }); + + it("when wrong owner id", async function () { + this.timeout(120000); + + // generate wrong owner account + const wrongOwnerPrivateKey = PrivateKey.generateED25519(); + const { accountId: wrongOwnerId } = await ( + await new AccountCreateTransaction() + .setKey(wrongOwnerPrivateKey) + .setMaxAutomaticTokenAssociations(-1) + .execute(env.client) + ).getReceipt(env.client); + + // transfer token to receiver + await ( + await new TransferTransaction() + .addNftTransfer(nftId, env.operatorId, receiverId) + .execute(env.client) + ).getReceipt(env.client); + + try { + // reject token with wrong token id + await ( + await ( + await new TokenRejectTransaction() + .addNftId(nftId) + .setOwnerId(wrongOwnerId) + .freezeWith(env.client) + .sign(wrongOwnerPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include("INVALID_OWNER_ID"); + } + }); + }); + }); + + describe("Other", function () { + beforeEach(async function () { + env = await IntegrationTestEnv.new(); + + // create token + const tokenCreateResponse = await new TokenCreateTransaction() + .setTokenName("ffff") + .setTokenSymbol("F") + .setDecimals(3) + .setInitialSupply(1000000) + .setTreasuryAccountId(env.operatorId) + .setPauseKey(env.operatorKey) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client); + + tokenId = (await tokenCreateResponse.getReceipt(env.client)) + .tokenId; + + // create receiver account + receiverPrivateKey = await PrivateKey.generateECDSA(); + const receiverCreateAccountResponse = + await new AccountCreateTransaction() + .setKey(receiverPrivateKey) + .setInitialBalance(new Hbar(1)) + .setMaxAutomaticTokenAssociations(-1) + .execute(env.client); + + receiverId = ( + await receiverCreateAccountResponse.getReceipt(env.client) + ).accountId; + }); + + it("should execute TokenReject tx with mixed type of tokens in one tx", async function () { + this.timeout(120000); + + // create NFT collection + const tokenCreateResponse = await new TokenCreateTransaction() + .setTokenType(TokenType.NonFungibleUnique) + .setTokenName("ffff") + .setTokenSymbol("F") + .setTreasuryAccountId(env.operatorId) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client); + const { tokenId: nftId } = await tokenCreateResponse.getReceipt( + env.client, + ); + const nftSerialId = new NftId(nftId, 1); + + // create FT + const tokenCreateResponse2 = await new TokenCreateTransaction() + .setTokenName("ffff2") + .setTokenSymbol("F2") + .setDecimals(3) + .setInitialSupply(1000000) + .setTreasuryAccountId(env.operatorId) + .setAdminKey(env.operatorKey) + .setSupplyKey(env.operatorKey) + .execute(env.client); + const { tokenId: ftId } = await tokenCreateResponse2.getReceipt( + env.client, + ); + + await ( + await new TokenMintTransaction() + .setTokenId(nftId) + .setMetadata(Buffer.from("-")) + .execute(env.client) + ).getReceipt(env.client); + + const tokenTransferResponse = await new TransferTransaction() + .addTokenTransfer(ftId, env.operatorId, -1) + .addTokenTransfer(ftId, receiverId, 1) + .addNftTransfer(nftSerialId, env.operatorId, receiverId) + .execute(env.client); + + await tokenTransferResponse.getReceipt(env.client); + + // reject tokens + await ( + await ( + await new TokenRejectTransaction() + .addTokenId(ftId) + .addNftId(nftSerialId) + .setOwnerId(receiverId) + .freezeWith(env.client) + .sign(receiverPrivateKey) + ).execute(env.client) + ).getReceipt(env.client); + + // check token balance of receiver + const tokenBalanceReceiverQuery = await new AccountBalanceQuery() + .setAccountId(receiverId) + .execute(env.client); + + const tokenBalanceFTReceiver = tokenBalanceReceiverQuery.tokens + .get(ftId) + .toInt(); + const tokenBalanceNFTReceiver = tokenBalanceReceiverQuery.tokens + .get(nftId) + .toInt(); + + expect(tokenBalanceFTReceiver).to.be.equal(0); + expect(tokenBalanceNFTReceiver).to.be.equal(0); + + // check token balance of treasury + const tokenBalanceTreasuryQuery = await new AccountBalanceQuery() + .setAccountId(env.operatorId) + .execute(env.client); + + const tokenBalanceTreasury = tokenBalanceTreasuryQuery.tokens + .get(ftId) + .toInt(); + const tokenBalance2Treasury = tokenBalanceTreasuryQuery.tokens + .get(nftId) + .toInt(); + + expect(tokenBalanceTreasury).to.be.equal(1000000); + expect(tokenBalance2Treasury).to.be.equal(1); + }); + + it("should throw if RejectToken transaction has empty token id list", async function () { + try { + await ( + await new TokenRejectTransaction().execute(env.client) + ).getReceipt(env.client); + } catch (err) { + expect(err.message).to.include("EMPTY_TOKEN_REFERENCE_LIST"); + } + }); + }); + + after(async function () { + await env.close(); + }); +}); diff --git a/test/unit/TokenRejectFlow.js b/test/unit/TokenRejectFlow.js new file mode 100644 index 000000000..33c10ab4d --- /dev/null +++ b/test/unit/TokenRejectFlow.js @@ -0,0 +1,81 @@ +/* eslint-disable mocha/no-setup-in-describe */ + +import { + AccountId, + Client, + NftId, + TokenId, + TokenRejectFlow, +} from "../../src/index.js"; + +describe("TokenRejectFlow", function () { + let tokenIds = [ + TokenId.fromString("1.2.3"), + TokenId.fromString("1.2.4"), + TokenId.fromString("1.2.5"), + ]; + + let nftIds = [ + new NftId(tokenIds[0], 1), + new NftId(tokenIds[1], 2), + new NftId(tokenIds[2], 3), + ]; + + let tx; + + it("should set owner id", function () { + const owner = new AccountId(1); + tx = new TokenRejectFlow().setOwnerId(owner); + expect(tx.ownerId.toString()).to.equal(owner.toString()); + }); + + it("set owner id when frozen", async function () { + const client = Client.forLocalNode(); + tx = new TokenRejectFlow().addNftId(nftIds[0]).freezeWith(client); + + let err = false; + try { + tx.setOwnerId(new AccountId(2)); + } catch (error) { + err = true; + } + + expect(err).to.equal(true); + }); + + it("should set token ids", function () { + const tx = new TokenRejectFlow().setTokenIds(tokenIds); + expect(tx.tokenIds).to.deep.equal(tokenIds); + }); + + it("should not be able to set token ids frozen", function () { + const client = Client.forLocalNode(); + const tx = new TokenRejectFlow().setTokenIds().freezeWith(client); + let err = false; + try { + tx.setTokenIds(tokenIds); + } catch (error) { + err = true; + } + + expect(err).to.equal(true); + }); + + it("should be able to set token nft ids", function () { + const tx = new TokenRejectFlow().setNftIds(nftIds); + expect(tx.nftIds).to.deep.equal(nftIds); + }); + + it("should not be able to set nft ids frozen", function () { + const client = Client.forLocalNode(); + const tx = new TokenRejectFlow().setNftIds().freezeWith(client); + let err = false; + try { + tx.setNftIds(nftIds); + } catch (error) { + err = true; + } + + expect(err).to.equal(true); + }); +}); diff --git a/test/unit/TokenRejectTransaction.js b/test/unit/TokenRejectTransaction.js new file mode 100644 index 000000000..eb8b785bd --- /dev/null +++ b/test/unit/TokenRejectTransaction.js @@ -0,0 +1,112 @@ +/* eslint-disable mocha/no-setup-in-describe */ +import { + AccountId, + NftId, + Timestamp, + TokenId, + TokenRejectTransaction, + Transaction, + TransactionId, +} from "../../src/index.js"; + +describe("Transaction", function () { + const owner = new AccountId(1); + const tokenIds = [new TokenId(2)]; + const nftId = new NftId(tokenIds[0], 3); + it("encodes to correct protobuf", async function () { + const owner = new AccountId(1); + const tokenReject = new TokenRejectTransaction() + .setOwnerId(owner) + .setTokenIds(tokenIds) + .setNftIds([nftId]); + + const protobuf = await tokenReject._makeTransactionData(); + expect(protobuf).to.deep.include({ + owner: owner._toProtobuf(), + rejections: [ + { + fungibleToken: tokenIds[0]._toProtobuf(), + }, + { + nft: nftId._toProtobuf(), + }, + ], + }); + }); + + it("decodes from protobuf", async function () { + const tx = new TokenRejectTransaction() + .setOwnerId(owner) + .setTokenIds(tokenIds) + .setNftIds([nftId]); + + const decodedBackTx = Transaction.fromBytes(tx.toBytes()); + expect(tx.ownerId.toString()).to.equal( + decodedBackTx.ownerId.toString(), + ); + expect(tx.tokenIds.toString()).to.equal( + decodedBackTx.tokenIds.toString(), + ); + expect(tx.nftIds.toString()).to.equal(decodedBackTx.nftIds.toString()); + }); + + it("should set owner id", function () { + const owner = new AccountId(1); + const tx = new TokenRejectTransaction().setOwnerId(owner); + expect(tx.ownerId).to.equal(owner); + }); + + it("should revert when updating owner id while frozen", function () { + const owner = new AccountId(1); + const timestamp = new Timestamp(14, 15); + + const tx = new TokenRejectTransaction() + .setTransactionId(TransactionId.withValidStart(owner, timestamp)) + .setNodeAccountIds([new AccountId(10, 11, 12)]) + .freeze(); + + expect(() => tx.setOwnerId(new AccountId(2))).to.throw( + "transaction is immutable; it has at least one signature or has been explicitly frozen", + ); + }); + + it("should set token ids", function () { + const tokenIds = [new TokenId(1), new TokenId(2)]; + const tx = new TokenRejectTransaction().setTokenIds(tokenIds); + expect(tx.tokenIds).to.deep.equal(tokenIds); + }); + + it("should revert when updating token ids when frozen", function () { + const tokenIds = [new TokenId(1), new TokenId(2)]; + const owner = new AccountId(1); + const timestamp = new Timestamp(14, 15); + + const tx = new TokenRejectTransaction() + .setNodeAccountIds([new AccountId(10, 11, 12)]) + .setTransactionId(TransactionId.withValidStart(owner, timestamp)) + .freeze(); + expect(() => tx.setTokenIds(tokenIds)).to.throw( + "transaction is immutable; it has at least one signature or has been explicitly frozen", + ); + }); + + it("should set nft ids", function () { + const nftIds = [new NftId(1), new NftId(2)]; + const tx = new TokenRejectTransaction().setNftIds(nftIds); + expect(tx.nftIds).to.deep.equal(nftIds); + }); + + it("should revert when updating nft ids when frozen", function () { + const nftIds = [new NftId(1), new NftId(2)]; + const owner = new AccountId(1); + const timestamp = new Timestamp(14, 15); + + const tx = new TokenRejectTransaction() + .setNodeAccountIds([new AccountId(10, 11, 12)]) + .setTransactionId(TransactionId.withValidStart(owner, timestamp)) + .freeze(); + expect(() => tx.setNftIds(nftIds)).to.throw( + "transaction is immutable; it has at least one signature or has been explicitly frozen", + ); + }); +});