diff --git a/packages/contracts/contracts/interfaces/IShop.sol b/packages/contracts/contracts/interfaces/IShop.sol index 7fd6634d..d27b5fe1 100644 --- a/packages/contracts/contracts/interfaces/IShop.sol +++ b/packages/contracts/contracts/interfaces/IShop.sol @@ -8,6 +8,7 @@ interface IShop { ACTIVE, INACTIVE } + struct ShopData { bytes32 shopId; // 상점 아이디 string name; // 상점 이름 @@ -16,12 +17,29 @@ interface IShop { address delegator; // 위임자의 지갑주소 uint256 providedAmount; // 제공된 결제통화의 총량 uint256 usedAmount; // 사용된 결제통화의 총량 + uint256 collectedAmount; // 정산관리자에 의해 수집된 결제통화의 총량 uint256 refundedAmount; // 정산된 결제통화의 총량 ShopStatus status; uint256 itemIndex; uint256 accountIndex; } + enum SettlementClientStates { + INVALID, + ACTIVE + } + + struct SettlementClientData { + uint256 index; + SettlementClientStates states; + } + + struct ShopSettlementData { + bytes32 manager; + bytes32[] clients; + mapping(bytes32 => SettlementClientData) clientValues; + } + function setLedger(address _contractAddress) external; function isAvailableId(bytes32 _shopId) external view returns (bool); @@ -41,4 +59,6 @@ interface IShop { function refundableOf(bytes32 _shopId) external view returns (uint256 refundableAmount, uint256 refundableToken); function nonceOf(address _account) external view returns (uint256); + + function settlementManagerOf(bytes32 _shopId) external view returns (bytes32); } diff --git a/packages/contracts/contracts/shop/Shop.sol b/packages/contracts/contracts/shop/Shop.sol index 2da75732..81b30cc0 100644 --- a/packages/contracts/contracts/shop/Shop.sol +++ b/packages/contracts/contracts/shop/Shop.sol @@ -55,6 +55,21 @@ contract Shop is ShopStorage, Initializable, OwnableUpgradeable, UUPSUpgradeable uint256 balanceToken ); + event SetSettlementManager(bytes32 shopId, bytes32 managerShopId); + event RemovedSettlementManager(bytes32 shopId, bytes32 managerShopId); + event CollectedSettlementAmount( + bytes32 clientId, + address clientAccount, + string clientCurrency, + uint256 clientAmount, + uint256 clientTotal, + bytes32 managerId, + address managerAccount, + string managerCurrency, + uint256 managerAmount, + uint256 managerTotal + ); + /// @notice 생성자 function initialize( address _currencyRate, @@ -124,6 +139,7 @@ contract Shop is ShopStorage, Initializable, OwnableUpgradeable, UUPSUpgradeable delegator: address(0x0), providedAmount: 0, usedAmount: 0, + collectedAmount: 0, refundedAmount: 0, status: ShopStatus.ACTIVE, itemIndex: items.length, @@ -133,6 +149,9 @@ contract Shop is ShopStorage, Initializable, OwnableUpgradeable, UUPSUpgradeable shops[_shopId] = data; shopIdByAddress[_account].push(_shopId); + ShopSettlementData storage settlementData = settlements[_shopId]; + settlementData.manager = bytes32(0x0); + nonce[_account]++; ShopData memory shop = shops[_shopId]; @@ -337,7 +356,9 @@ contract Shop is ShopStorage, Initializable, OwnableUpgradeable, UUPSUpgradeable bytes32 _shopId ) external view override returns (uint256 refundableAmount, uint256 refundableToken) { ShopData memory shop = shops[_shopId]; - uint256 settlementAmount = (shop.usedAmount > shop.providedAmount) ? shop.usedAmount - shop.providedAmount : 0; + uint256 settlementAmount = (shop.collectedAmount + shop.usedAmount > shop.providedAmount) + ? shop.collectedAmount + shop.usedAmount - shop.providedAmount + : 0; refundableAmount = (settlementAmount > shop.refundedAmount) ? settlementAmount - shop.refundedAmount : 0; refundableToken = currencyRate.convertCurrencyToToken(refundableAmount, shops[_shopId].currency); } @@ -352,9 +373,12 @@ contract Shop is ShopStorage, Initializable, OwnableUpgradeable, UUPSUpgradeable require(ECDSA.recover(ECDSA.toEthSignedMessageHash(dataHash), _signature) == _account, "1501"); require(shops[_shopId].account == _account, "1050"); require(_amount % 1 gwei == 0, "1030"); + require(settlements[_shopId].manager == bytes32(0x0), "1552"); ShopData memory shop = shops[_shopId]; - uint256 settlementAmount = (shop.usedAmount > shop.providedAmount) ? shop.usedAmount - shop.providedAmount : 0; + uint256 settlementAmount = (shop.collectedAmount + shop.usedAmount > shop.providedAmount) + ? shop.collectedAmount + shop.usedAmount - shop.providedAmount + : 0; uint256 refundableAmount = (settlementAmount > shop.refundedAmount) ? settlementAmount - shop.refundedAmount : 0; @@ -373,9 +397,207 @@ contract Shop is ShopStorage, Initializable, OwnableUpgradeable, UUPSUpgradeable emit Refunded(_shopId, _account, _amount, refundedTotal, currency, amountToken, balanceToken); } - /// @notice nonce를 리턴한다 + /// @notice nonce 를 리턴한다 /// @param _account 지갑주소 function nonceOf(address _account) external view override returns (uint256) { return nonce[_account]; } + + /// @notice 정산관리자를 지정한다 + /// @param _managerShopId 정산관리자의 상점아이디 + /// @param _shopId 클라이언트의 상점아이디 + /// @param _signature 서명 + /// @dev 중계서버를 통해서 호출됩니다. + function setSettlementManager(bytes32 _shopId, bytes32 _managerShopId, bytes calldata _signature) external { + require(_shopId != bytes32(0x0), "1223"); + require(_managerShopId != bytes32(0x0), "1223"); + require(_shopId != _managerShopId, "1224"); + require(shops[_shopId].status != ShopStatus.INVALID, "1201"); + require(shops[_managerShopId].status != ShopStatus.INVALID, "1201"); + address account = shops[_shopId].account; + bytes32 dataHash = keccak256( + abi.encode("SetSettlementManager", _shopId, _managerShopId, block.chainid, nonce[account]) + ); + require(ECDSA.recover(ECDSA.toEthSignedMessageHash(dataHash), _signature) == account, "1501"); + + // 정산관리자에 클라이언트를 추가한다. + ShopSettlementData storage settlementData = settlements[_managerShopId]; + if (settlementData.clientValues[_shopId].states == SettlementClientStates.INVALID) { + settlementData.clientValues[_shopId] = SettlementClientData({ + index: settlementData.clients.length, + states: SettlementClientStates.ACTIVE + }); + settlementData.clients.push(_shopId); + } + // 정산클라이언트의 정보에 정산관리자를 설정한다 + ShopSettlementData storage clientSettlementData = settlements[_shopId]; + clientSettlementData.manager = _managerShopId; + + nonce[account]++; + + emit SetSettlementManager(_shopId, _managerShopId); + } + + /// @notice 정산관리자를 제거한다 + /// @param _shopId 클라이언트의 상점아이디 + /// @dev 중계서버를 통해서 호출됩니다. + function removeSettlementManager(bytes32 _shopId, bytes calldata _signature) external { + require(_shopId != bytes32(0x0), "1223"); + require(shops[_shopId].status != ShopStatus.INVALID, "1201"); + address account = shops[_shopId].account; + bytes32 dataHash = keccak256( + abi.encode("RemoveSettlementManager", _shopId, bytes32(0x0), block.chainid, nonce[account]) + ); + require(ECDSA.recover(ECDSA.toEthSignedMessageHash(dataHash), _signature) == account, "1501"); + + // 정산클라이언트의 정보에 정산관리자를 제거한다 + ShopSettlementData storage clientSettlementData = settlements[_shopId]; + bytes32 managerShopId = clientSettlementData.manager; + clientSettlementData.manager = bytes32(0x0); + + // 정산관리자에서 클라이언트를 제거한다. + if (managerShopId != bytes32(0x0)) { + ShopSettlementData storage settlementData = settlements[managerShopId]; + if (settlementData.clientValues[_shopId].states == SettlementClientStates.ACTIVE) { + uint256 idx = settlementData.clientValues[_shopId].index; + uint256 last = settlementData.clients.length - 1; + settlementData.clients[idx] = settlementData.clients[last]; + settlementData.clientValues[settlementData.clients[idx]].index = idx; + settlementData.clientValues[_shopId].states = SettlementClientStates.INVALID; + settlementData.clients.pop(); + } + } + + nonce[account]++; + + emit RemovedSettlementManager(_shopId, managerShopId); + } + + /// @notice 정산관리자의 상점아이디를 리턴한다 + function settlementManagerOf(bytes32 _shopId) external view override returns (bytes32) { + return settlements[_shopId].manager; + } + + function getSettlementClientLength(bytes32 _managerShopId) external view returns (uint256) { + require(_managerShopId != bytes32(0x0), "1223"); + require(shops[_managerShopId].status != ShopStatus.INVALID, "1201"); + return settlements[_managerShopId].clients.length; + } + + function getSettlementClientList( + bytes32 _managerShopId, + uint256 startIndex, + uint256 endIndex + ) external view returns (bytes32[] memory) { + require(_managerShopId != bytes32(0x0), "1223"); + require(shops[_managerShopId].status != ShopStatus.INVALID, "1201"); + uint256 length = settlements[_managerShopId].clients.length; + uint256 first; + uint256 last; + if (startIndex <= endIndex) { + first = (startIndex <= length - 1) ? startIndex : length; + last = (endIndex <= length) ? endIndex : length; + } else { + first = (endIndex <= length - 1) ? endIndex : length; + last = (startIndex <= length) ? startIndex : length; + } + bytes32[] memory res = new bytes32[](last - first); + for (uint256 idx = first; idx < last; idx++) { + res[idx - first] = settlements[_managerShopId].clients[idx]; + } + return res; + } + + function collectSettlementAmount( + bytes32 _managerShopId, + bytes32 _clientShopId, + bytes calldata _signature + ) external { + require(_managerShopId != bytes32(0x0), "1223"); + require(_clientShopId != bytes32(0x0), "1223"); + require(shops[_managerShopId].status != ShopStatus.INVALID, "1201"); + require(shops[_clientShopId].status != ShopStatus.INVALID, "1201"); + require(settlements[_clientShopId].manager == _managerShopId, "1553"); + require( + settlements[_managerShopId].clientValues[_clientShopId].states == SettlementClientStates.ACTIVE, + "1554" + ); + + address account = shops[_managerShopId].account; + bytes32 dataHash = keccak256( + abi.encode("CollectSettlementAmount", _managerShopId, _clientShopId, block.chainid, nonce[account]) + ); + require(ECDSA.recover(ECDSA.toEthSignedMessageHash(dataHash), _signature) == account, "1501"); + + nonce[account]++; + + _collectSettlementAmount(_managerShopId, _clientShopId); + } + + function collectSettlementAmountMultiClient( + bytes32 _managerShopId, + bytes32[] calldata _clientShopIds, + bytes calldata _signature + ) external { + require(_managerShopId != bytes32(0x0), "1223"); + require(shops[_managerShopId].status != ShopStatus.INVALID, "1201"); + + address account = shops[_managerShopId].account; + bytes32 dataHash = keccak256( + abi.encode( + "CollectSettlementAmountMultiClient", + _managerShopId, + _clientShopIds, + block.chainid, + nonce[account] + ) + ); + require(ECDSA.recover(ECDSA.toEthSignedMessageHash(dataHash), _signature) == account, "1501"); + + nonce[account]++; + + for (uint256 idx = 0; idx < _clientShopIds.length; idx++) { + bytes32 clientShopId = _clientShopIds[idx]; + if (shops[clientShopId].status == ShopStatus.INVALID) continue; + if (settlements[clientShopId].manager != _managerShopId) continue; + if (settlements[_managerShopId].clientValues[clientShopId].states != SettlementClientStates.ACTIVE) + continue; + _collectSettlementAmount(_managerShopId, clientShopId); + } + } + + function _collectSettlementAmount(bytes32 _managerShopId, bytes32 _clientShopId) internal { + ShopData storage managerShop = shops[_managerShopId]; + ShopData storage clientShop = shops[_clientShopId]; + + uint256 settlementAmount = (clientShop.collectedAmount + clientShop.usedAmount > clientShop.providedAmount) + ? clientShop.collectedAmount + clientShop.usedAmount - clientShop.providedAmount + : 0; + uint256 refundableAmount = (settlementAmount > clientShop.refundedAmount) + ? settlementAmount - clientShop.refundedAmount + : 0; + + if (refundableAmount > 0) { + clientShop.refundedAmount += refundableAmount; + uint256 managerAmount = currencyRate.convertCurrency( + refundableAmount, + clientShop.currency, + managerShop.currency + ); + managerShop.collectedAmount += managerAmount; + + emit CollectedSettlementAmount( + clientShop.shopId, + clientShop.account, + clientShop.currency, + refundableAmount, + clientShop.refundedAmount, + managerShop.shopId, + managerShop.account, + managerShop.currency, + managerAmount, + managerShop.collectedAmount + ); + } + } } diff --git a/packages/contracts/contracts/shop/ShopStorage.sol b/packages/contracts/contracts/shop/ShopStorage.sol index af847dbc..a9b1a078 100644 --- a/packages/contracts/contracts/shop/ShopStorage.sol +++ b/packages/contracts/contracts/shop/ShopStorage.sol @@ -10,6 +10,7 @@ import "../interfaces/ILedger.sol"; contract ShopStorage { mapping(bytes32 => IShop.ShopData) internal shops; mapping(address => bytes32[]) internal shopIdByAddress; + mapping(bytes32 => IShop.ShopSettlementData) internal settlements; bytes32[] internal items; diff --git a/packages/contracts/package.json b/packages/contracts/package.json index ca02989e..78fdff7e 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "acc-contracts-v2", - "version": "2.8.0", + "version": "2.9.0", "description": "Smart contracts that decentralized loyalty systems", "files": [ "**/*.sol" @@ -71,7 +71,7 @@ "@openzeppelin/contracts-upgradeable": "^4.9.5", "@openzeppelin/hardhat-upgrades": "^1.28.0", "acc-bridge-contracts-v2": "~2.5.0", - "loyalty-tokens": "~2.3.0", + "loyalty-tokens": "~2.1.1", "multisig-wallet-contracts": "~2.0.0" } } diff --git a/packages/contracts/src/utils/ContractUtils.ts b/packages/contracts/src/utils/ContractUtils.ts index ca7e2f05..f977cedd 100644 --- a/packages/contracts/src/utils/ContractUtils.ts +++ b/packages/contracts/src/utils/ContractUtils.ts @@ -7,7 +7,7 @@ import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; // tslint:disable-next-line:no-implicit-dependencies import { arrayify, BytesLike } from "@ethersproject/bytes"; // tslint:disable-next-line:no-implicit-dependencies -import { AddressZero } from "@ethersproject/constants"; +import { AddressZero, HashZero } from "@ethersproject/constants"; // tslint:disable-next-line:no-implicit-dependencies import { ContractReceipt, ContractTransaction } from "@ethersproject/contracts"; // tslint:disable-next-line:no-implicit-dependencies @@ -707,6 +707,75 @@ export class ContractUtils { return signer.signMessage(arrayify(keccak256(encodedData))); } + public static getSetSettlementManagerMessage( + shopId: BytesLike, + managerId: BytesLike, + nonce: BigNumberish, + chainId?: BigNumberish + ): Uint8Array { + const encodedResult = defaultAbiCoder.encode( + ["string", "bytes32", "bytes32", "uint256", "uint256"], + ["SetSettlementManager", shopId, managerId, chainId ? chainId : hre.ethers.provider.network.chainId, nonce] + ); + return arrayify(keccak256(encodedResult)); + } + + public static getRemoveSettlementManagerMessage( + shopId: BytesLike, + nonce: BigNumberish, + chainId?: BigNumberish + ): Uint8Array { + const encodedResult = defaultAbiCoder.encode( + ["string", "bytes32", "bytes32", "uint256", "uint256"], + [ + "RemoveSettlementManager", + shopId, + HashZero, + chainId ? chainId : hre.ethers.provider.network.chainId, + nonce, + ] + ); + return arrayify(keccak256(encodedResult)); + } + + public static getCollectSettlementAmountMessage( + managerShopId: BytesLike, + clientShopId: BytesLike, + nonce: BigNumberish, + chainId?: BigNumberish + ): Uint8Array { + const encodedResult = defaultAbiCoder.encode( + ["string", "bytes32", "bytes32", "uint256", "uint256"], + [ + "CollectSettlementAmount", + managerShopId, + clientShopId, + chainId ? chainId : hre.ethers.provider.network.chainId, + nonce, + ] + ); + return arrayify(keccak256(encodedResult)); + } + + public static getCollectSettlementAmountMultiClientMessage( + managerShopId: BytesLike, + clientShopIds: BytesLike[], + nonce: BigNumberish, + chainId?: BigNumberish + ): Uint8Array { + const encodedResult = defaultAbiCoder.encode( + ["string", "bytes32", "bytes32[]", "uint256", "uint256"], + [ + "CollectSettlementAmountMultiClient", + managerShopId, + clientShopIds, + chainId ? chainId : hre.ethers.provider.network.chainId, + nonce, + ] + ); + return arrayify(keccak256(encodedResult)); + } + public static async signMessage(signer: Signer, message: Uint8Array): Promise { return signer.signMessage(message); } diff --git a/packages/contracts/test/04-Ledger.test.ts b/packages/contracts/test/04-Ledger.test.ts index f07a3854..a89f82fd 100644 --- a/packages/contracts/test/04-Ledger.test.ts +++ b/packages/contracts/test/04-Ledger.test.ts @@ -22,7 +22,8 @@ import { solidity } from "ethereum-waffle"; import { BigNumber, Wallet } from "ethers"; -import { AddressZero } from "@ethersproject/constants"; +import { BytesLike } from "@ethersproject/bytes"; +import { AddressZero, HashZero } from "@ethersproject/constants"; import { Deployments } from "./helper/Deployments"; import * as hre from "hardhat"; @@ -2794,4 +2795,1396 @@ describe("Test for Ledger", () => { .withArgs(hash, deployments.accounts.users[userIndex].address); }); }); + + context("Clearing for shops - Not use settlement manager", () => { + const userData: IUserData[] = [ + { + phone: "08201012341001", + address: deployments.accounts.users[0].address, + privateKey: deployments.accounts.users[0].privateKey, + }, + { + phone: "08201012341002", + address: deployments.accounts.users[1].address, + privateKey: deployments.accounts.users[1].privateKey, + }, + { + phone: "08201012341003", + address: deployments.accounts.users[2].address, + privateKey: deployments.accounts.users[2].privateKey, + }, + { + phone: "08201012341004", + address: deployments.accounts.users[3].address, + privateKey: deployments.accounts.users[3].privateKey, + }, + { + phone: "08201012341005", + address: deployments.accounts.users[4].address, + privateKey: deployments.accounts.users[4].privateKey, + }, + ]; + + const purchaseData: IPurchaseData[] = [ + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 0, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 1, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 1, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000000, + providePercent: 1, + currency: "krw", + shopIndex: 4, + userIndex: 0, + }, + ]; + + const shopData: IShopData[] = [ + { + shopId: "F000100", + name: "Shop1", + currency: "krw", + wallet: deployments.accounts.shops[0], + }, + { + shopId: "F000200", + name: "Shop2", + currency: "krw", + wallet: deployments.accounts.shops[1], + }, + { + shopId: "F000300", + name: "Shop3", + currency: "krw", + wallet: deployments.accounts.shops[2], + }, + { + shopId: "F000400", + name: "Shop4", + currency: "krw", + wallet: deployments.accounts.shops[3], + }, + { + shopId: "F000500", + name: "Shop5", + currency: "krw", + wallet: deployments.accounts.shops[4], + }, + ]; + + before("Set Shop ID", async () => { + for (const elem of shopData) { + elem.shopId = ContractUtils.getShopId(elem.wallet.address, LoyaltyNetworkID.ACC_TESTNET); + } + }); + + before("Deploy", async () => { + await deployAllContract(shopData); + }); + + context("Save Purchase Data", () => { + it("Save Purchase Data", async () => { + for (const purchase of purchaseData) { + const phoneHash = ContractUtils.getPhoneHash(userData[purchase.userIndex].phone); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const loyaltyAmount = purchaseAmount.mul(purchase.providePercent).div(100); + const amt = purchaseAmount.mul(purchase.providePercent).div(100); + const userAccount = + userData[purchase.userIndex].address.trim() !== "" + ? userData[purchase.userIndex].address.trim() + : AddressZero; + const purchaseParam = { + purchaseId: getPurchaseId(), + amount: purchaseAmount, + loyalty: loyaltyAmount, + currency: purchase.currency.toLowerCase(), + shopId: shopData[purchase.shopIndex].shopId, + account: userAccount, + phone: phoneHash, + sender: deployments.accounts.system.address, + signature: "", + }; + purchaseParam.signature = await ContractUtils.getPurchaseSignature( + deployments.accounts.system, + purchaseParam + ); + const purchaseMessage = ContractUtils.getPurchasesMessage(0, [purchaseParam]); + const signatures = await Promise.all( + deployments.accounts.validators.map((m) => ContractUtils.signMessage(m, purchaseMessage)) + ); + const proposeMessage = ContractUtils.getPurchasesProposeMessage(0, [purchaseParam], signatures); + const proposerSignature = await ContractUtils.signMessage( + deployments.accounts.validators[0], + proposeMessage + ); + await expect( + providerContract + .connect(deployments.accounts.certifiers[0]) + .savePurchase(0, [purchaseParam], signatures, proposerSignature) + ) + .to.emit(providerContract, "SavedPurchase") + .withArgs( + purchaseParam.purchaseId, + purchaseParam.amount, + purchaseParam.loyalty, + purchaseParam.currency, + purchaseParam.shopId, + purchaseParam.account, + purchaseParam.phone, + purchaseParam.sender + ) + .emit(ledgerContract, "ProvidedPoint") + .withNamedArgs({ + account: userAccount, + providedPoint: amt, + providedValue: amt, + purchaseId: purchaseParam.purchaseId, + }); + } + }); + + it("Check balances", async () => { + const expected: Map = new Map(); + for (const purchase of purchaseData) { + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const key = + userData[purchase.userIndex].address.trim() !== "" + ? userData[purchase.userIndex].address.trim() + : ContractUtils.getPhoneHash(userData[purchase.userIndex].phone.trim()); + const oldValue = expected.get(key); + + const point = purchaseAmount.mul(purchase.providePercent).div(100); + + if (oldValue !== undefined) expected.set(key, oldValue.add(point)); + else expected.set(key, point); + } + for (const key of expected.keys()) { + if (key.match(/^0x[A-Fa-f0-9]{64}$/i)) { + expect(await ledgerContract.unPayablePointBalanceOf(key)).to.deep.equal(expected.get(key)); + } else { + expect(await ledgerContract.pointBalanceOf(key)).to.deep.equal(expected.get(key)); + } + } + }); + + it("Check shop data", async () => { + const shopInfo1 = await shopContract.shopOf(shopData[0].shopId); + expect(shopInfo1.providedAmount).to.equal( + Amount.make(10000 * 1, 18) + .value.mul(1) + .div(100) + ); + + const shopInfo2 = await shopContract.shopOf(shopData[1].shopId); + expect(shopInfo2.providedAmount).to.equal( + Amount.make(10000 * 2, 18) + .value.mul(1) + .div(100) + ); + const shopInfo3 = await shopContract.shopOf(shopData[2].shopId); + expect(shopInfo3.providedAmount).to.equal( + Amount.make(10000 * 3, 18) + .value.mul(1) + .div(100) + ); + const shopInfo4 = await shopContract.shopOf(shopData[3].shopId); + expect(shopInfo4.providedAmount).to.equal(Amount.make(0, 18).value); + }); + }); + + context("Pay point", () => { + it("Pay point - Success", async () => { + const providedAmount = [100, 200, 300, 0].map((m) => Amount.make(m, 18).value); + const usedAmount = [500, 500, 500, 500].map((m) => Amount.make(m, 18).value); + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const purchase = { + purchaseId: getPurchaseId(), + amount: 500, + providePercent: 1, + currency: "krw", + shopIndex, + userIndex: 0, + }; + + const paymentId = ContractUtils.getPaymentId( + deployments.accounts.users[purchase.userIndex].address, + 0 + ); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const shop = shopData[purchase.shopIndex]; + const nonce = await ledgerContract.nonceOf(deployments.accounts.users[purchase.userIndex].address); + const signature = await ContractUtils.signLoyaltyNewPayment( + deployments.accounts.users[purchase.userIndex], + paymentId, + purchase.purchaseId, + purchaseAmount, + purchase.currency, + shop.shopId, + nonce + ); + + [secret, secretLock] = ContractUtils.getSecret(); + await expect( + consumerContract.connect(deployments.accounts.certifiers[0]).openNewLoyaltyPayment({ + paymentId, + purchaseId: purchase.purchaseId, + amount: purchaseAmount, + currency: purchase.currency.toLowerCase(), + shopId: shop.shopId, + account: deployments.accounts.users[purchase.userIndex].address, + signature, + secretLock, + }) + ).to.emit(consumerContract, "LoyaltyPaymentEvent"); + + await expect( + consumerContract + .connect(deployments.accounts.certifiers[0]) + .closeNewLoyaltyPayment(paymentId, secret, true) + ).to.emit(consumerContract, "LoyaltyPaymentEvent"); + + const shopInfo = await shopContract.shopOf(shop.shopId); + expect(shopInfo.providedAmount).to.equal(providedAmount[shopIndex]); + expect(shopInfo.usedAmount).to.equal(usedAmount[shopIndex]); + } + }); + }); + + context("refund", () => { + const expected = [400, 300, 200, 500].map((m) => Amount.make(m, 18).value); + const amountToken: BigNumber[] = []; + + it("Check refundable amount", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const { refundableAmount, refundableToken } = await shopContract.refundableOf(shop.shopId); + expect(refundableAmount).to.equal(expected[shopIndex]); + amountToken.push(refundableToken); + } + }); + + it("refund", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const nonce = await shopContract.nonceOf(shopData[shopIndex].wallet.address); + const message = ContractUtils.getShopRefundMessage( + shopData[shopIndex].shopId, + shopData[shopIndex].wallet.address, + expected[shopIndex], + nonce + ); + const signature = await ContractUtils.signMessage(shopData[shopIndex].wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .refund(shop.shopId, shopData[shopIndex].wallet.address, expected[shopIndex], signature) + ) + .to.emit(shopContract, "Refunded") + .withNamedArgs({ + shopId: shop.shopId, + account: deployments.accounts.shops[shopIndex].address, + refundAmount: expected[shopIndex], + amountToken: amountToken[shopIndex], + }); + } + }); + + it("Check refundable amount", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const { refundableAmount } = await shopContract.refundableOf(shop.shopId); + expect(refundableAmount).to.equal(0); + } + }); + + it("Check balance of ledger", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const balance = await ledgerContract.tokenBalanceOf(shop.wallet.address); + expect(balance).to.equal(amountToken[shopIndex++]); + } + }); + }); + }); + + context("Clearing for shops - Use settlement manager", () => { + const userData: IUserData[] = [ + { + phone: "08201012341001", + address: deployments.accounts.users[0].address, + privateKey: deployments.accounts.users[0].privateKey, + }, + { + phone: "08201012341002", + address: deployments.accounts.users[1].address, + privateKey: deployments.accounts.users[1].privateKey, + }, + { + phone: "08201012341003", + address: deployments.accounts.users[2].address, + privateKey: deployments.accounts.users[2].privateKey, + }, + { + phone: "08201012341004", + address: deployments.accounts.users[3].address, + privateKey: deployments.accounts.users[3].privateKey, + }, + { + phone: "08201012341005", + address: deployments.accounts.users[4].address, + privateKey: deployments.accounts.users[4].privateKey, + }, + ]; + + const purchaseData: IPurchaseData[] = [ + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 0, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 1, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 1, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000000, + providePercent: 1, + currency: "krw", + shopIndex: 5, + userIndex: 0, + }, + ]; + + const shopData: IShopData[] = [ + { + shopId: "F000100", + name: "Shop1", + currency: "krw", + wallet: deployments.accounts.shops[0], + }, + { + shopId: "F000200", + name: "Shop2", + currency: "krw", + wallet: deployments.accounts.shops[1], + }, + { + shopId: "F000300", + name: "Shop3", + currency: "krw", + wallet: deployments.accounts.shops[2], + }, + { + shopId: "F000400", + name: "Shop4", + currency: "krw", + wallet: deployments.accounts.shops[3], + }, + { + shopId: "F000500", + name: "Shop5", + currency: "krw", + wallet: deployments.accounts.shops[4], + }, + { + shopId: "F000500", + name: "Shop6", + currency: "krw", + wallet: deployments.accounts.shops[5], + }, + ]; + + before("Set Shop ID", async () => { + for (const elem of shopData) { + elem.shopId = ContractUtils.getShopId(elem.wallet.address, LoyaltyNetworkID.ACC_TESTNET); + } + }); + + before("Deploy", async () => { + await deployAllContract(shopData); + }); + + context("Save Purchase Data", () => { + it("Save Purchase Data", async () => { + for (const purchase of purchaseData) { + const phoneHash = ContractUtils.getPhoneHash(userData[purchase.userIndex].phone); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const loyaltyAmount = purchaseAmount.mul(purchase.providePercent).div(100); + const amt = purchaseAmount.mul(purchase.providePercent).div(100); + const userAccount = + userData[purchase.userIndex].address.trim() !== "" + ? userData[purchase.userIndex].address.trim() + : AddressZero; + const purchaseParam = { + purchaseId: getPurchaseId(), + amount: purchaseAmount, + loyalty: loyaltyAmount, + currency: purchase.currency.toLowerCase(), + shopId: shopData[purchase.shopIndex].shopId, + account: userAccount, + phone: phoneHash, + sender: deployments.accounts.system.address, + signature: "", + }; + purchaseParam.signature = await ContractUtils.getPurchaseSignature( + deployments.accounts.system, + purchaseParam + ); + const purchaseMessage = ContractUtils.getPurchasesMessage(0, [purchaseParam]); + const signatures = await Promise.all( + deployments.accounts.validators.map((m) => ContractUtils.signMessage(m, purchaseMessage)) + ); + const proposeMessage = ContractUtils.getPurchasesProposeMessage(0, [purchaseParam], signatures); + const proposerSignature = await ContractUtils.signMessage( + deployments.accounts.validators[0], + proposeMessage + ); + await expect( + providerContract + .connect(deployments.accounts.certifiers[0]) + .savePurchase(0, [purchaseParam], signatures, proposerSignature) + ) + .to.emit(providerContract, "SavedPurchase") + .withArgs( + purchaseParam.purchaseId, + purchaseParam.amount, + purchaseParam.loyalty, + purchaseParam.currency, + purchaseParam.shopId, + purchaseParam.account, + purchaseParam.phone, + purchaseParam.sender + ) + .emit(ledgerContract, "ProvidedPoint") + .withNamedArgs({ + account: userAccount, + providedPoint: amt, + providedValue: amt, + purchaseId: purchaseParam.purchaseId, + }); + } + }); + + it("Check balances", async () => { + const expected: Map = new Map(); + for (const purchase of purchaseData) { + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const key = + userData[purchase.userIndex].address.trim() !== "" + ? userData[purchase.userIndex].address.trim() + : ContractUtils.getPhoneHash(userData[purchase.userIndex].phone.trim()); + const oldValue = expected.get(key); + + const point = purchaseAmount.mul(purchase.providePercent).div(100); + + if (oldValue !== undefined) expected.set(key, oldValue.add(point)); + else expected.set(key, point); + } + for (const key of expected.keys()) { + if (key.match(/^0x[A-Fa-f0-9]{64}$/i)) { + expect(await ledgerContract.unPayablePointBalanceOf(key)).to.deep.equal(expected.get(key)); + } else { + expect(await ledgerContract.pointBalanceOf(key)).to.deep.equal(expected.get(key)); + } + } + }); + + it("Check shop data", async () => { + const shopInfo1 = await shopContract.shopOf(shopData[0].shopId); + expect(shopInfo1.providedAmount).to.equal( + Amount.make(10000 * 1, 18) + .value.mul(1) + .div(100) + ); + + const shopInfo2 = await shopContract.shopOf(shopData[1].shopId); + expect(shopInfo2.providedAmount).to.equal( + Amount.make(10000 * 2, 18) + .value.mul(1) + .div(100) + ); + const shopInfo3 = await shopContract.shopOf(shopData[2].shopId); + expect(shopInfo3.providedAmount).to.equal( + Amount.make(10000 * 3, 18) + .value.mul(1) + .div(100) + ); + const shopInfo4 = await shopContract.shopOf(shopData[3].shopId); + expect(shopInfo4.providedAmount).to.equal(Amount.make(0, 18).value); + }); + }); + + context("Pay point", () => { + it("Pay point - Success", async () => { + const providedAmount = [100, 200, 300, 0].map((m) => Amount.make(m, 18).value); + const usedAmount = [500, 500, 500, 500].map((m) => Amount.make(m, 18).value); + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const purchase = { + purchaseId: getPurchaseId(), + amount: 500, + providePercent: 1, + currency: "krw", + shopIndex, + userIndex: 0, + }; + + const paymentId = ContractUtils.getPaymentId( + deployments.accounts.users[purchase.userIndex].address, + 0 + ); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const shop = shopData[purchase.shopIndex]; + const nonce = await ledgerContract.nonceOf(deployments.accounts.users[purchase.userIndex].address); + const signature = await ContractUtils.signLoyaltyNewPayment( + deployments.accounts.users[purchase.userIndex], + paymentId, + purchase.purchaseId, + purchaseAmount, + purchase.currency, + shop.shopId, + nonce + ); + + [secret, secretLock] = ContractUtils.getSecret(); + await expect( + consumerContract.connect(deployments.accounts.certifiers[0]).openNewLoyaltyPayment({ + paymentId, + purchaseId: purchase.purchaseId, + amount: purchaseAmount, + currency: purchase.currency.toLowerCase(), + shopId: shop.shopId, + account: deployments.accounts.users[purchase.userIndex].address, + signature, + secretLock, + }) + ).to.emit(consumerContract, "LoyaltyPaymentEvent"); + + await expect( + consumerContract + .connect(deployments.accounts.certifiers[0]) + .closeNewLoyaltyPayment(paymentId, secret, true) + ).to.emit(consumerContract, "LoyaltyPaymentEvent"); + + const shopInfo = await shopContract.shopOf(shop.shopId); + expect(shopInfo.providedAmount).to.equal(providedAmount[shopIndex]); + expect(shopInfo.usedAmount).to.equal(usedAmount[shopIndex]); + } + }); + }); + + context("setSettlementManager/removeSettlementManager", () => { + const managerShop = shopData[4]; + const clients: BytesLike[] = []; + + it("prepare", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + clients.push(shopData[shopIndex].shopId); + } + }); + + it("setSettlementManager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const nonce = await shopContract.nonceOf(shop.wallet.address); + const message = ContractUtils.getSetSettlementManagerMessage( + shop.shopId, + managerShop.shopId, + nonce + ); + const signature = ContractUtils.signMessage(shop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .setSettlementManager(shop.shopId, managerShop.shopId, signature) + ) + .to.emit(shopContract, "SetSettlementManager") + .withNamedArgs({ + shopId: shop.shopId, + managerShopId: managerShop.shopId, + }); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + expect(await shopContract.settlementManagerOf(shop.shopId)).to.be.equal(managerShop.shopId); + } + }); + + it("check client", async () => { + expect(await shopContract.getSettlementClientLength(managerShop.shopId)).to.be.equal(clients.length); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 0, 2)).to.deep.equal( + clients.slice(0, 2) + ); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 1, 3)).to.deep.equal( + clients.slice(1, 3) + ); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 0, 4)).to.deep.equal( + clients.slice(0, 4) + ); + }); + + it("removeSettlementManager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const nonce = await shopContract.nonceOf(shop.wallet.address); + const message = ContractUtils.getRemoveSettlementManagerMessage(shop.shopId, nonce); + const signature = ContractUtils.signMessage(shop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .removeSettlementManager(shop.shopId, signature) + ) + .to.emit(shopContract, "RemovedSettlementManager") + .withNamedArgs({ + shopId: shop.shopId, + managerShopId: managerShop.shopId, + }); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + expect(await shopContract.settlementManagerOf(shop.shopId)).to.be.equal(HashZero); + } + }); + + it("check client", async () => { + expect(await shopContract.getSettlementClientLength(managerShop.shopId)).to.be.equal(0); + }); + + it("setSettlementManager again", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const nonce = await shopContract.nonceOf(shop.wallet.address); + const message = ContractUtils.getSetSettlementManagerMessage( + shop.shopId, + managerShop.shopId, + nonce + ); + const signature = ContractUtils.signMessage(shop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .setSettlementManager(shop.shopId, managerShop.shopId, signature) + ) + .to.emit(shopContract, "SetSettlementManager") + .withNamedArgs({ + shopId: shop.shopId, + managerShopId: managerShop.shopId, + }); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + expect(await shopContract.settlementManagerOf(shop.shopId)).to.be.equal(managerShop.shopId); + } + }); + + it("check client", async () => { + expect(await shopContract.getSettlementClientLength(managerShop.shopId)).to.be.equal(clients.length); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 0, 2)).to.deep.equal( + clients.slice(0, 2) + ); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 1, 3)).to.deep.equal( + clients.slice(1, 3) + ); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 0, 4)).to.deep.equal( + clients.slice(0, 4) + ); + }); + }); + + context("refund", () => { + const managerShop = shopData[4]; + const expected = [400, 300, 200, 500].map((m) => Amount.make(m, 18).value); + const sumExpected = Amount.make(1400, 18).value; + let amountToken: BigNumber; + + it("Check refundable amount", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const { refundableAmount, refundableToken } = await shopContract.refundableOf(shop.shopId); + expect(refundableAmount).to.equal(expected[shopIndex]); + } + }); + + it("collectSettlementAmount", async () => { + const clientLength = await shopContract.getSettlementClientLength(managerShop.shopId); + const clients = await shopContract.getSettlementClientList(managerShop.shopId, 0, clientLength); + for (const client of clients) { + const shopIndex = shopData.findIndex((m) => m.shopId === client); + expect(shopIndex).to.gte(0); + const shop = shopData[shopIndex]; + const nonce = await shopContract.nonceOf(managerShop.wallet.address); + const message = ContractUtils.getCollectSettlementAmountMessage( + managerShop.shopId, + shop.shopId, + nonce + ); + const signature = await ContractUtils.signMessage(managerShop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .collectSettlementAmount(managerShop.shopId, shop.shopId, signature) + ) + .to.emit(shopContract, "CollectedSettlementAmount") + .withNamedArgs({ + clientId: shop.shopId, + clientAccount: shop.wallet.address, + clientCurrency: shop.currency, + clientAmount: expected[shopIndex], + managerId: managerShop.shopId, + managerAccount: managerShop.wallet.address, + managerCurrency: managerShop.currency, + managerAmount: expected[shopIndex], + }); + } + }); + + it("Check refundable amount", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const { refundableAmount } = await shopContract.refundableOf(shop.shopId); + expect(refundableAmount).to.equal(0); + } + }); + + it("Check refundable amount of settlement manager", async () => { + const { refundableAmount, refundableToken } = await shopContract.refundableOf(managerShop.shopId); + expect(refundableAmount).to.equal(sumExpected); + amountToken = BigNumber.from(refundableToken); + }); + + it("refund", async () => { + const nonce = await shopContract.nonceOf(managerShop.wallet.address); + const message = ContractUtils.getShopRefundMessage( + managerShop.shopId, + managerShop.wallet.address, + sumExpected, + nonce + ); + const signature = await ContractUtils.signMessage(managerShop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .refund(managerShop.shopId, managerShop.wallet.address, sumExpected, signature) + ) + .to.emit(shopContract, "Refunded") + .withNamedArgs({ + shopId: managerShop.shopId, + account: managerShop.wallet.address, + refundAmount: sumExpected, + amountToken, + }); + }); + + it("Check balance of ledger", async () => { + const balance = await ledgerContract.tokenBalanceOf(managerShop.wallet.address); + expect(balance).to.equal(amountToken); + }); + }); + }); + + context("Clearing for shops - Use settlement manager", () => { + const userData: IUserData[] = [ + { + phone: "08201012341001", + address: deployments.accounts.users[0].address, + privateKey: deployments.accounts.users[0].privateKey, + }, + { + phone: "08201012341002", + address: deployments.accounts.users[1].address, + privateKey: deployments.accounts.users[1].privateKey, + }, + { + phone: "08201012341003", + address: deployments.accounts.users[2].address, + privateKey: deployments.accounts.users[2].privateKey, + }, + { + phone: "08201012341004", + address: deployments.accounts.users[3].address, + privateKey: deployments.accounts.users[3].privateKey, + }, + { + phone: "08201012341005", + address: deployments.accounts.users[4].address, + privateKey: deployments.accounts.users[4].privateKey, + }, + ]; + + const purchaseData: IPurchaseData[] = [ + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 0, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 1, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 1, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000, + providePercent: 1, + currency: "krw", + shopIndex: 2, + userIndex: 0, + }, + { + purchaseId: getPurchaseId(), + amount: 10000000, + providePercent: 1, + currency: "krw", + shopIndex: 5, + userIndex: 0, + }, + ]; + + const shopData: IShopData[] = [ + { + shopId: "F000100", + name: "Shop1", + currency: "krw", + wallet: deployments.accounts.shops[0], + }, + { + shopId: "F000200", + name: "Shop2", + currency: "krw", + wallet: deployments.accounts.shops[1], + }, + { + shopId: "F000300", + name: "Shop3", + currency: "krw", + wallet: deployments.accounts.shops[2], + }, + { + shopId: "F000400", + name: "Shop4", + currency: "krw", + wallet: deployments.accounts.shops[3], + }, + { + shopId: "F000500", + name: "Shop5", + currency: "krw", + wallet: deployments.accounts.shops[4], + }, + { + shopId: "F000500", + name: "Shop6", + currency: "krw", + wallet: deployments.accounts.shops[5], + }, + ]; + + before("Set Shop ID", async () => { + for (const elem of shopData) { + elem.shopId = ContractUtils.getShopId(elem.wallet.address, LoyaltyNetworkID.ACC_TESTNET); + } + }); + + before("Deploy", async () => { + await deployAllContract(shopData); + }); + + context("Save Purchase Data", () => { + it("Save Purchase Data", async () => { + for (const purchase of purchaseData) { + const phoneHash = ContractUtils.getPhoneHash(userData[purchase.userIndex].phone); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const loyaltyAmount = purchaseAmount.mul(purchase.providePercent).div(100); + const amt = purchaseAmount.mul(purchase.providePercent).div(100); + const userAccount = + userData[purchase.userIndex].address.trim() !== "" + ? userData[purchase.userIndex].address.trim() + : AddressZero; + const purchaseParam = { + purchaseId: getPurchaseId(), + amount: purchaseAmount, + loyalty: loyaltyAmount, + currency: purchase.currency.toLowerCase(), + shopId: shopData[purchase.shopIndex].shopId, + account: userAccount, + phone: phoneHash, + sender: deployments.accounts.system.address, + signature: "", + }; + purchaseParam.signature = await ContractUtils.getPurchaseSignature( + deployments.accounts.system, + purchaseParam + ); + const purchaseMessage = ContractUtils.getPurchasesMessage(0, [purchaseParam]); + const signatures = await Promise.all( + deployments.accounts.validators.map((m) => ContractUtils.signMessage(m, purchaseMessage)) + ); + const proposeMessage = ContractUtils.getPurchasesProposeMessage(0, [purchaseParam], signatures); + const proposerSignature = await ContractUtils.signMessage( + deployments.accounts.validators[0], + proposeMessage + ); + await expect( + providerContract + .connect(deployments.accounts.certifiers[0]) + .savePurchase(0, [purchaseParam], signatures, proposerSignature) + ) + .to.emit(providerContract, "SavedPurchase") + .withArgs( + purchaseParam.purchaseId, + purchaseParam.amount, + purchaseParam.loyalty, + purchaseParam.currency, + purchaseParam.shopId, + purchaseParam.account, + purchaseParam.phone, + purchaseParam.sender + ) + .emit(ledgerContract, "ProvidedPoint") + .withNamedArgs({ + account: userAccount, + providedPoint: amt, + providedValue: amt, + purchaseId: purchaseParam.purchaseId, + }); + } + }); + + it("Check balances", async () => { + const expected: Map = new Map(); + for (const purchase of purchaseData) { + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const key = + userData[purchase.userIndex].address.trim() !== "" + ? userData[purchase.userIndex].address.trim() + : ContractUtils.getPhoneHash(userData[purchase.userIndex].phone.trim()); + const oldValue = expected.get(key); + + const point = purchaseAmount.mul(purchase.providePercent).div(100); + + if (oldValue !== undefined) expected.set(key, oldValue.add(point)); + else expected.set(key, point); + } + for (const key of expected.keys()) { + if (key.match(/^0x[A-Fa-f0-9]{64}$/i)) { + expect(await ledgerContract.unPayablePointBalanceOf(key)).to.deep.equal(expected.get(key)); + } else { + expect(await ledgerContract.pointBalanceOf(key)).to.deep.equal(expected.get(key)); + } + } + }); + + it("Check shop data", async () => { + const shopInfo1 = await shopContract.shopOf(shopData[0].shopId); + expect(shopInfo1.providedAmount).to.equal( + Amount.make(10000 * 1, 18) + .value.mul(1) + .div(100) + ); + + const shopInfo2 = await shopContract.shopOf(shopData[1].shopId); + expect(shopInfo2.providedAmount).to.equal( + Amount.make(10000 * 2, 18) + .value.mul(1) + .div(100) + ); + const shopInfo3 = await shopContract.shopOf(shopData[2].shopId); + expect(shopInfo3.providedAmount).to.equal( + Amount.make(10000 * 3, 18) + .value.mul(1) + .div(100) + ); + const shopInfo4 = await shopContract.shopOf(shopData[3].shopId); + expect(shopInfo4.providedAmount).to.equal(Amount.make(0, 18).value); + }); + }); + + context("Pay point", () => { + it("Pay point - Success", async () => { + const providedAmount = [100, 200, 300, 0].map((m) => Amount.make(m, 18).value); + const usedAmount = [500, 500, 500, 500].map((m) => Amount.make(m, 18).value); + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const purchase = { + purchaseId: getPurchaseId(), + amount: 500, + providePercent: 1, + currency: "krw", + shopIndex, + userIndex: 0, + }; + + const paymentId = ContractUtils.getPaymentId( + deployments.accounts.users[purchase.userIndex].address, + 0 + ); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const shop = shopData[purchase.shopIndex]; + const nonce = await ledgerContract.nonceOf(deployments.accounts.users[purchase.userIndex].address); + const signature = await ContractUtils.signLoyaltyNewPayment( + deployments.accounts.users[purchase.userIndex], + paymentId, + purchase.purchaseId, + purchaseAmount, + purchase.currency, + shop.shopId, + nonce + ); + + [secret, secretLock] = ContractUtils.getSecret(); + await expect( + consumerContract.connect(deployments.accounts.certifiers[0]).openNewLoyaltyPayment({ + paymentId, + purchaseId: purchase.purchaseId, + amount: purchaseAmount, + currency: purchase.currency.toLowerCase(), + shopId: shop.shopId, + account: deployments.accounts.users[purchase.userIndex].address, + signature, + secretLock, + }) + ).to.emit(consumerContract, "LoyaltyPaymentEvent"); + + await expect( + consumerContract + .connect(deployments.accounts.certifiers[0]) + .closeNewLoyaltyPayment(paymentId, secret, true) + ).to.emit(consumerContract, "LoyaltyPaymentEvent"); + + const shopInfo = await shopContract.shopOf(shop.shopId); + expect(shopInfo.providedAmount).to.equal(providedAmount[shopIndex]); + expect(shopInfo.usedAmount).to.equal(usedAmount[shopIndex]); + } + }); + }); + + context("setSettlementManager/removeSettlementManager", () => { + const managerShop = shopData[4]; + const clients: BytesLike[] = []; + + it("prepare", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + clients.push(shopData[shopIndex].shopId); + } + }); + + it("setSettlementManager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const nonce = await shopContract.nonceOf(shop.wallet.address); + const message = ContractUtils.getSetSettlementManagerMessage( + shop.shopId, + managerShop.shopId, + nonce + ); + const signature = ContractUtils.signMessage(shop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .setSettlementManager(shop.shopId, managerShop.shopId, signature) + ) + .to.emit(shopContract, "SetSettlementManager") + .withNamedArgs({ + shopId: shop.shopId, + managerShopId: managerShop.shopId, + }); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + expect(await shopContract.settlementManagerOf(shop.shopId)).to.be.equal(managerShop.shopId); + } + }); + + it("check client", async () => { + expect(await shopContract.getSettlementClientLength(managerShop.shopId)).to.be.equal(clients.length); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 0, 2)).to.deep.equal( + clients.slice(0, 2) + ); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 1, 3)).to.deep.equal( + clients.slice(1, 3) + ); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 0, 4)).to.deep.equal( + clients.slice(0, 4) + ); + }); + + it("removeSettlementManager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const nonce = await shopContract.nonceOf(shop.wallet.address); + const message = ContractUtils.getRemoveSettlementManagerMessage(shop.shopId, nonce); + const signature = ContractUtils.signMessage(shop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .removeSettlementManager(shop.shopId, signature) + ) + .to.emit(shopContract, "RemovedSettlementManager") + .withNamedArgs({ + shopId: shop.shopId, + managerShopId: managerShop.shopId, + }); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + expect(await shopContract.settlementManagerOf(shop.shopId)).to.be.equal(HashZero); + } + }); + + it("check client", async () => { + expect(await shopContract.getSettlementClientLength(managerShop.shopId)).to.be.equal(0); + }); + + it("setSettlementManager again", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const nonce = await shopContract.nonceOf(shop.wallet.address); + const message = ContractUtils.getSetSettlementManagerMessage( + shop.shopId, + managerShop.shopId, + nonce + ); + const signature = ContractUtils.signMessage(shop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .setSettlementManager(shop.shopId, managerShop.shopId, signature) + ) + .to.emit(shopContract, "SetSettlementManager") + .withNamedArgs({ + shopId: shop.shopId, + managerShopId: managerShop.shopId, + }); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + expect(await shopContract.settlementManagerOf(shop.shopId)).to.be.equal(managerShop.shopId); + } + }); + + it("check client", async () => { + expect(await shopContract.getSettlementClientLength(managerShop.shopId)).to.be.equal(clients.length); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 0, 2)).to.deep.equal( + clients.slice(0, 2) + ); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 1, 3)).to.deep.equal( + clients.slice(1, 3) + ); + expect(await shopContract.getSettlementClientList(managerShop.shopId, 0, 4)).to.deep.equal( + clients.slice(0, 4) + ); + }); + }); + + context("refund", () => { + const managerShop = shopData[4]; + const expected = [400, 300, 200, 500].map((m) => Amount.make(m, 18).value); + const sumExpected = Amount.make(1400, 18).value; + let amountToken: BigNumber; + + it("Check refundable amount", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const { refundableAmount, refundableToken } = await shopContract.refundableOf(shop.shopId); + expect(refundableAmount).to.equal(expected[shopIndex]); + } + }); + + it("getCollectSettlementAmountMultiClientMessage", async () => { + const clientLength = await shopContract.getSettlementClientLength(managerShop.shopId); + const clients = await shopContract.getSettlementClientList(managerShop.shopId, 0, clientLength); + const nonce = await shopContract.nonceOf(managerShop.wallet.address); + const message = ContractUtils.getCollectSettlementAmountMultiClientMessage( + managerShop.shopId, + clients, + nonce + ); + const signature = await ContractUtils.signMessage(managerShop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .collectSettlementAmountMultiClient(managerShop.shopId, clients, signature) + ) + .to.emit(shopContract, "CollectedSettlementAmount") + .withNamedArgs({ + managerId: managerShop.shopId, + managerAccount: managerShop.wallet.address, + managerCurrency: managerShop.currency, + }); + }); + + it("Check refundable amount", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const { refundableAmount } = await shopContract.refundableOf(shop.shopId); + expect(refundableAmount).to.equal(0); + } + }); + + it("Check refundable amount of settlement manager", async () => { + const { refundableAmount, refundableToken } = await shopContract.refundableOf(managerShop.shopId); + expect(refundableAmount).to.equal(sumExpected); + amountToken = BigNumber.from(refundableToken); + }); + + it("refund", async () => { + const nonce = await shopContract.nonceOf(managerShop.wallet.address); + const message = ContractUtils.getShopRefundMessage( + managerShop.shopId, + managerShop.wallet.address, + sumExpected, + nonce + ); + const signature = await ContractUtils.signMessage(managerShop.wallet, message); + await expect( + shopContract + .connect(deployments.accounts.certifiers[0]) + .refund(managerShop.shopId, managerShop.wallet.address, sumExpected, signature) + ) + .to.emit(shopContract, "Refunded") + .withNamedArgs({ + shopId: managerShop.shopId, + account: managerShop.wallet.address, + refundAmount: sumExpected, + amountToken, + }); + }); + + it("Check balance of ledger", async () => { + const balance = await ledgerContract.tokenBalanceOf(managerShop.wallet.address); + expect(balance).to.equal(amountToken); + }); + }); + }); }); diff --git a/packages/relay/src/utils/Errors.ts b/packages/relay/src/utils/Errors.ts index 4110787c..e947da75 100644 --- a/packages/relay/src/utils/Errors.ts +++ b/packages/relay/src/utils/Errors.ts @@ -33,6 +33,8 @@ export class ResponseMessage { ["1220", "Insufficient withdrawal amount"], ["1221", "Withdrawal is already opened"], ["1222", "Withdrawal is not opened"], + ["1223", "The shop ID is invalid"], + ["1224", "The ID of the clearing manager and the ID of the shop should be different"], ["1501", "Invalid signature"], ["1502", "Unregistered phone number"], ["1503", "Does not match registered wallet address"], @@ -50,6 +52,10 @@ export class ResponseMessage { ["1532", "The status of the payment corresponding to the payment ID is not a cancellable condition"], ["1533", "The status of the payment corresponding to the payment ID is not being cancelled"], ["1534", "The period for cancellation of payment has expired"], + ["1551", "Expired signature"], + ["1552", "Only a settlement manager can execute"], + ["1553", "Settlement Manager does not match the registered value"], + ["1554", "This shop is not registered with the settlement manager"], ["1711", "Already Exist Deposit"], ["1712", "No Exist Withdraw"], ["1714", "The value entered is not an appropriate value"], diff --git a/yarn.lock b/yarn.lock index f1c7a517..e40232ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1932,6 +1932,18 @@ acc-bridge-contracts-v2@~2.5.0: "@openzeppelin/hardhat-upgrades" "^1.28.0" loyalty-tokens "~2.1.1" +acc-contracts-v2@~2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/acc-contracts-v2/-/acc-contracts-v2-2.6.0.tgz#eb71aaae439ef3096314f83de9c244f69366d310" + integrity sha512-Cnyh3XGRcN8m+PTD/3O/nr28IWlPu9tszkX9lAytmQO1w/yi/NEt5C0Bu0vcuLo5DdR6K4q7FGSgC3RIq5vqAA== + dependencies: + "@openzeppelin/contracts" "^4.9.5" + "@openzeppelin/contracts-upgradeable" "^4.9.5" + "@openzeppelin/hardhat-upgrades" "^1.28.0" + acc-bridge-contracts-v2 "~2.5.0" + loyalty-tokens "~2.1.1" + multisig-wallet-contracts "~2.0.0" + accepts@~1.3.8: version "1.3.8" resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz"