diff --git a/contracts/bootstrap/package.json b/contracts/bootstrap/package.json index 5751cb2f..9dce2feb 100644 --- a/contracts/bootstrap/package.json +++ b/contracts/bootstrap/package.json @@ -1,6 +1,6 @@ { "name": "bootstrap", - "version": "0.8.4", + "version": "0.8.5", "description": "", "main": "index.ts", "scripts": { diff --git a/contracts/package.json b/contracts/package.json index fc5b64a8..f0c4d76f 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -1,6 +1,6 @@ { "name": "reti-contracts", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "scripts": { "generate-client": "algokit generate client contracts/artifacts/ --language typescript --output contracts/clients/{contract_name}Client.ts && ./update_contract_artifacts.sh``", diff --git a/ui/package.json b/ui/package.json index 61cdf9be..846d7104 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "reti-ui", - "version": "0.8.4", + "version": "0.8.5", "author": { "name": "Doug Richar", "email": "drichar@gmail.com" diff --git a/ui/src/api/algod.ts b/ui/src/api/algod.ts index 20adfab4..0d5c9b89 100644 --- a/ui/src/api/algod.ts +++ b/ui/src/api/algod.ts @@ -3,6 +3,7 @@ import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' import { AccountBalance, AccountInformation, + AlgodHttpError, Asset, AssetCreatorHolding, AssetHolding, @@ -67,6 +68,48 @@ export async function fetchAssetHoldings(address: string | null): Promise { + if (!address) { + throw new Error('No address provided') + } + if (!assetId) { + throw new Error('No assetId provided') + } + try { + const assetHolding = await algodClient.accountAssetInformation(address, assetId).do() + return assetHolding as AssetHolding + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.status && error.body?.message) { + throw new AlgodHttpError({ + status: error.status, + body: error.body, + headers: error.headers, + ok: error.ok, + text: error.text, + }) + } else { + throw error + } + } +} + +export async function isOptedInToAsset(address: string | null, assetId: number): Promise { + try { + await fetchAccountAssetInformation(address, assetId) + return true + } catch (error: unknown) { + if (error instanceof AlgodHttpError && error.status === 404) { + return false + } else { + throw error + } + } +} + export async function fetchAssetCreatorHoldings( address: string | null, ): Promise { diff --git a/ui/src/api/contracts.ts b/ui/src/api/contracts.ts index f6f00451..d3b776fc 100644 --- a/ui/src/api/contracts.ts +++ b/ui/src/api/contracts.ts @@ -2,6 +2,7 @@ import * as algokit from '@algorandfoundation/algokit-utils' import { TransactionSignerAccount } from '@algorandfoundation/algokit-utils/types/account' import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' import algosdk from 'algosdk' +import { isOptedInToAsset } from '@/api/algod' import { getSimulateStakingPoolClient, getSimulateValidatorClient, @@ -521,6 +522,7 @@ export async function addStake( validatorId: number, stakeAmount: number, // microalgos valueToVerify: number, + rewardTokenId: number, signer: algosdk.TransactionSigner, activeAddress: string, authAddr?: string, @@ -537,9 +539,19 @@ export async function addStake( suggestedParams, }) + const rewardTokenOptInTxn = algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({ + from: activeAddress, + to: activeAddress, + amount: 0, + assetIndex: rewardTokenId, + suggestedParams, + }) + + const needsOptInTxn = rewardTokenId > 0 && !(await isOptedInToAsset(activeAddress, rewardTokenId)) + const simulateValidatorClient = await getSimulateValidatorClient(activeAddress, authAddr) - const simulateResults = await simulateValidatorClient + const simulateComposer = simulateValidatorClient .compose() .gas({}) .addStake( @@ -553,16 +565,25 @@ export async function addStake( }, { sendParams: { fee: AlgoAmount.MicroAlgos(240_000) } }, ) - .simulate({ allowEmptySignatures: true, allowUnnamedResources: true }) + + if (needsOptInTxn) { + simulateComposer.addTransaction(rewardTokenOptInTxn) + } + + const simulateResults = await simulateComposer.simulate({ + allowEmptySignatures: true, + allowUnnamedResources: true, + }) stakeTransferPayment.group = undefined + rewardTokenOptInTxn.group = undefined // @todo: switch to Joe's new method(s) - const feesAmount = AlgoAmount.MicroAlgos( + const feeAmount = AlgoAmount.MicroAlgos( 2000 + 1000 * ((simulateResults.simulateResponse.txnGroups[0].appBudgetAdded as number) / 700), ) - const results = await validatorClient + const composer = validatorClient .compose() .gas({}) .addStake( @@ -574,11 +595,16 @@ export async function addStake( validatorId, valueToVerify, }, - { sendParams: { fee: feesAmount } }, + { sendParams: { fee: feeAmount } }, ) - .execute({ populateAppCallResources: true }) - const [valId, poolId, poolAppId] = results.returns![1] + if (needsOptInTxn) { + composer.addTransaction(rewardTokenOptInTxn) + } + + const result = await composer.execute({ populateAppCallResources: true }) + + const [valId, poolId, poolAppId] = result.returns![1] return { poolId: Number(poolId), @@ -629,7 +655,12 @@ export async function fetchStakedPoolsForAccount(staker: string): Promise ({ + // Filter out potential duplicates (temporary UI fix for duplicate staked pools bug) + const uniqueStakedPools = Array.from( + new Set(stakedPools.map((sp) => JSON.stringify(sp.map((v) => Number(v))))), + ).map((sp) => JSON.parse(sp) as (typeof stakedPools)[0]) + + return uniqueStakedPools.map(([validatorId, poolId, poolAppId]) => ({ validatorId: Number(validatorId), poolId: Number(poolId), poolAppId: Number(poolAppId), @@ -795,17 +826,30 @@ export async function fetchProtocolConstraints( export async function removeStake( poolAppId: number | bigint, amountToUnstake: number, + rewardTokenId: number, signer: algosdk.TransactionSigner, activeAddress: string, authAddr?: string, ) { + const suggestedParams = await ParamsCache.getSuggestedParams() + + const rewardTokenOptInTxn = algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({ + from: activeAddress, + to: activeAddress, + amount: 0, + assetIndex: rewardTokenId, + suggestedParams, + }) + + const needsOptInTxn = rewardTokenId > 0 && !(await isOptedInToAsset(activeAddress, rewardTokenId)) + const stakingPoolSimulateClient = await getSimulateStakingPoolClient( poolAppId, activeAddress, authAddr, ) - const simulateResult = await stakingPoolSimulateClient + const simulateComposer = stakingPoolSimulateClient .compose() .gas({}, { note: '1', sendParams: { fee: AlgoAmount.MicroAlgos(0) } }) .gas({}, { note: '2', sendParams: { fee: AlgoAmount.MicroAlgos(0) } }) @@ -816,19 +860,29 @@ export async function removeStake( }, { sendParams: { fee: AlgoAmount.MicroAlgos(240_000) } }, ) - .simulate({ allowEmptySignatures: true, allowUnnamedResources: true }) + + if (needsOptInTxn) { + simulateComposer.addTransaction(rewardTokenOptInTxn) + } + + const simulateResult = await simulateComposer.simulate({ + allowEmptySignatures: true, + allowUnnamedResources: true, + }) // @todo: switch to Joe's new method(s) - const feesAmount = AlgoAmount.MicroAlgos( + const feeAmount = AlgoAmount.MicroAlgos( 1000 * Math.floor( ((simulateResult.simulateResponse.txnGroups[0].appBudgetAdded as number) + 699) / 700, ), ) + rewardTokenOptInTxn.group = undefined + const stakingPoolClient = await getStakingPoolClient(poolAppId, signer, activeAddress) - await stakingPoolClient + const composer = stakingPoolClient .compose() .gas({}, { note: '1', sendParams: { fee: AlgoAmount.MicroAlgos(0) } }) .gas({}, { note: '2', sendParams: { fee: AlgoAmount.MicroAlgos(0) } }) @@ -837,9 +891,14 @@ export async function removeStake( staker: activeAddress, amountToUnstake, }, - { sendParams: { fee: feesAmount } }, + { sendParams: { fee: feeAmount } }, ) - .execute({ populateAppCallResources: true }) + + if (needsOptInTxn) { + composer.addTransaction(rewardTokenOptInTxn) + } + + await composer.execute({ populateAppCallResources: true }) } export async function epochBalanceUpdate( @@ -863,7 +922,7 @@ export async function epochBalanceUpdate( .simulate({ allowEmptySignatures: true, allowUnnamedResources: true }) // @todo: switch to Joe's new method(s) - const feesAmount = AlgoAmount.MicroAlgos( + const feeAmount = AlgoAmount.MicroAlgos( 3000 + 1000 * ((simulateResult.simulateResponse.txnGroups[0].appBudgetAdded as number) / 700), ) @@ -873,7 +932,7 @@ export async function epochBalanceUpdate( .compose() .gas({}, { note: '1', sendParams: { fee: AlgoAmount.MicroAlgos(0) } }) .gas({}, { note: '2', sendParams: { fee: AlgoAmount.MicroAlgos(0) } }) - .epochBalanceUpdate({}, { sendParams: { fee: feesAmount } }) + .epochBalanceUpdate({}, { sendParams: { fee: feeAmount } }) .execute({ populateAppCallResources: true }) } catch (error) { console.error(error) @@ -992,7 +1051,7 @@ export async function claimTokens( ) // @todo: switch to Joe's new method(s) - const feesAmount = AlgoAmount.MicroAlgos( + const feeAmount = AlgoAmount.MicroAlgos( 1000 * Math.floor( ((simulateResult.simulateResponse.txnGroups[0].appBudgetAdded as number) + 699) / 700, @@ -1005,7 +1064,7 @@ export async function claimTokens( const client = await getStakingPoolClient(pool.poolAppId, signer, activeAddress) await client.gas({}, { note: '1', sendParams: { atc: atc2, fee: AlgoAmount.MicroAlgos(0) } }) await client.gas({}, { note: '2', sendParams: { atc: atc2, fee: AlgoAmount.MicroAlgos(0) } }) - await client.claimTokens({}, { sendParams: { atc: atc2, fee: feesAmount } }) + await client.claimTokens({}, { sendParams: { atc: atc2, fee: feeAmount } }) } await algokit.sendAtomicTransactionComposer( diff --git a/ui/src/components/AddStakeModal.tsx b/ui/src/components/AddStakeModal.tsx index b373d71f..1b142041 100644 --- a/ui/src/components/AddStakeModal.tsx +++ b/ui/src/components/AddStakeModal.tsx @@ -292,6 +292,7 @@ export function AddStakeModal({ validator!.id, totalAmount, valueToVerify, + validator!.config.rewardTokenId, transactionSigner, activeAddress, authAddress, diff --git a/ui/src/components/UnstakeModal.tsx b/ui/src/components/UnstakeModal.tsx index 046f0fbf..d5d17246 100644 --- a/ui/src/components/UnstakeModal.tsx +++ b/ui/src/components/UnstakeModal.tsx @@ -187,6 +187,7 @@ export function UnstakeModal({ validator, setValidator, stakesByValidator }: Uns await removeStake( pool.poolKey.poolAppId, amountToUnstake, + validator!.config.rewardTokenId, transactionSigner, activeAddress, authAddress, diff --git a/ui/src/interfaces/algod.ts b/ui/src/interfaces/algod.ts index f56d302f..2941321a 100644 --- a/ui/src/interfaces/algod.ts +++ b/ui/src/interfaces/algod.ts @@ -40,6 +40,32 @@ export interface Asset { params: AssetParams } +export class AlgodHttpError extends Error { + public status: number + public body: { + message: string + } + public headers: Record + public ok: boolean + public text: string + + constructor(response: { + status: number + body: { message: string } + headers: Record + ok: boolean + text: string + }) { + super(response.body.message) + this.name = 'AlgodHttpError' + this.status = response.status + this.body = response.body + this.headers = response.headers + this.ok = response.ok + this.text = response.text + } +} + export interface NodeStatusResponse { /** * CatchupTime in nanoseconds diff --git a/ui/src/utils/contracts.spec.ts b/ui/src/utils/contracts.spec.ts index e76ca75a..4a4cb697 100644 --- a/ui/src/utils/contracts.spec.ts +++ b/ui/src/utils/contracts.spec.ts @@ -261,10 +261,10 @@ describe('calculateRewardEligibility', () => { expect(calculateRewardEligibility(10, 1000, undefined)).toBeNull() }) - it('should calculate correct percentage when entry round and payout are in the past', () => { + it('should calculate correct percentage when entry round is halfway through an epoch', () => { const epochRoundLength = 100 const lastPoolPayoutRound = 900 - const entryRound = 850 + const entryRound = 950 expect(calculateRewardEligibility(epochRoundLength, lastPoolPayoutRound, entryRound)).toBe(50) }) @@ -290,23 +290,23 @@ describe('calculateRewardEligibility', () => { }) it('should round down to the nearest integer', () => { - const epochRoundLength = 100 - const lastPoolPayoutRound = 300 - const entryRound = 251 // Exact eligibility is 49% - expect(calculateRewardEligibility(epochRoundLength, lastPoolPayoutRound, entryRound)).toBe(49) + const epochRoundLength = 200 + const lastPoolPayoutRound = 600 + const entryRound = 651 // Exact eligibility is 74.5% + expect(calculateRewardEligibility(epochRoundLength, lastPoolPayoutRound, entryRound)).toBe(74) }) it('should never return more than 100%', () => { const epochRoundLength = 100 const lastPoolPayoutRound = 300 - const entryRound = 200 + const entryRound = 200 // Calculated eligibility is 200% expect(calculateRewardEligibility(epochRoundLength, lastPoolPayoutRound, entryRound)).toBe(100) }) it('should never return less than 0%', () => { const epochRoundLength = 100 const lastPoolPayoutRound = 100 - const entryRound = 200 // Future round beyond the current epoch + const entryRound = 250 // Future round beyond the current epoch expect(calculateRewardEligibility(epochRoundLength, lastPoolPayoutRound, entryRound)).toBe(0) }) }) diff --git a/ui/src/utils/contracts.ts b/ui/src/utils/contracts.ts index b9f7aea6..51891f4f 100644 --- a/ui/src/utils/contracts.ts +++ b/ui/src/utils/contracts.ts @@ -596,11 +596,11 @@ export function calculateRewardEligibility( return 0 } - // Calculate the effective rounds staked within the current epoch starting from the last payout - const roundsInEpoch = Math.max(0, lastPoolPayoutRound - entryRound) + // Calculate the effective rounds remaining in the current epoch + const remainingRoundsInEpoch = Math.max(0, nextPayoutRound - entryRound) // Calculate eligibility as a percentage of the epoch length - const eligibilePercent = (roundsInEpoch / epochRoundLength) * 100 + const eligibilePercent = (remainingRoundsInEpoch / epochRoundLength) * 100 // Ensure eligibility is within 0-100% range const rewardEligibility = Math.max(0, Math.min(eligibilePercent, 100))