Skip to content

Commit

Permalink
Merge pull request #30 from CoMakery/UP-216-fix-disable-howtwallet-fo…
Browse files Browse the repository at this point in the history
…r-algorand

[UP-216] Fix disable hotwallet for algorand
  • Loading branch information
Alexey1100 authored Jan 27, 2023
2 parents 0b511ec + 74a1e8a commit 7590a5e
Show file tree
Hide file tree
Showing 4 changed files with 63 additions and 88 deletions.
61 changes: 30 additions & 31 deletions lib/blockchains/AlgorandBlockchain.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class AlgorandBlockchain {
// It is necessary to cache values gotten from blockchain
this.optedInApps = {}
this.algoBalances = {}
this.minBalances = {}
this.tokenBalances = {}
this.txValidator = new TxValidator({
type: 'algorand',
Expand Down Expand Up @@ -129,8 +130,6 @@ class AlgorandBlockchain {
}

async getTokenBalance(hotWalletAddress) {
if (hotWalletAddress in this.tokenBalances) { return this.tokenBalances[hotWalletAddress] }

const localState = (await this.getAppLocalState(hotWalletAddress, this.envs.optInApp))
const values = localState["key-value"]
if (!values) { return 0 }
Expand Down Expand Up @@ -167,12 +166,11 @@ class AlgorandBlockchain {
}

async getAlgoBalanceForHotWallet(hotWalletAddress) {
if (hotWalletAddress in this.algoBalances) { return this.algoBalances[hotWalletAddress] }

let balance
let balance, minBalance
try {
const accountInfo = await this.algodClient.accountInformation(hotWalletAddress).do()
balance = accountInfo.amount
minBalance = accountInfo['min-balance']
} catch (err) {
if (err.message.includes('no accounts found for address')) {
balance = 0
Expand All @@ -182,17 +180,26 @@ class AlgorandBlockchain {
}

this.algoBalances[hotWalletAddress] = new BigNumber(balance)
this.minBalances[hotWalletAddress] = new BigNumber(minBalance)
return this.algoBalances[hotWalletAddress]
}

async getAccountMinBalance(hotWalletAddress) {
if (hotWalletAddress in this.minBalances) { return this.minBalances[hotWalletAddress] }

await this.getAlgoBalanceForHotWallet(hotWalletAddress)
return this.minBalances[hotWalletAddress]
}

async isOptedInToCurrentApp(hotWalletAddress, appIndex) {
const optedInApps = await this.getOptedInAppsForHotWallet(hotWalletAddress)
return optedInApps.includes(appIndex)
}

async enoughCoinBalanceToSendTransaction(hotWalletAddress) {
const algoBalance = await this.getAlgoBalanceForHotWallet(hotWalletAddress)
return algoBalance.isGreaterThan(new BigNumber(algosdk.ALGORAND_MIN_TX_FEE)) // 1000 microalgos

return algoBalance.isGreaterThan((new BigNumber(algosdk.ALGORAND_MIN_TX_FEE)).plus(await this.getAccountMinBalance(hotWalletAddress)))
}

async optInToApp(hotWallet, appToOptIn) {
Expand Down Expand Up @@ -290,9 +297,9 @@ class AlgorandBlockchain {
}
}

async sendWithdrawalCoinsTransaction(coinAddress, amountWithoutFee, hotWallet) {
async sendWithdrawalCoinsTransaction(coinAddress, hotWallet) {
try {
const txn = this.withdrawCoinsTx(hotWallet.address, coinAddress, amountWithoutFee)
const txn = this.withdrawCoinsTx(hotWallet.address, coinAddress)
const txResult = await this.sendTransaction(txn, hotWallet)

console.log(`Transaction has successfully signed and sent by ${hotWallet.address} to blockchain tx hash: ${txResult.transactionId}`)
Expand All @@ -304,14 +311,16 @@ class AlgorandBlockchain {
}
}

withdrawCoinsTx(hotWalletAddress, withdrawalAddress, coinsAmount) {
// Algorand requires reserving minimum amount of coins for each account
// that is why we can pay tx fee and send all remaining coins to another address
withdrawCoinsTx(hotWalletAddress, withdrawalAddress) {
return {
network: this.blockchainNetwork,
txRaw: JSON.stringify({
type: "pay",
from: hotWalletAddress,
to: withdrawalAddress,
amount: coinsAmount,
amount: 0, // all reamaining coins will be sent to remainderTo address
closeRemainderTo: withdrawalAddress
})
}
Expand All @@ -322,6 +331,7 @@ class AlgorandBlockchain {
async disableWallet(withdrawalAddresses, hotWallet) {
console.log("Robo wallet is going to be disabled...")
const result = { tokensWithdrawalTx: {}, coinsWithdrawalTx: {} }

if (withdrawalAddresses.tokenAddress) {
const appTokenBalance = await this.getTokenBalance(hotWallet.address)

Expand All @@ -330,7 +340,6 @@ class AlgorandBlockchain {
await new Promise(resolve => setTimeout(resolve, 1000))
const txObject = this.withdrawAppTokensTx(hotWallet.address, this.envs.optInApp, withdrawalAddresses.tokenAddress, appTokenBalance)
console.log(`Sending ${appTokenBalance} tokens to ${withdrawalAddresses.tokenAddress}`)

const tx = await this.sendTransaction(txObject, hotWallet)

if (typeof tx.valid === 'undefined' || tx.valid) {
Expand Down Expand Up @@ -369,28 +378,18 @@ class AlgorandBlockchain {
}

if (withdrawalAddresses.coinAddress && result.tokensWithdrawalTx.status !== "failed") {
const txParams = await this.algodClient.getTransactionParams().do()
const withdrawalFee = 2 * Math.max(txParams.fee, algosdk.ALGORAND_MIN_TX_FEE)
const coinBalance = await this.getAlgoBalanceForHotWallet(hotWallet.address)
if (coinBalance.isGreaterThan(new BigNumber(withdrawalFee))) {
// ClearApplication is done so we can withdraw full balance without fee
const amountWithoutFee = coinBalance - withdrawalFee
// wait 1 sec to not get too many requests error
await new Promise(resolve => setTimeout(resolve, 1000))

const tx = await this.sendWithdrawalCoinsTransaction(withdrawalAddresses.coinAddress, amountWithoutFee, hotWallet)
if (typeof tx.valid == 'undefined' || tx.valid) {
const msg = `Successfully sent Tx id: ${tx.transactionId}`
console.log(msg)
result.coinsWithdrawalTx = { status: "success", txHash: tx.transactionId, message: msg }
} else {
const msg = "Error during withdraw Algo tx"
console.error(msg)
result.coinsWithdrawalTx = { status: "failed", txHash: null, message: msg }
}
// wait 1 sec to not get too many requests error
await new Promise(resolve => setTimeout(resolve, 1000))

const tx = await this.sendWithdrawalCoinsTransaction(withdrawalAddresses.coinAddress, hotWallet)
if (typeof tx.valid == 'undefined' || tx.valid) {
const msg = `Successfully sent Tx id: ${tx.transactionId}`
console.log(msg)
result.coinsWithdrawalTx = { status: "success", txHash: tx.transactionId, message: msg }
} else {
result.coinsWithdrawalTx = { status: "skipped", txHash: null, message: `ALGO balance is <= ${withdrawalFee}, transaction skipped`}
const msg = "Error during withdraw Algo tx"
console.error(msg)
result.coinsWithdrawalTx = { status: "failed", txHash: null, message: msg }
}
}
return result
Expand Down
39 changes: 0 additions & 39 deletions tests/AlgorandBlockchain.disableWallet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,12 @@ describe("EthereumBlockchain.disableWallet", () => {
}
getTokenBalanceSpy.mockReturnValueOnce(new BigNumber("0"))
isOptedInToCurrentAppSpy.mockReturnValueOnce(true)
getAlgoBalanceForHotWalletSpy.mockReturnValueOnce(new BigNumber(txFee * 2 + 1))
sendTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId })
sendWithdrawalCoinsTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId + 1 })

const validResults = await algorandBlockchain.disableWallet(disableValues, hwAddress)

expect(getTokenBalanceSpy).toHaveBeenCalledTimes(1)
expect(getAlgoBalanceForHotWalletSpy).toHaveBeenCalledTimes(1)
expect(sendTransactionSpy).toHaveBeenCalledTimes(1)
expect(isOptedInToCurrentAppSpy).toHaveBeenCalledTimes(1)
expect(clearAppTxSpy).toHaveBeenCalledTimes(1)
Expand All @@ -139,51 +137,18 @@ describe("EthereumBlockchain.disableWallet", () => {
})
})

test('for zero token balance clear app is executed and coins balance is less than minimum allowed', async () => {
const disableValues = {
tokenAddress: hwAddress,
coinAddress: hwAddress
}
getTokenBalanceSpy.mockReturnValueOnce(new BigNumber("0"))
getAlgoBalanceForHotWalletSpy.mockReturnValueOnce(new BigNumber(txFee * 2))
isOptedInToCurrentAppSpy.mockReturnValueOnce(true)
sendTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId })
sendWithdrawalCoinsTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId + 1 })

const validResults = await algorandBlockchain.disableWallet(disableValues, hwAddress)

expect(getTokenBalanceSpy).toHaveBeenCalledTimes(1)
expect(getAlgoBalanceForHotWalletSpy).toHaveBeenCalledTimes(1)
expect(sendTransactionSpy).toHaveBeenCalledTimes(1)
expect(clearAppTxSpy).toHaveBeenCalledTimes(1)
expect(isOptedInToCurrentAppSpy).toHaveBeenCalledTimes(1)
expect(sendWithdrawalCoinsTransactionSpy).toHaveBeenCalledTimes(0)

expect(validResults).toEqual({
tokensWithdrawalTx: { status: "skipped", txHash: null, message: "Tokens balance is zero, transaction skipped" },
clearAppTx: { status: "success", txHash: transactionId, message: `Successfully sent close out Tx id: ${transactionId}` },
coinsWithdrawalTx: {
status: "skipped",
txHash: null,
message: `ALGO balance is <= ${txFee * 2}, transaction skipped`
}
})
})

test('coin balance withdrawed for already cleared app', async () => {
const disableValues = {
tokenAddress: hwAddress,
coinAddress: hwAddress
}
getTokenBalanceSpy.mockReturnValueOnce(new BigNumber("0"))
getAlgoBalanceForHotWalletSpy.mockReturnValueOnce(new BigNumber(txFee * 2 + 1))
isOptedInToCurrentAppSpy.mockReturnValueOnce(false)
sendWithdrawalCoinsTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId })

const validResults = await algorandBlockchain.disableWallet(disableValues, hwAddress)

expect(getTokenBalanceSpy).toHaveBeenCalledTimes(1)
expect(getAlgoBalanceForHotWalletSpy).toHaveBeenCalledTimes(1)
expect(sendTransactionSpy).toHaveBeenCalledTimes(0)
expect(clearAppTxSpy).toHaveBeenCalledTimes(0)
expect(isOptedInToCurrentAppSpy).toHaveBeenCalledTimes(1)
Expand All @@ -206,14 +171,12 @@ describe("EthereumBlockchain.disableWallet", () => {
coinAddress: hwAddress
}
getTokenBalanceSpy.mockReturnValueOnce(new BigNumber("1000000"))
getAlgoBalanceForHotWalletSpy.mockReturnValueOnce(new BigNumber(txFee * 2 + 1))
sendTransactionSpy.mockReturnValueOnce({ valid: false, transactionId: transactionId })
sendWithdrawalCoinsTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId + 1 })

const validResults = await algorandBlockchain.disableWallet(disableValues, hwAddress)

expect(getTokenBalanceSpy).toHaveBeenCalledTimes(1)
expect(getAlgoBalanceForHotWalletSpy).toHaveBeenCalledTimes(0)
expect(sendTransactionSpy).toHaveBeenCalledTimes(1)
expect(clearAppTxSpy).toHaveBeenCalledTimes(0)
expect(sendWithdrawalCoinsTransactionSpy).toHaveBeenCalledTimes(0)
Expand All @@ -231,7 +194,6 @@ describe("EthereumBlockchain.disableWallet", () => {
coinAddress: hwAddress
}
getTokenBalanceSpy.mockReturnValueOnce(new BigNumber("10"))
getAlgoBalanceForHotWalletSpy.mockReturnValueOnce(new BigNumber(txFee * 2 + 1))
sendTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId })
sendTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId + 1 })
sendWithdrawalCoinsTransactionSpy.mockReturnValueOnce({ valid: true, transactionId: transactionId + 2 })
Expand All @@ -240,7 +202,6 @@ describe("EthereumBlockchain.disableWallet", () => {
const validResults = await algorandBlockchain.disableWallet(disableValues, hwAddress)

expect(getTokenBalanceSpy).toHaveBeenCalledTimes(1)
expect(getAlgoBalanceForHotWalletSpy).toHaveBeenCalledTimes(1)
expect(sendTransactionSpy).toHaveBeenCalledTimes(2)
expect(clearAppTxSpy).toHaveBeenCalledTimes(1)
expect(isOptedInToCurrentAppSpy).toHaveBeenCalledTimes(1)
Expand Down
16 changes: 13 additions & 3 deletions tests/AlgorandBlockchain.getAlgoBalanceForHotWallet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,28 @@ describe("Get Algo balance for Hot Wallet", () => {

test("with succesfully response", async () => {
const hwAlgorand = new hwUtils.AlgorandBlockchain(envs)
const returnValue = {
amount: BigInt(1000)
const getBalance = (balance) => {
return {
amount: BigInt(balance),
'min-balance': BigInt(balance)
}
}
const doMock = jest.fn()
doMock.mockReturnValueOnce(getBalance(1000))
doMock.mockReturnValueOnce(getBalance(900))
jest.spyOn(hwAlgorand.algodClient, "accountInformation").mockImplementation(() => {
return {
do: jest.fn().mockReturnValue(returnValue)
do: doMock
}
});

res = await hwAlgorand.getAlgoBalanceForHotWallet(hwAddress)

expect(res).toEqual(new BigNumber("1000"))

// result is not cached
res = await hwAlgorand.getAlgoBalanceForHotWallet(hwAddress)
expect(res).toEqual(new BigNumber("900"))
})

test("with no account found error", async () => {
Expand Down
35 changes: 20 additions & 15 deletions tests/AlgorandBlockchain.getTokenBalance.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ const envs = {
blockchainNetwork: "algorand_test"
}

const getLocalState = (balance) => {
return {
deleted: false,
id: 13997710,
'key-value': [
{ key: 'YmFsYW5jZQ==', value: { bytes: '', type: 2, uint: balance } },
{ key: 'bG9ja1VudGls', value: { bytes: '', type: 2, uint: 0 } },
{ key: 'bWF4QmFsYW5jZQ==', value: { bytes: '', type: 2, uint: 0 } },
{ key: 'dHJhbnNmZXJHcm91cA==', value: { bytes: '', type: 2, uint: 1 } }
],
'opted-in-at-round': 12725706,
schema: { 'num-byte-slice': 8, 'num-uint': 8 }
}
}

describe("Get token balance", () => {
const hwAddress = "YFGM3UODOZVHSI4HXKPXOKFI6T2YCIK3HKWJYXYFQBONJD4D3HD2DPMYW4"

Expand All @@ -19,27 +34,17 @@ describe("Get token balance", () => {

test("return actual amount from blockchain", async () => {
const hwAlgorand = new hwUtils.AlgorandBlockchain(envs)
const localState = {
deleted: false,
id: 13997710,
'key-value': [
{ key: 'YmFsYW5jZQ==', value: { bytes: '', type: 2, uint: 99 } }, // balance here
{ key: 'bG9ja1VudGls', value: { bytes: '', type: 2, uint: 0 } },
{ key: 'bWF4QmFsYW5jZQ==', value: { bytes: '', type: 2, uint: 0 } },
{ key: 'dHJhbnNmZXJHcm91cA==', value: { bytes: '', type: 2, uint: 1 } }
],
'opted-in-at-round': 12725706,
schema: { 'num-byte-slice': 8, 'num-uint': 8 }
}
jest.spyOn(hwAlgorand, "getAppLocalState").mockReturnValueOnce(localState)
jest.spyOn(hwAlgorand, "getAppLocalState")
.mockReturnValueOnce(getLocalState(99))
.mockReturnValueOnce(getLocalState(100))

res = await hwAlgorand.getTokenBalance(hwAddress)

expect(res).toEqual(99)

// result cached
// result is not cached
res = await hwAlgorand.getTokenBalance(hwAddress)
expect(res).toEqual(99)
expect(res).toEqual(100)
})

test("when local state is empty", async () => {
Expand Down

0 comments on commit 7590a5e

Please sign in to comment.