diff --git a/CHANGELOG.md b/CHANGELOG.md index 6924c53..88316f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## vNEXT +- Top up voucher. (#23) - Claim task part 2 - Add voucher tests. (#21) - Claim task part 1 - Solidity with minimal tests. (#20) - Compute deal price with proper volume. (#19) diff --git a/contracts/IVoucherHub.sol b/contracts/IVoucherHub.sol index 33cba60..647f4af 100644 --- a/contracts/IVoucherHub.sol +++ b/contracts/IVoucherHub.sol @@ -15,6 +15,7 @@ interface IVoucherHub { uint256 voucherType, uint256 value ); + event VoucherToppedUp(address indexed voucher, uint256 value, uint256 voucherExpiration); event VoucherDebited(address indexed voucher, uint256 sponsoredAmount); event VoucherRefunded(address indexed voucher, uint256 amount); event VoucherTypeCreated(uint256 indexed id, string description, uint256 duration); @@ -33,6 +34,7 @@ interface IVoucherHub { uint256 voucherType, uint256 value ) external returns (address voucherAddress); + function topUpVoucher(address voucher, uint256 value) external; function debitVoucher( uint256 voucherTypeId, address app, diff --git a/contracts/VoucherHub.sol b/contracts/VoucherHub.sol index 856dd2f..8d71f1c 100644 --- a/contracts/VoucherHub.sol +++ b/contracts/VoucherHub.sol @@ -177,6 +177,25 @@ contract VoucherHub is emit VoucherCreated(voucherAddress, owner, voucherExpiration, voucherType, value); } + /** + * Top up a voucher by increasing its balance and extending the validity + * period before expiration. + * @param voucher The address of the voucher. + * @param value The amount of credits to top up. + */ + function topUpVoucher(address voucher, uint256 value) external onlyRole(VOUCHER_MANAGER_ROLE) { + VoucherHubStorage storage $ = _getVoucherHubStorage(); + require($._isVoucher[voucher], "VoucherHub: unknown voucher"); + if (value > 0) { + _mint(voucher, value); // VCHR + IERC20($._iexecPoco).transfer(voucher, value); // SRLC + } + uint256 voucherExpiration = block.timestamp + + $.voucherTypes[Voucher(voucher).getType()].duration; + Voucher(voucher).setExpiration(voucherExpiration); + emit VoucherToppedUp(voucher, value, voucherExpiration); + } + /** * Debit voucher balance when used assets are eligible to voucher sponsoring. * @notice (1) If this function is called by an account which is not a voucher, diff --git a/contracts/beacon/IVoucher.sol b/contracts/beacon/IVoucher.sol index dd96701..c2d0f22 100644 --- a/contracts/beacon/IVoucher.sol +++ b/contracts/beacon/IVoucher.sol @@ -6,6 +6,7 @@ import {IexecLibOrders_v5} from "@iexec/poco/contracts/libs/IexecLibOrders_v5.so pragma solidity ^0.8.20; interface IVoucher { + event ExpirationUpdated(uint256 expiration); event AccountAuthorized(address indexed account); event AccountUnauthorized(address indexed account); event OrdersMatchedWithVoucher(bytes32 dealId); @@ -32,6 +33,7 @@ interface IVoucher { function getVoucherHub() external view returns (address); function getType() external view returns (uint256); function getExpiration() external view returns (uint256); + function setExpiration(uint256 expiration) external; function getBalance() external view returns (uint256); function isAccountAuthorized(address account) external view returns (bool); function getSponsoredAmount(bytes32 dealId) external view returns (uint256); diff --git a/contracts/beacon/Voucher.sol b/contracts/beacon/Voucher.sol index dfccfa9..28df4cd 100644 --- a/contracts/beacon/Voucher.sol +++ b/contracts/beacon/Voucher.sol @@ -255,6 +255,17 @@ contract Voucher is Initializable, IVoucher { return $._expiration; } + /** + * Set the expiration timestamp of the voucher. + * @param expiration The expiration timestamp. + */ + function setExpiration(uint256 expiration) external { + VoucherStorage storage $ = _getVoucherStorage(); + require(msg.sender == $._voucherHub, "Voucher: sender is not VoucherHub"); + $._expiration = expiration; + emit ExpirationUpdated(expiration); + } + /** * Retrieve the type of the voucher. * @return voucherType The type of the voucher. diff --git a/test/VoucherHub.test.ts b/test/VoucherHub.test.ts index 9c49c78..5e6c6a2 100644 --- a/test/VoucherHub.test.ts +++ b/test/VoucherHub.test.ts @@ -4,12 +4,17 @@ import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; import { loadFixture } from '@nomicfoundation/hardhat-toolbox/network-helpers'; import { expect } from 'chai'; -import { AddressLike, BigNumberish } from 'ethers'; +import { AddressLike, BigNumberish, Wallet } from 'ethers'; import { ethers } from 'hardhat'; import * as commonUtils from '../scripts/common'; import * as voucherHubUtils from '../scripts/voucherHubUtils'; import * as voucherUtils from '../scripts/voucherUtils'; -import { IexecPocoMock, IexecPocoMock__factory, Voucher } from '../typechain-types'; +import { + IexecPocoMock, + IexecPocoMock__factory, + Voucher, + Voucher__factory, +} from '../typechain-types'; import { VoucherHub } from '../typechain-types/contracts'; import { random } from './utils/address-utils'; @@ -659,6 +664,86 @@ describe('VoucherHub', function () { }); }); + describe('Top up voucher', function () { + let [voucherOwner1, anyone]: SignerWithAddress[] = []; + let voucherHub: VoucherHub; + let voucherAddress: string; + + beforeEach(async function () { + ({ voucherHub, voucherOwner1, anyone } = await loadFixture(deployFixture)); + await voucherHubWithAssetEligibilityManagerSigner + .createVoucherType(description, duration) + .then((tx) => tx.wait()); + voucherAddress = await voucherHubWithVoucherManagerSigner + .createVoucher(voucherOwner1, voucherType, voucherValue) + .then((tx) => tx.wait()) + .then(() => voucherHub.getVoucher(voucherOwner1)); + }); + + it('Should top up voucher', async function () { + const topUpValue = 123n; // arbitrary value + const voucherCreditBalanceBefore = await voucherHub.balanceOf(voucherAddress); + const voucherRlcBalanceBefore = await iexecPocoInstance.balanceOf(voucherAddress); + + const tx = await voucherHubWithVoucherManagerSigner.topUpVoucher( + voucherAddress, + topUpValue, + ); + const txReceipt = await tx.wait(); + const expectedExpiration = await commonUtils.getExpectedExpiration(duration, txReceipt); + await expect(tx) + .to.emit(voucherHub, 'VoucherToppedUp') + .withArgs(voucherAddress, topUpValue, expectedExpiration); + const voucherCreditBalanceAfter = await voucherHub.balanceOf(voucherAddress); + const voucherRlcBalanceAfter = await iexecPocoInstance.balanceOf(voucherAddress); + expect(voucherCreditBalanceAfter) + .equal(voucherCreditBalanceBefore + topUpValue) + .equal(voucherRlcBalanceBefore + topUpValue) + .equal(voucherRlcBalanceAfter); + expect( + await Voucher__factory.connect(voucherAddress, anyone).getExpiration(), + ).to.be.equal(expectedExpiration); + }); + + it('Should top up voucher without value', async function () { + const voucherCreditBalanceBefore = await voucherHub.balanceOf(voucherAddress); + const voucherRlcBalanceBefore = await iexecPocoInstance.balanceOf(voucherAddress); + const expirationBefore = await Voucher__factory.connect( + voucherAddress, + anyone, + ).getExpiration(); + + await expect( + voucherHubWithVoucherManagerSigner.topUpVoucher(voucherAddress, 0n), + ).to.emit(voucherHub, 'VoucherToppedUp'); + expect(await voucherHub.balanceOf(voucherAddress)) + .equal(voucherCreditBalanceBefore) + .equal(await iexecPocoInstance.balanceOf(voucherAddress)) + .equal(voucherRlcBalanceBefore); + expect( + // expiration is extended even if top up value is empty + await Voucher__factory.connect(voucherAddress, anyone).getExpiration(), + ).greaterThan(expirationBefore); + }); + + it('Should not top up by anyone', async function () { + await expect( + voucherHub + .connect(anyone) + .topUpVoucher(Wallet.createRandom().address, voucherValue), + ).to.revertedWithCustomError(voucherHub, 'AccessControlUnauthorizedAccount'); + }); + + it('Should not top up unknown voucher', async function () { + await expect( + voucherHubWithVoucherManagerSigner.topUpVoucher( + Wallet.createRandom().address, + voucherValue, + ), + ).to.revertedWith('VoucherHub: unknown voucher'); + }); + }); + describe('Debit voucher', function () { let [voucherOwner1, voucherOwner2, voucher, anyone]: SignerWithAddress[] = []; let voucherHub: VoucherHub; diff --git a/test/beacon/Voucher.test.ts b/test/beacon/Voucher.test.ts index 8e01b09..036dde3 100644 --- a/test/beacon/Voucher.test.ts +++ b/test/beacon/Voucher.test.ts @@ -15,6 +15,7 @@ import { UpgradeableBeacon, Voucher, VoucherHub, + VoucherProxy__factory, Voucher__factory, } from '../../typechain-types'; import { random } from '../utils/address-utils'; @@ -209,6 +210,41 @@ describe('Voucher', function () { }); }); + describe('Voucher expiration', function () { + it('Should get expiration', async function () { + const expiration = 42; // arbitrary value + const voucher = await new VoucherProxy__factory(anyone) + .deploy(await beacon.getAddress()) + .then((tx) => tx.waitForDeployment()) + .then((proxy) => proxy.getAddress()) + .then((address) => Voucher__factory.connect(address, anyone)); + await voucher.initialize(anyone.address, voucherHub, expiration, voucherType); + expect(await voucher.getExpiration()).equal(expiration); + }); + + it('Should set expiration', async function () { + const voucherHubSigner = await ethers.getImpersonatedSigner( + await voucherHub.getAddress(), + ); + const expirationBefore = await voucherAsAnyone.getExpiration(); + const expirationAfter = 13; // arbitrary value + await expect( + await voucherAsAnyone.connect(voucherHubSigner).setExpiration(expirationAfter), + ) + .to.emit(voucherAsAnyone, 'ExpirationUpdated') + .withArgs(expirationAfter); + expect(await voucherAsAnyone.getExpiration()) + .equal(expirationAfter) + .not.equal(expirationBefore); + }); + + it('Should not set expiration', async function () { + await expect( + voucherAsAnyone.setExpiration(789), // any expiration value is fine + ).to.be.revertedWith('Voucher: sender is not VoucherHub'); + }); + }); + describe('Authorization', function () { it('Should authorize an account', async function () { expect(await voucherAsOwner.isAccountAuthorized(anyone.address)).to.be.false;