From 0c6f5c1a7f9dda591174527fdeef939574b77d39 Mon Sep 17 00:00:00 2001 From: Negar Date: Wed, 18 Oct 2023 23:42:15 +1000 Subject: [PATCH] feat: task opt in and opt out (#148) * feat: adding opt in * feat: adding initial stage of opt out * fix: fixing opt out and opt in * test: added test and fixing other tests * fix: fix a test * fix: fixing exceeded timeout for test * docs: added initial document * fix: pr reviews * test: fix a test * feat: adding multiple opt in * docs: changing docs for multiple opt in * fix: fix transfer tests * fix: edit the settings * docs: adding docs based on pr reviews * fix: fix export for opt in and out * docs: update generated docs * refactor: refactoring based on pr review * docs: updating generated docs * chore: fixing audit error * fix: removing redundant functions * docs: updating generated docs --- docs/capabilities/asset.md | 16 ++++ docs/code/modules/index.md | 60 ++++++++++++- package-lock.json | 164 ++++++++++++++++++++++++---------- src/asset.spec.ts | 168 +++++++++++++++++++++++++++++++++++ src/asset.ts | 175 +++++++++++++++++++++++++++++++++++++ src/index.ts | 3 +- src/testing/asset.ts | 19 +--- src/transfer.spec.ts | 28 +++--- 8 files changed, 557 insertions(+), 76 deletions(-) create mode 100644 docs/capabilities/asset.md create mode 100644 src/asset.spec.ts create mode 100644 src/asset.ts diff --git a/docs/capabilities/asset.md b/docs/capabilities/asset.md new file mode 100644 index 00000000..828856fe --- /dev/null +++ b/docs/capabilities/asset.md @@ -0,0 +1,16 @@ +# Assets + +The asset management functions include opting in and out of assets, which are fundamental to asset interaction in a blockchain environment. +To see some usage examples check out the [automated tests](../../src/asset.spec.ts). + +## `optIn` + +Before an account can receive a specific asset, it must `opt-in` to receive it. An opt-in transaction places an asset holding of 0 into the account and increases its minimum balance by [100,000 microAlgos](https://developer.algorand.org/docs/get-details/asa/#assets-overview). +The `optIn` function facilitates the opt-in process for an account to multiple assets, allowing the account to receive and hold those assets. + +## `optOut` + +An account can opt out of an asset at any time. This means that the account will no longer hold the asset, and the account will no longer be able to receive the asset. The account also recovers the Minimum Balance Requirement for the asset (100,000 microAlgos) +The `optOut` function manages the opt-out process, permitting the account to discontinue holding a group of assets. + +> **Note**:It's essential to note that an account can only opt-out of an asset if its balance of that asset is zero. diff --git a/docs/code/modules/index.md b/docs/code/modules/index.md index f230c56b..c56d6f18 100644 --- a/docs/code/modules/index.md +++ b/docs/code/modules/index.md @@ -73,6 +73,8 @@ - [mnemonicAccount](index.md#mnemonicaccount) - [mnemonicAccountFromEnvironment](index.md#mnemonicaccountfromenvironment) - [multisigAccount](index.md#multisigaccount) +- [optIn](index.md#optin) +- [optOut](index.md#optout) - [performAtomicTransactionComposerDryrun](index.md#performatomictransactioncomposerdryrun) - [performAtomicTransactionComposerSimulate](index.md#performatomictransactioncomposersimulate) - [performTemplateSubstitution](index.md#performtemplatesubstitution) @@ -103,7 +105,7 @@ The AlgoKit config. To update it use the configure method. #### Defined in -[src/index.ts:16](https://github.com/algorandfoundation/algokit-utils-ts/blob/main/src/index.ts#L16) +[src/index.ts:17](https://github.com/algorandfoundation/algokit-utils-ts/blob/main/src/index.ts#L17) ## Functions @@ -1930,6 +1932,62 @@ A multisig account wrapper ___ +### optIn + +▸ **optIn**(`client`, `account`, `assetIds`): `Promise`<`Record`<`number`, `string`\>\> + +Opt in to a list of assets on the Algorand blockchain. + +#### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `client` | `default` | An instance of the Algodv2 class from the `algosdk` library. | +| `account` | `default` | An instance of the Account class from the `algosdk` library representing the account that wants to opt in to the assets. | +| `assetIds` | `number`[] | An array of asset IDs that the account wants to opt in to. | + +#### Returns + +`Promise`<`Record`<`number`, `string`\>\> + +A record object where the keys are the asset IDs and the values are the corresponding transaction IDs for successful opt-ins. + +**`Throws`** + +If there is an error during the opt-in process. + +#### Defined in + +[src/asset.ts:77](https://github.com/algorandfoundation/algokit-utils-ts/blob/main/src/asset.ts#L77) + +___ + +### optOut + +▸ **optOut**(`client`, `account`, `assetIds`): `Promise`<`Record`<`number`, `string`\>\> + +Opt out of multiple assets in Algorand blockchain. + +#### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `client` | `default` | An instance of the Algodv2 client used to interact with the Algorand blockchain. | +| `account` | `default` | The Algorand account that wants to opt out of the assets. | +| `assetIds` | `number`[] | An array of asset IDs representing the assets to opt out of. | + +#### Returns + +`Promise`<`Record`<`number`, `string`\>\> + +- A record object containing asset IDs as keys and their corresponding transaction IDs as values. + +#### Defined in + +[src/asset.ts:130](https://github.com/algorandfoundation/algokit-utils-ts/blob/main/src/asset.ts#L130) + +___ + ### performAtomicTransactionComposerDryrun ▸ **performAtomicTransactionComposerDryrun**(`atc`, `algod`): `Promise`<`DryrunResult`\> diff --git a/package-lock.json b/package-lock.json index 5ac663bd..ddb9e229 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,17 +71,89 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", - "integrity": "sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dev": true, "dependencies": { - "@babel/highlight": "^7.22.5" + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/compat-data": { "version": "7.22.6", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.6.tgz", @@ -131,13 +203,13 @@ "peer": true }, "node_modules/@babel/generator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.5.tgz", - "integrity": "sha512-+lcUbnTRhd0jOewtFSedLyiPsD5tswKkbgcezOqqWFUVNEwoUTlpPOBmvhG7OXWLR4jMdv0czPGH5XbflnD1EA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "peer": true, "dependencies": { - "@babel/types": "^7.22.5", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -167,9 +239,9 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, "peer": true, "engines": { @@ -177,14 +249,14 @@ } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "peer": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -283,9 +355,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, "engines": { "node": ">=6.9.0" @@ -317,13 +389,13 @@ } }, "node_modules/@babel/highlight": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz", - "integrity": "sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.5", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "engines": { @@ -402,9 +474,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.6.tgz", - "integrity": "sha512-EIQu22vNkceq3LbjAq7knDf/UmtI2qbcNI8GRBlijez6TpQLvSodJPYfydQmNA5buwkxxxa/PVI44jjYZ+/cLw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true, "peer": true, "bin": { @@ -606,35 +678,35 @@ } }, "node_modules/@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "peer": true, "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.6.tgz", - "integrity": "sha512-53CijMvKlLIDlOTrdWiHileRddlIiwUIyCKqYa7lYnnPldXCG5dUSN38uT0cA6i7rHWNKJLH0VU/Kxdr1GzB3w==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "peer": true, "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/generator": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.6", - "@babel/types": "^7.22.5", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -653,14 +725,14 @@ } }, "node_modules/@babel/types": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.5.tgz", - "integrity": "sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { diff --git a/src/asset.spec.ts b/src/asset.spec.ts new file mode 100644 index 00000000..8639d445 --- /dev/null +++ b/src/asset.spec.ts @@ -0,0 +1,168 @@ +import { describe, test } from '@jest/globals' +import algosdk from 'algosdk' +import * as algokit from './' +import { algos, microAlgos } from './amount' +import { optIn, optOut } from './asset' +import { algorandFixture } from './testing' +import { ensureFunds, generateTestAsset } from './testing/asset' +import { ensureFunded } from './transfer' + +describe('asset', () => { + const localnet = algorandFixture() + beforeEach(localnet.beforeEach, 100_000) + + test('OptIn an asset to an account succeed', async () => { + const { algod, testAccount, kmd } = localnet.context + const dummyAssetId = await generateTestAsset(algod, testAccount, 1) + const dummyAssetIds = [dummyAssetId] + const secondAccount = algosdk.generateAccount() + + const secondAccountInfo = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfo['total-assets-opted-in']).toBe(0) + + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, dummyAssetIds) + + const testAccountInfoAfterOptIn = await algod.accountInformation(secondAccount.addr).do() + expect(testAccountInfoAfterOptIn['total-assets-opted-in']).toBe(1) + }) + + test('OptIn assets to an account second attempt failed ', async () => { + const { algod, testAccount, kmd } = localnet.context + const dummyAssetId = await generateTestAsset(algod, testAccount, 0) + const dummyAssetId2 = await generateTestAsset(algod, testAccount, 0) + const dummyAssetIds = [dummyAssetId, dummyAssetId2] + const secondAccount = algosdk.generateAccount() + + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, dummyAssetIds) + + const secondAccountInfo = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfo['total-assets-opted-in']).toBe(2) + + // await optIn(algod, secondAccount, dummyAssetIds) + await expect(optIn(algod, secondAccount, [dummyAssetId])).rejects.toThrow( + `Assets ${dummyAssetId} cannot be opted in. Ensure that they are valid and that the account has not previously opted into them.`, + ) + }, 10e6) + + test('OptIn two batches of asset to an account succeed', async () => { + const { algod, testAccount, kmd } = localnet.context + const dummyAssetIds: number[] = [] + const secondAccount = algosdk.generateAccount() + for (let i = 0; i < 20; i++) { + const dummyAssetId = await generateTestAsset(algod, testAccount, 0) + dummyAssetIds.push(dummyAssetId) + } + await ensureFunded( + { + accountToFund: secondAccount, + minSpendingBalance: microAlgos(1), + minFundingIncrement: algos(3), + }, + algod, + kmd, + ) + await optIn(algod, secondAccount, dummyAssetIds) + const secondAccountInfo = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfo['total-assets-opted-in']).toBe(20) + }, 10e6) + + test('OptOut of an asset to an account succeed', async () => { + const { algod, testAccount, kmd } = localnet.context + const dummyAssetId = await generateTestAsset(algod, testAccount, 0) + const dummyAssetId2 = await generateTestAsset(algod, testAccount, 0) + const dummyAssetIds = [dummyAssetId, dummyAssetId2] + const secondAccount = algosdk.generateAccount() + + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, dummyAssetIds) + + const secondAccountInfo = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfo['total-assets-opted-in']).toBe(2) + + await optOut(algod, secondAccount, dummyAssetIds) + + const secondAccountInfoAfterOptOut = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfoAfterOptOut['total-assets-opted-in']).toBe(0) + }) + + test('OptOut of an not opt-in asset to an account failed ', async () => { + const { algod, testAccount, kmd } = localnet.context + const dummyAssetId = await generateTestAsset(algod, testAccount, 0) + const dummyAssetIds = [dummyAssetId, 1234567, -132] + const secondAccount = algosdk.generateAccount() + + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, [dummyAssetId]) + + const secondAccountInfo = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfo['total-assets-opted-in']).toBe(1) + + await expect(optOut(algod, secondAccount, dummyAssetIds)).rejects.toThrow( + 'Assets 1234567, -132 cannot be opted out. Ensure that they are valid and that the account has previously opted into them and holds zero balance.', + ) + + const secondAccountInfoAfterFailedOptOut = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfoAfterFailedOptOut['total-assets-opted-in']).toBe(1) + }) + + test('OptOut of an non-zero balance asset to an account failed ', async () => { + const { algod, testAccount, kmd } = localnet.context + const dummyAssetId = await generateTestAsset(algod, testAccount, 0) + const dummyAssetId2 = await generateTestAsset(algod, testAccount, 0) + const dummyAssetIds = [dummyAssetId, dummyAssetId2] + const secondAccount = algosdk.generateAccount() + + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, dummyAssetIds) + + const secondAccountInfo = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfo['total-assets-opted-in']).toBe(2) + + await algokit.transferAsset( + { + from: testAccount, + to: secondAccount.addr, + assetId: dummyAssetId, + amount: 5, + note: `Transfer 5 assets with id ${dummyAssetId}`, + }, + algod, + ) + + await expect(optOut(algod, secondAccount, dummyAssetIds)).rejects.toThrow( + `Assets ${dummyAssetId} cannot be opted out. Ensure that they are valid and that the account has previously opted into them and holds zero balance.`, + ) + + const secondAccountInfoAfterFailedOptOut = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfoAfterFailedOptOut['total-assets-opted-in']).toBe(2) + }) + + test('OptOut of two batches of asset to an account succeed', async () => { + const { algod, testAccount, kmd } = localnet.context + const dummyAssetIds: number[] = [] + const secondAccount = algosdk.generateAccount() + for (let i = 0; i < 20; i++) { + const dummyAssetId = await generateTestAsset(algod, testAccount, 0) + dummyAssetIds.push(dummyAssetId) + } + await ensureFunded( + { + accountToFund: secondAccount, + minSpendingBalance: microAlgos(1), + minFundingIncrement: algos(3), + }, + algod, + kmd, + ) + await optIn(algod, secondAccount, dummyAssetIds) + const secondAccountInfo = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfo['total-assets-opted-in']).toBe(20) + + await optOut(algod, secondAccount, dummyAssetIds) + + const secondAccountInfoAfterOptOut = await algod.accountInformation(secondAccount.addr).do() + expect(secondAccountInfoAfterOptOut['total-assets-opted-in']).toBe(0) + }, 10e6) +}) diff --git a/src/asset.ts b/src/asset.ts new file mode 100644 index 00000000..90131e9b --- /dev/null +++ b/src/asset.ts @@ -0,0 +1,175 @@ +import algosdk, { Account, Algodv2 } from 'algosdk' +import { Config, sendGroupOfTransactions } from '.' +import { TransactionGroupToSend, TransactionToSign } from './types/transaction' + +const MaxTxGroupSize = 16 + +enum ValidationType { + OptIn, + OptOut, +} + +function* chunks(arr: T[], n: number): Generator { + for (let i = 0; i < arr.length; i += n) yield arr.slice(i, i + n) +} + +async function ensureAccountIsValid(client: algosdk.Algodv2, account: algosdk.Account) { + try { + await client.accountInformation(account.addr).do() + } catch (error) { + throw new Error(`Account address ${account.addr} does not exist`) + } +} + +async function ensureAssetBalanceConditions( + client: algosdk.Algodv2, + account: algosdk.Account, + assetIds: number[], + validationType: ValidationType, +) { + const accountInfo = await client.accountInformation(account.addr).do() + const assetPromises = assetIds.map(async (assetId) => { + if (validationType === ValidationType.OptIn) { + if (accountInfo.assets.find((a: Record) => a['asset-id'] === assetId)) { + Config.logger.debug(`Account ${account.addr} has already opted-in to asset ${assetId}`) + return assetId + } + } else if (validationType === ValidationType.OptOut) { + try { + const accountAssetInfo = await client.accountAssetInformation(account.addr, assetId).do() + if (accountAssetInfo['asset-holding']['amount'] !== 0) { + Config.logger.debug(`Asset ${assetId} is not with zero balance`) + return assetId + } + } catch (e) { + Config.logger.debug(`Account ${account.addr} does not have asset ${assetId}`) + return assetId + } + } + return null + }) + + const invalidAssets = (await Promise.all(assetPromises)).filter((assetId) => assetId !== null) + if (invalidAssets.length > 0) { + let errorMsg = '' + if (validationType === ValidationType.OptIn) { + errorMsg = `Assets ${invalidAssets.join( + ', ', + )} cannot be opted in. Ensure that they are valid and that the account has not previously opted into them.` + } else if (validationType === ValidationType.OptOut) { + errorMsg = `Assets ${invalidAssets.join( + ', ', + )} cannot be opted out. Ensure that they are valid and that the account has previously opted into them and holds zero balance.` + } + throw new Error(errorMsg) + } +} + +/** + * Opt in to a list of assets on the Algorand blockchain. + * + * @param client - An instance of the Algodv2 class from the `algosdk` library. + * @param account - An instance of the Account class from the `algosdk` library representing the account that wants to opt in to the assets. + * @param assetIds - An array of asset IDs that the account wants to opt in to. + * @returns A record object where the keys are the asset IDs and the values are the corresponding transaction IDs for successful opt-ins. + * @throws If there is an error during the opt-in process. + */ +export async function optIn(client: Algodv2, account: Account, assetIds: number[]) { + const result: Record = {} + await ensureAccountIsValid(client, account) + await ensureAssetBalanceConditions(client, account, assetIds, ValidationType.OptIn) + + const assets = await Promise.all(assetIds.map((aid) => client.getAssetByID(aid).do())) + const suggestedParams = await client.getTransactionParams().do() + + for (const assetGroup of chunks(assets, MaxTxGroupSize)) { + try { + const transactionToSign: TransactionToSign[] = assetGroup.flatMap((asset) => [ + { + transaction: algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({ + from: account.addr, + to: account.addr, + assetIndex: asset.index, + amount: 0, + rekeyTo: undefined, + revocationTarget: undefined, + closeRemainderTo: undefined, + suggestedParams, + }), + signer: account, + }, + ]) + const txnGrp: TransactionGroupToSend = { + transactions: transactionToSign, + signer: account, + } + const sendGroupOfTransactionsResult = await sendGroupOfTransactions(txnGrp, client) + assetGroup.map((asset, index) => { + result[asset.index] = sendGroupOfTransactionsResult.txIds[index] + + Config.logger.info( + `Successfully opted in of asset ${asset.index} with transaction ID ${sendGroupOfTransactionsResult.txIds[index]}, + grouped under ${sendGroupOfTransactionsResult.groupId} round ${sendGroupOfTransactionsResult.confirmations}.`, + ) + }) + } catch (e) { + throw new Error(`Received error trying to opt in ${e}`) + } + } + return result +} + +/** + * Opt out of multiple assets in Algorand blockchain. + * + * @param {Algodv2} client - An instance of the Algodv2 client used to interact with the Algorand blockchain. + * @param {Account} account - The Algorand account that wants to opt out of the assets. + * @param {number[]} assetIds - An array of asset IDs representing the assets to opt out of. + * @returns {Promise>} - A record object containing asset IDs as keys and their corresponding transaction IDs as values. + */ +export async function optOut(client: Algodv2, account: Account, assetIds: number[]): Promise> { + const result: Record = {} + + // Verify assets + await ensureAccountIsValid(client, account) + await ensureAssetBalanceConditions(client, account, assetIds, ValidationType.OptOut) + + const assets = await Promise.all(assetIds.map((aid) => client.getAssetByID(aid).do())) + const suggestedParams = await client.getTransactionParams().do() + + for (const assetGroup of chunks(assets, MaxTxGroupSize)) { + try { + const transactionToSign: TransactionToSign[] = assetGroup.flatMap((asset) => [ + { + transaction: algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({ + assetIndex: asset.index, + amount: 0, + from: account.addr, + to: account.addr, + rekeyTo: undefined, + revocationTarget: undefined, + suggestedParams, + closeRemainderTo: asset.params.creator, + }), + signer: account, + }, + ]) + const txnGrp: TransactionGroupToSend = { + transactions: transactionToSign, + signer: account, + } + const sendGroupOfTransactionsResult = await sendGroupOfTransactions(txnGrp, client) + assetGroup.map((asset, index) => { + result[asset.index] = sendGroupOfTransactionsResult.txIds[index] + + Config.logger.info( + `Successfully opted out of asset ${asset.index} with transaction ID ${sendGroupOfTransactionsResult.txIds[index]}, + grouped under ${sendGroupOfTransactionsResult.groupId} round ${sendGroupOfTransactionsResult.confirmations}.`, + ) + }) + } catch (e) { + throw new Error(`Received error trying to opt out ${e}`) + } + } + return result +} diff --git a/src/index.ts b/src/index.ts index d129363c..7aa7bccc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,12 +5,13 @@ export * from './amount' export * from './app' export * from './app-client' export * from './app-deploy' +export { optIn, optOut } from './asset' +export * from './dispenser-client' export * from './indexer-lookup' export * from './localnet' export * from './network-client' export * from './transaction' export * from './transfer' -export * from './dispenser-client' /** The AlgoKit config. To update it use the configure method. */ export const Config = new UpdatableConfig() diff --git a/src/testing/asset.ts b/src/testing/asset.ts index 4f745968..09cd6809 100644 --- a/src/testing/asset.ts +++ b/src/testing/asset.ts @@ -1,6 +1,6 @@ import { Account, Algodv2, Kmd, makeAssetCreateTxnWithSuggestedParamsFromObject } from 'algosdk' import { algos, microAlgos } from '../amount' -import { ensureFunded, transferAsset } from '../transfer' +import { ensureFunded } from '../transfer' export async function generateTestAsset(client: Algodv2, sender: Account, total?: number) { total = !total ? Math.floor(Math.random() * 100) + 20 : total @@ -36,20 +36,7 @@ export async function generateTestAsset(client: Algodv2, sender: Account, total? return assetId } -export async function optIn(algod: Algodv2, account: Account, assetId: number) { - await transferAsset( - { - from: account, - to: account.addr, - assetId, - amount: 0, - note: `Opt in asset id ${assetId}`, - }, - algod, - ) -} - -export async function ensureFundsAndOptIn(algod: Algodv2, account: Account, assetId: number, kmd: Kmd) { +export async function ensureFunds(algod: Algodv2, account: Account, kmd: Kmd) { await ensureFunded( { accountToFund: account, @@ -59,6 +46,4 @@ export async function ensureFundsAndOptIn(algod: Algodv2, account: Account, asse algod, kmd, ) - - return optIn(algod, account, assetId) } diff --git a/src/transfer.spec.ts b/src/transfer.spec.ts index 93f69ef2..3042ca68 100644 --- a/src/transfer.spec.ts +++ b/src/transfer.spec.ts @@ -2,8 +2,9 @@ import { describe, test } from '@jest/globals' import algosdk, { TransactionType } from 'algosdk' import invariant from 'tiny-invariant' import * as algokit from './' +import { optIn } from './asset' import { algorandFixture } from './testing' -import { ensureFundsAndOptIn, generateTestAsset, optIn } from './testing/asset' +import { ensureFunds, generateTestAsset } from './testing/asset' describe('transfer', () => { const localnet = algorandFixture() @@ -75,7 +76,8 @@ describe('transfer', () => { const dummyAssetId = await generateTestAsset(algod, testAccount, 100) const secondAccount = algosdk.generateAccount() - await ensureFundsAndOptIn(algod, secondAccount, dummyAssetId, kmd) + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, [dummyAssetId]) try { await algokit.transferAsset( @@ -99,8 +101,9 @@ describe('transfer', () => { const dummyAssetId = await generateTestAsset(algod, testAccount, 100) const secondAccount = algosdk.generateAccount() - await ensureFundsAndOptIn(algod, secondAccount, dummyAssetId, kmd) - await optIn(algod, testAccount, dummyAssetId) + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, [dummyAssetId]) + try { await algokit.transferAsset( { @@ -123,8 +126,9 @@ describe('transfer', () => { const dummyAssetId = await generateTestAsset(algod, testAccount, 100) const secondAccount = algosdk.generateAccount() - await ensureFundsAndOptIn(algod, secondAccount, dummyAssetId, kmd) - await optIn(algod, testAccount, dummyAssetId) + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, [dummyAssetId]) + const response = await algokit.transferAsset( { from: testAccount, @@ -146,8 +150,8 @@ describe('transfer', () => { const dummyAssetId = await generateTestAsset(algod, testAccount, 100) const secondAccount = algosdk.generateAccount() - await ensureFundsAndOptIn(algod, secondAccount, dummyAssetId, kmd) - await optIn(algod, testAccount, dummyAssetId) + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, [dummyAssetId]) await algokit.transferAsset( { @@ -173,9 +177,11 @@ describe('transfer', () => { const secondAccount = algosdk.generateAccount() const clawbackAccount = algosdk.generateAccount() - await ensureFundsAndOptIn(algod, secondAccount, dummyAssetId, kmd) - await ensureFundsAndOptIn(algod, clawbackAccount, dummyAssetId, kmd) - await optIn(algod, testAccount, dummyAssetId) + await ensureFunds(algod, secondAccount, kmd) + await optIn(algod, secondAccount, [dummyAssetId]) + + await ensureFunds(algod, clawbackAccount, kmd) + await optIn(algod, clawbackAccount, [dummyAssetId]) await algokit.transferAsset( {