diff --git a/.prettierrc b/.prettierrc index 07a2cea..ac0fa89 100644 --- a/.prettierrc +++ b/.prettierrc @@ -9,4 +9,3 @@ "arrowParens": "always", "endOfLine": "lf" } - diff --git a/docs/test-specifications/token-service/TokenDisscoiateTransaction.md b/docs/test-specifications/token-service/TokenDissociateTransaction.md similarity index 90% rename from docs/test-specifications/token-service/TokenDisscoiateTransaction.md rename to docs/test-specifications/token-service/TokenDissociateTransaction.md index 4f5d1f7..674f2d0 100644 --- a/docs/test-specifications/token-service/TokenDisscoiateTransaction.md +++ b/docs/test-specifications/token-service/TokenDissociateTransaction.md @@ -54,12 +54,12 @@ The tests contained in this specification will assume that a valid account and a | Test no | Name | Input | Expected response | Implemented (Y/N) | |---------|------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|-------------------| -| 1 | Dissociates a token from an account | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation succeeds and the token is associated with . | N | -| 2 | Dissociates a token from an account with which it is already dissociated | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_NOT_ASSOCIATED_TO_ACCOUNT response code from the network. | N | -| 3 | Dissociates a token from an account without signing with the account's private key | accountId=, tokenIds=[] | The token dissociation fails with an INVALID_SIGNATURE response code from the network. | N | -| 4 | Dissociates a token from an account that doesn't exist | accountId="123.456.789", tokenIds=[] | The token dissociation fails with an INVALID_ACCOUNT_ID response code from the network. | N | -| 5 | Dissociates a token from an account that is deleted | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an INVALID_ACCOUNT_ID response code from the network. | N | -| 6 | Dissociates a token from an empty account | accountId="", tokenIds=[] | The token dissociation fails with an SDK internal error. | N | +| 1 | Dissociates a token from an account | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation succeeds and the token is associated with . | Y | +| 2 | Dissociates a token from an account with which it is already dissociated | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_NOT_ASSOCIATED_TO_ACCOUNT response code from the network. | Y | +| 3 | Dissociates a token from an account without signing with the account's private key | accountId=, tokenIds=[] | The token dissociation fails with an INVALID_SIGNATURE response code from the network. | Y | +| 4 | Dissociates a token from an account that doesn't exist | accountId="123.456.789", tokenIds=[] | The token dissociation fails with an INVALID_ACCOUNT_ID response code from the network. | Y | +| 5 | Dissociates a token from an account that is deleted | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an INVALID_ACCOUNT_ID response code from the network. | Y | +| 6 | Dissociates a token from an empty account | accountId="", tokenIds=[] | The token dissociation fails with an SDK internal error. | Y | #### JSON Request Example @@ -101,17 +101,17 @@ The tests contained in this specification will assume that a valid account and a | Test no | Name | Input | Expected response | Implemented (Y/N) | |---------|-----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-------------------| -| 1 | Dissociates no tokens from an account | accountId=, commonTransactionParams.signers=[] | The token dissociation succeeds and no disassociations are made. | N | -| 2 | Dissociates a token that doesn't exist from an account | accountId=, tokenIds=["123.456.789"], commonTransactionParams.signers=[] | The token dissociation fails with an INVALID_TOKEN_ID response code from the network. | N | -| 3 | Dissociates a token that is deleted from an account | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_WAS_DELETED response code from the network. | N | -| 4 | Dissociates a token that is empty from an account | accountId=, tokenIds=[""], commonTransactionParams.signers=[] | The token dissociation fails with an SDK internal error. | N | -| 5 | Dissociates a token twice from an account | accountId=, tokenIds=[, ], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_ID_REPEATED_IN_TOKEN_LIST response code from the network. | N | -| 6 | Dissociates three valid tokens from an account | accountId=, tokenIds=[, , ], commonTransactionParams.signers=[] | The token dissociation succeeds and three disassociations are made. | N | -| 7 | Dissociates two valid tokens and an invalid token from an account | accountId=, tokenIds=[, , "123.456.789"], commonTransactionParams.signers=[] | The token dissociation fails with an INVALID_TOKEN_ID response code from the network. | N | -| 8 | Dissociates two valid tokens and a deleted token from an account | accountId=, tokenIds=[, , ], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_WAS_DELETED response code from the network. | N | +| 1 | Dissociates no tokens from an account | accountId=, commonTransactionParams.signers=[] | The token dissociation succeeds and no disassociations are made. | Y | +| 2 | Dissociates a token that doesn't exist from an account | accountId=, tokenIds=["123.456.789"], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_NOT_ASSOCIATED_TO_ACCOUNT response code from the network. | Y | +| 3 | Dissociates a token that is deleted from an account | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_NOT_ASSOCIATED_TO_ACCOUNT response code from the network. | Y | +| 4 | Dissociates a token that is empty from an account | accountId=, tokenIds=[""], commonTransactionParams.signers=[] | The token dissociation fails with an SDK internal error. | Y | +| 5 | Dissociates a token twice from an account | accountId=, tokenIds=[, ], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_ID_REPEATED_IN_TOKEN_LIST response code from the network. | Y | +| 6 | Dissociates three valid tokens from an account | accountId=, tokenIds=[, , ], commonTransactionParams.signers=[] | The token dissociation succeeds and three disassociations are made. | Y | +| 7 | Dissociates two valid and associated tokens and an invalid token from an account | accountId=, tokenIds=[, , "123.456.789"], commonTransactionParams.signers=[] | The token dissociation fails with an INVALID_TOKEN_ID response code from the network. | Y | +| 8 | Dissociates two valid and associated tokens and a deleted token from an account | accountId=, tokenIds=[, , ], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_WAS_DELETED response code from the network. | Y | | 9 | Dissociates a token from an account while that account has a balance of the token | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an TRANSACTION_REQUIRES_ZERO_TOKEN_BALANCES response code from the network. | N | -| 10 | Dissociates a token from an account while its frozen for the account | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an ACCOUNT_FROZEN_FOR_TOKEN response code from the network. | N | -| 11 | Dissociates a token from an account while the token is paused | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_IS_PAUSED response code from the network. | N | +| 10 | Dissociates a token from an account while its frozen for the account | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an ACCOUNT_FROZEN_FOR_TOKEN response code from the network. | Y | +| 11 | Dissociates a token from an account while the token is paused | accountId=, tokenIds=[], commonTransactionParams.signers=[] | The token dissociation fails with an TOKEN_IS_PAUSED response code from the network. | Y | #### JSON Request Example diff --git a/mirrorNodeClient.js b/mirrorNodeClient.js new file mode 100644 index 0000000..1d9e4d4 --- /dev/null +++ b/mirrorNodeClient.js @@ -0,0 +1,54 @@ +import axios from "axios"; + +class MirrorNodeClient { + constructor() { + this.mirrorNodeRestUrl = process.env.MIRROR_NODE_REST_URL; + this.NODE_TIMEOUT = process.env.NODE_TIMEOUT; + } + + async getAccountData(accountId) { + const url = `${this.mirrorNodeRestUrl}/api/v1/accounts/${accountId}`; + return this.retryUntilData(url); + } + + async getBalanceData() { + const url = `${this.mirrorNodeRestUrl}/api/v1/balances`; + return this.retryUntilData(url); + } + + async getTokenData(tokenId) { + const url = `${this.mirrorNodeRestUrl}/api/v1/tokens/${tokenId}`; + return this.retryUntilData(url); + } + + async getTokenRelationships(accountId) { + const url = `${this.mirrorNodeRestUrl}/api/v1/accounts/${accountId}/tokens`; + return this.retryUntilData(url); + } + + async retryUntilData(url) { + const maxRetries = Math.floor(this.NODE_TIMEOUT / 1000); // retry once per second + let retries = 0; + + while (retries < maxRetries) { + try { + const response = await axios.get(url); + + if (response.data) { + return response.data; + } + } catch (error) { + // Uncomment if you want to see the error + // console.error(error); + } + + // If the array is empty, delay for a second before the next try + await new Promise((resolve) => setTimeout(resolve, 1000)); + retries++; + } + + throw new Error("Max retries reached without data"); + } +} + +export default new MirrorNodeClient(); diff --git a/src/services/MirrorNodeClient.ts b/src/services/MirrorNodeClient.ts index 75e459e..07813e4 100644 --- a/src/services/MirrorNodeClient.ts +++ b/src/services/MirrorNodeClient.ts @@ -1,5 +1,5 @@ -import { fetchData } from "../utils/helpers/fetch-data"; -import { retryOnError } from "../utils/helpers/retry-on-error"; +import { fetchData } from "@helpers/fetch-data"; +import { retryOnError } from "@helpers/retry-on-error"; class MirrorNodeClient { private mirrorNodeRestUrl: string | undefined; @@ -25,6 +25,12 @@ class MirrorNodeClient { const url = `${this.mirrorNodeRestUrl}/api/v1/tokens/${tokenId}`; return retryOnError(async () => fetchData(url)); } + + // TODO: Get mirror node interface with OpenAPI + async getTokenRelationships(accountId: string): Promise { + const url = `${this.mirrorNodeRestUrl}/api/v1/accounts/${accountId}/tokens`; + return retryOnError(async () => fetchData(url)); + } } export default new MirrorNodeClient(); diff --git a/src/tests/token-service/test-token-dissociate-transaction.ts b/src/tests/token-service/test-token-dissociate-transaction.ts new file mode 100644 index 0000000..f63f632 --- /dev/null +++ b/src/tests/token-service/test-token-dissociate-transaction.ts @@ -0,0 +1,519 @@ +import { assert, expect } from "chai"; + +import { JSONRPCRequest } from "@services/Client"; +import mirrorNodeClient from "@services/MirrorNodeClient"; + +import { setOperator } from "@helpers/setup-tests"; +import { retryOnError } from "@helpers/retry-on-error"; +import { ErrorStatusCodes } from "@enums/error-status-codes"; + +/** + * Tests for TokenDissociateTransaction + */ +describe("TokenDissociateTransaction", function () { + // Tests should not take longer than 30 seconds to fully execute. + this.timeout(30000); + + // All tests require an account and a token to be created, and to have the two be associated. + let tokenId: string, + tokenKey: string, + accountId: string, + accountPrivateKey: string; + beforeEach(async function () { + await setOperator( + this, + process.env.OPERATOR_ACCOUNT_ID as string, + process.env.OPERATOR_ACCOUNT_PRIVATE_KEY as string, + ); + + tokenKey = ( + await JSONRPCRequest(this, "generateKey", { + type: "ed25519PrivateKey", + }) + ).key; + + tokenId = ( + await JSONRPCRequest(this, "createToken", { + name: "testname", + symbol: "testsymbol", + treasuryAccountId: process.env.OPERATOR_ACCOUNT_ID, + freezeKey: tokenKey, + tokenType: "ft", + pauseKey: tokenKey, + }) + ).tokenId; + + accountPrivateKey = ( + await JSONRPCRequest(this, "generateKey", { + type: "ed25519PrivateKey", + }) + ).key; + + accountId = ( + await JSONRPCRequest(this, "createAccount", { + key: accountPrivateKey, + }) + ).accountId; + + await JSONRPCRequest(this, "associateToken", { + accountId, + tokenIds: [tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + }); + afterEach(async function () { + await JSONRPCRequest(this, "reset"); + }); + + async function verifyTokenAssociation(accountId: string, tokenId: string) { + // No way to get token associations via consensus node, so just query mirror node. + const mirrorNodeInfo = + await mirrorNodeClient.getTokenRelationships(accountId); + + let foundToken = false; + for (let i = 0; i < mirrorNodeInfo.tokens.length; i++) { + if (mirrorNodeInfo.tokens[i].token_id === tokenId) { + foundToken = true; + break; + } + } + + expect(foundToken).to.be.true; + } + + async function verifyNoTokenAssociations(accountId: string) { + // No way to get token associations via consensus node, so just query mirror node. + const mirrorNodeInfo = + await mirrorNodeClient.getTokenRelationships(accountId); + expect(mirrorNodeInfo.tokens.length).to.equal(0); + } + + describe("Account ID", function () { + it("(#1) Dissociates a token from an account", async function () { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + + await retryOnError(async () => { + await verifyNoTokenAssociations(accountId); + }); + }); + + it("(#2) Dissociates a token from an account with which it is already dissociated", async function () { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "TOKEN_NOT_ASSOCIATED_TO_ACCOUNT"); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#3) Dissociates a token from an account without signing with the account's private key", async function () { + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId], + }); + } catch (err: any) { + assert.equal(err.data.status, "INVALID_SIGNATURE"); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#4) Dissociates a token from an account that doesn't exist", async function () { + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId: "123.456.789", + tokenIds: [tokenId], + }); + } catch (err: any) { + assert.equal(err.data.status, "INVALID_ACCOUNT_ID"); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#5) Dissociates a token from an account that is deleted", async function () { + await JSONRPCRequest(this, "deleteAccount", { + deleteAccountId: accountId, + transferAccountId: process.env.OPERATOR_ACCOUNT_ID, + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "ACCOUNT_DELETED"); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#6) Dissociates a token from an empty account", async function () { + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId: "", + tokenIds: [tokenId], + }); + } catch (err: any) { + assert.equal( + err.code, + ErrorStatusCodes.INTERNAL_ERROR, + "Internal error", + ); + return; + } + + assert.fail("Should throw an error"); + }); + }); + + describe("Token IDs", function () { + it("(#1) Dissociates no tokens from an account", async function () { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + + await retryOnError(async () => { + verifyTokenAssociation(accountId, tokenId); + }); + }); + + it("(#2) Dissociates a token that doesn't exist from an account", async function () { + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: ["123.456.789"], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "TOKEN_NOT_ASSOCIATED_TO_ACCOUNT"); + return; + } + + assert.fail("Should throw an error"); + }); + + it.skip("(#3) Dissociates a token that is deleted from an account", async function () { + const adminKey = ( + await JSONRPCRequest(this, "generateKey", { + type: "ecdsaSecp256k1PrivateKey", + }) + ).key; + + const deletedTokenId = ( + await JSONRPCRequest(this, "createToken", { + name: "testname", + symbol: "testsymbol", + treasuryAccountId: process.env.OPERATOR_ACCOUNT_ID, + adminKey, + tokenType: "ft", + commonTransactionParams: { + signers: [adminKey], + }, + }) + ).tokenId; + + await JSONRPCRequest(this, "deleteToken", { + tokenId: deletedTokenId, + commonTransactionParams: { + signers: [adminKey], + }, + }); + + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [deletedTokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "TOKEN_WAS_DELETED"); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#4) Dissociates a token that is empty from an account", async function () { + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [""], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal( + err.code, + ErrorStatusCodes.INTERNAL_ERROR, + "Internal error", + ); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#5) Dissociates a token twice from an account", async function () { + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId, tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "TOKEN_ID_REPEATED_IN_TOKEN_LIST"); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#6) Dissociates three valid tokens from an account", async function () { + const secondTokenId = ( + await JSONRPCRequest(this, "createToken", { + name: "testname", + symbol: "testsymbol", + treasuryAccountId: process.env.OPERATOR_ACCOUNT_ID, + tokenType: "ft", + }) + ).tokenId; + + const thirdTokenId = ( + await JSONRPCRequest(this, "createToken", { + name: "testname", + symbol: "testsymbol", + treasuryAccountId: process.env.OPERATOR_ACCOUNT_ID, + tokenType: "ft", + }) + ).tokenId; + + await JSONRPCRequest(this, "associateToken", { + accountId, + tokenIds: [secondTokenId, thirdTokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId, secondTokenId, thirdTokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + + await retryOnError(async () => { + await verifyNoTokenAssociations(accountId); + }); + }); + + it.skip("(#7) Dissociates two valid and associated tokens and an invalid token from an account", async function () { + const secondTokenId = ( + await JSONRPCRequest(this, "createToken", { + name: "testname", + symbol: "testsymbol", + treasuryAccountId: process.env.OPERATOR_ACCOUNT_ID, + tokenType: "ft", + }) + ).tokenId; + + await JSONRPCRequest(this, "associateToken", { + accountId, + tokenId: secondTokenId, + commonTransactionParams: { + signers: [accountPrivateKey] + } + }); + + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId, secondTokenId, "123.456.789"], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "INVALID_TOKEN_ID"); + return; + } + + assert.fail("Should throw an error"); + }); + + it.skip("(#8) Dissociates two valid and associated tokens and a deleted token from an account", async function () { + const secondTokenId = (await JSONRPCRequest(this, "createToken", { + name: "testname", + symbol: "testsymbol", + treasuryAccountId: process.env.OPERATOR_ACCOUNT_ID, + tokenType: "ft", + })).tokenId; + + const adminKey = (await JSONRPCRequest(this, "generateKey", { + type: "ecdsaSecp256k1PrivateKey", + })).key; + + const deletedTokenId = (await JSONRPCRequest(this, "createToken", { + name: "testname", + symbol: "testsymbol", + treasuryAccountId: process.env.OPERATOR_ACCOUNT_ID, + adminKey, + tokenType: "ft", + commonTransactionParams: { + signers: [adminKey], + }, + })).tokenId; + + await JSONRPCRequest(this, "deleteToken", { + tokenId: deletedTokenId, + commonTransactionParams: { + signers: [adminKey], + }, + }); + + await JSONRPCRequest(this, "associateToken", { + accountId, + tokenIds: [secondTokenId], + commonTransactionParams: { + signers: [accountPrivateKey] + } + }); + + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId, secondTokenId, deletedTokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "TOKEN_WAS_DELETED"); + return; + } + + assert.fail("Should throw an error"); + }); + + it.skip("(#9) Dissociates a token from an account while that account has a balance of the token", async function () { + // TODO: implement TransferTransaction here and transfer balance of token to account. + + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal( + err.data.status, + "TRANSACTION_REQUIRES_ZERO_TOKEN_BALANCES", + ); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#10) Dissociates a token from an account while its frozen for the account", async function () { + await JSONRPCRequest(this, "freezeToken", { + tokenId, + accountId, + commonTransactionParams: { + signers: [tokenKey], + }, + }); + + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "ACCOUNT_FROZEN_FOR_TOKEN"); + return; + } + + assert.fail("Should throw an error"); + }); + + it("(#11) Dissociates a token from an account while the token is paused", async function () { + await JSONRPCRequest(this, "pauseToken", { + tokenId, + accountId, + commonTransactionParams: { + signers: [tokenKey], + }, + }); + + try { + await JSONRPCRequest(this, "dissociateToken", { + accountId, + tokenIds: [tokenId], + commonTransactionParams: { + signers: [accountPrivateKey], + }, + }); + } catch (err: any) { + assert.equal(err.data.status, "TOKEN_IS_PAUSED"); + return; + } + + assert.fail("Should throw an error"); + }); + }); + + return Promise.resolve(); +});