diff --git a/packages/library/package.json b/packages/library/package.json index be2d95b..ba13998 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "acc-contracts-lib-v2", - "version": "2.8.0", + "version": "2.9.0", "description": "", "main": "dist/bundle-cjs.js", "module": "dist/bundle-esm.js", diff --git a/packages/relay/package.json b/packages/relay/package.json index aed17e4..414546c 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -59,6 +59,7 @@ }, "dependencies": { "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", "@ethersproject/constants": "^5.7.0", "@ethersproject/wallet": "^5.7.0", "@ethersproject/experimental": "^5.7.0", @@ -70,7 +71,7 @@ "@typechain/ethers-v5": "^10.1.0", "@typechain/hardhat": "^6.1.2", "acc-bridge-contracts-v2": "~2.5.0", - "acc-contracts-v2": "~2.8.0", + "acc-contracts-v2": "~2.9.0", "argparse": "^2.0.1", "assert": "^2.0.0", "axios": "^1.6.7", diff --git a/packages/relay/src/routers/ShopRouter.ts b/packages/relay/src/routers/ShopRouter.ts index cf2af1f..78a9df2 100644 --- a/packages/relay/src/routers/ShopRouter.ts +++ b/packages/relay/src/routers/ShopRouter.ts @@ -17,6 +17,7 @@ import { body, param, query, validationResult } from "express-validator"; import * as hre from "hardhat"; // tslint:disable-next-line:no-implicit-dependencies +import { BigNumber } from "@ethersproject/bignumber"; import { AddressZero } from "@ethersproject/constants"; import { ContractManager } from "../contract/ContractManager"; import { Metrics } from "../metrics/Metrics"; @@ -246,6 +247,88 @@ export class ShopRouter { ], this.shop_refundable.bind(this) ); + this.app.post( + "/v1/shop/settlement/manager/set", + [ + body("shopId") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{64}$/i), + body("account").exists().trim().isEthereumAddress(), + body("managerId") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{64}$/i), + body("signature") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{130}$/i), + ], + this.shop_settlement_manager_set.bind(this) + ); + this.app.post( + "/v1/shop/settlement/manager/remove", + [ + body("shopId") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{64}$/i), + body("account").exists().trim().isEthereumAddress(), + body("signature") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{130}$/i), + ], + this.shop_settlement_manager_remove.bind(this) + ); + this.app.get( + "/v1/shop/settlement/manager/get/:shopId", + [ + param("shopId") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{64}$/i), + ], + this.shop_settlement_manager_get.bind(this) + ); + this.app.get( + "/v1/shop/settlement/client/length/:shopId", + [ + param("shopId") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{64}$/i), + ], + this.shop_settlement_client_length.bind(this) + ); + this.app.get( + "/v1/shop/settlement/client/list/:shopId", + [ + param("shopId") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{64}$/i), + query("startIndex").exists().trim().isNumeric(), + query("endIndex").exists().trim().isNumeric(), + ], + this.shop_settlement_client_list.bind(this) + ); + this.app.post( + "/v1/shop/settlement/collect", + [ + body("shopId") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{64}$/i), + body("account").exists().trim().isEthereumAddress(), + body("clients").exists(), + body("signature") + .exists() + .trim() + .matches(/^(0x)[0-9a-f]{130}$/i), + ], + this.shop_settlement_collect.bind(this) + ); } private async getNonce(req: express.Request, res: express.Response) { @@ -1306,4 +1389,244 @@ export class ShopRouter { return res.status(200).json(this.makeResponseData(msg.code, undefined, msg.error)); } } + + /** + * POST /v1/shop/settlement/manager/set + * @private + */ + private async shop_settlement_manager_set(req: express.Request, res: express.Response) { + logger.http(`POST /v1/shop/settlement/manager/set ${req.ip}:${JSON.stringify(req.body)}`); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(200).json(ResponseMessage.getErrorMessage("2001", { validation: errors.array() })); + } + + const signerItem = await this.getRelaySigner(); + try { + const shopId: string = String(req.body.shopId).trim(); + const account: string = String(req.body.account).trim(); + const managerId: string = String(req.body.managerId).trim(); + const signature: string = String(req.body.signature).trim(); + + const contract = this.contractManager.sideShopContract; + const message = ContractUtils.getSetSettlementManagerMessage( + shopId, + managerId, + await contract.nonceOf(account), + this.contractManager.sideChainId + ); + if (!ContractUtils.verifyMessage(account, message, signature)) + return res.status(200).json(ResponseMessage.getErrorMessage("1501")); + + const tx = await contract.connect(signerItem.signer).setSettlementManager(shopId, managerId, signature); + this.metrics.add("success", 1); + return res.status(200).json( + this.makeResponseData(0, { + shopId, + managerId, + txHash: tx.hash, + }) + ); + } catch (error: any) { + const msg = ResponseMessage.getEVMErrorMessage(error); + logger.error(`POST /v1/shop/settlement/manager/set : ${msg.error.message}`); + this.metrics.add("failure", 1); + return res.status(200).json(msg); + } finally { + this.releaseRelaySigner(signerItem); + } + } + + /** + * POST /v1/shop/settlement/manager/remove + * @private + */ + private async shop_settlement_manager_remove(req: express.Request, res: express.Response) { + logger.http(`POST /v1/shop/settlement/manager/remove ${req.ip}:${JSON.stringify(req.body)}`); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(200).json(ResponseMessage.getErrorMessage("2001", { validation: errors.array() })); + } + + const signerItem = await this.getRelaySigner(); + try { + const shopId: string = String(req.body.shopId).trim(); + const account: string = String(req.body.account).trim(); + const signature: string = String(req.body.signature).trim(); + + const contract = this.contractManager.sideShopContract; + const message = ContractUtils.getRemoveSettlementManagerMessage( + shopId, + await contract.nonceOf(account), + this.contractManager.sideChainId + ); + if (!ContractUtils.verifyMessage(account, message, signature)) + return res.status(200).json(ResponseMessage.getErrorMessage("1501")); + + const tx = await contract.connect(signerItem.signer).removeSettlementManager(shopId, signature); + this.metrics.add("success", 1); + return res.status(200).json( + this.makeResponseData(0, { + shopId, + txHash: tx.hash, + }) + ); + } catch (error: any) { + const msg = ResponseMessage.getEVMErrorMessage(error); + logger.error(`POST /v1/shop/settlement/manager/remove : ${msg.error.message}`); + this.metrics.add("failure", 1); + return res.status(200).json(msg); + } finally { + this.releaseRelaySigner(signerItem); + } + } + + /** + * GET /v1/shop/settlement/manager/get + * @private + */ + private async shop_settlement_manager_get(req: express.Request, res: express.Response) { + logger.http(`GET /v1/shop/settlement/manager/get ${req.ip}:${JSON.stringify(req.params)}`); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(200).json(ResponseMessage.getErrorMessage("2001", { validation: errors.array() })); + } + + try { + const shopId: string = String(req.params.shopId).trim(); + + const contract = this.contractManager.sideShopContract; + const managerId = await contract.settlementManagerOf(shopId); + this.metrics.add("success", 1); + return res.status(200).json( + this.makeResponseData(0, { + shopId, + managerId, + }) + ); + } catch (error: any) { + const msg = ResponseMessage.getEVMErrorMessage(error); + logger.error(`GET /v1/shop/settlement/manager/get : ${msg.error.message}`); + this.metrics.add("failure", 1); + return res.status(200).json(msg); + } + } + + /** + * GET /v1/shop/settlement/client/length + * @private + */ + private async shop_settlement_client_length(req: express.Request, res: express.Response) { + logger.http(`GET /v1/shop/settlement/client/length ${req.ip}:${JSON.stringify(req.body)}`); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(200).json(ResponseMessage.getErrorMessage("2001", { validation: errors.array() })); + } + + try { + const shopId: string = String(req.params.shopId).trim(); + + const contract = this.contractManager.sideShopContract; + const length = await contract.getSettlementClientLength(shopId); + this.metrics.add("success", 1); + return res.status(200).json( + this.makeResponseData(0, { + shopId, + length: length.toNumber(), + }) + ); + } catch (error: any) { + const msg = ResponseMessage.getEVMErrorMessage(error); + logger.error(`GET /v1/shop/settlement/client/length : ${msg.error.message}`); + this.metrics.add("failure", 1); + return res.status(200).json(msg); + } + } + + /** + * GET /v1/shop/settlement/client/list + * @private + */ + private async shop_settlement_client_list(req: express.Request, res: express.Response) { + logger.http(`GET /v1/shop/settlement/client/list ${req.ip}:${JSON.stringify(req.body)}`); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(200).json(ResponseMessage.getErrorMessage("2001", { validation: errors.array() })); + } + + try { + const shopId: string = String(req.params.shopId).trim(); + const startIndex = BigNumber.from(String(req.query.startIndex).trim()); + const endIndex = BigNumber.from(String(req.query.endIndex).trim()); + + const contract = this.contractManager.sideShopContract; + const clients = await contract.getSettlementClientList(shopId, startIndex, endIndex); + this.metrics.add("success", 1); + return res.status(200).json( + this.makeResponseData(0, { + shopId, + clients, + }) + ); + } catch (error: any) { + const msg = ResponseMessage.getEVMErrorMessage(error); + logger.error(`GET /v1/shop/settlement/client/list : ${msg.error.message}`); + this.metrics.add("failure", 1); + return res.status(200).json(msg); + } + } + + /** + * POST /v1/shop/settlement/collect + * @private + */ + private async shop_settlement_collect(req: express.Request, res: express.Response) { + logger.http(`POST /v1/shop/settlement/collect ${req.ip}:${JSON.stringify(req.body)}`); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(200).json(ResponseMessage.getErrorMessage("2001", { validation: errors.array() })); + } + + const signerItem = await this.getRelaySigner(); + try { + const shopId: string = String(req.body.shopId).trim(); + const account: string = String(req.body.account).trim(); + const clients: string[] = String(req.body.clients).trim().split(","); + const signature: string = String(req.body.signature).trim(); + + const contract = this.contractManager.sideShopContract; + const message = ContractUtils.getCollectSettlementAmountMultiClientMessage( + shopId, + clients, + await contract.nonceOf(account), + this.contractManager.sideChainId + ); + if (!ContractUtils.verifyMessage(account, message, signature)) + return res.status(200).json(ResponseMessage.getErrorMessage("1501")); + + const tx = await contract + .connect(signerItem.signer) + .collectSettlementAmountMultiClient(shopId, clients, signature); + this.metrics.add("success", 1); + return res.status(200).json( + this.makeResponseData(0, { + shopId, + txHash: tx.hash, + }) + ); + } catch (error: any) { + const msg = ResponseMessage.getEVMErrorMessage(error); + logger.error(`POST /v1/shop/settlement/collect : ${msg.error.message}`); + this.metrics.add("failure", 1); + return res.status(200).json(msg); + } finally { + this.releaseRelaySigner(signerItem); + } + } } diff --git a/packages/relay/src/utils/ContractUtils.ts b/packages/relay/src/utils/ContractUtils.ts index 99dba00..5140d38 100644 --- a/packages/relay/src/utils/ContractUtils.ts +++ b/packages/relay/src/utils/ContractUtils.ts @@ -9,7 +9,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 @@ -832,6 +832,57 @@ export class ContractUtils { return arrayify(keccak256(encodedResult)); } + 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, 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, 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, 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, nonce] + ); + return arrayify(keccak256(encodedResult)); + } + public static async signMessage(signer: Signer, message: Uint8Array): Promise { return signer.signMessage(message); } diff --git a/packages/relay/test/ShopWithdraw.test.ts b/packages/relay/test/ShopWithdraw.test.ts index 0e0c63b..9197d40 100644 --- a/packages/relay/test/ShopWithdraw.test.ts +++ b/packages/relay/test/ShopWithdraw.test.ts @@ -16,12 +16,12 @@ import { solidity } from "ethereum-waffle"; import { BigNumber, Wallet } from "ethers"; -import assert from "assert"; import path from "path"; import URI from "urijs"; import { URL } from "url"; -import { AddressZero } from "@ethersproject/constants"; +import { BytesLike } from "@ethersproject/bytes"; +import { AddressZero, HashZero } from "@ethersproject/constants"; chai.use(solidity); @@ -64,8 +64,6 @@ describe("Test for Shop", () => { const multiple = BigNumber.from(1000000000); const price = BigNumber.from(150).mul(multiple); - const amount = Amount.make(20_000, 18); - let client: TestClient; let server: TestServer; let storage: RelayStorage; @@ -514,7 +512,7 @@ describe("Test for Shop", () => { const url = URI(serverURL).directory("/v1/shop/info").filename(shop.shopId).toString(); const response = await client.get(url); expect(response.data.code).to.equal(0); - assert.deepStrictEqual(response.data.data, { + expect(response.data.data).to.deep.equal({ shopId: shop.shopId, name: "Shop3", currency: "krw", @@ -573,7 +571,7 @@ describe("Test for Shop", () => { const url = URI(serverURL).directory("/v1/shop/info").filename(shop.shopId).toString(); const response = await client.get(url); expect(response.data.code).to.equal(0); - assert.deepStrictEqual(response.data.data, { + expect(response.data.data).to.deep.equal({ shopId: shop.shopId, name: "Shop3", currency: "krw", @@ -587,4 +585,609 @@ describe("Test for Shop", () => { }); }); }); + + context("Refunds of shops 2", () => { + const userData: IUserData[] = [ + { + phone: "08201012341001", + address: userWallets[0].address, + privateKey: userWallets[0].privateKey, + }, + { + phone: "08201012341002", + address: userWallets[1].address, + privateKey: userWallets[1].privateKey, + }, + { + phone: "08201012341003", + address: userWallets[2].address, + privateKey: userWallets[2].privateKey, + }, + { + phone: "08201012341004", + address: userWallets[3].address, + privateKey: userWallets[3].privateKey, + }, + { + phone: "08201012341005", + address: userWallets[4].address, + privateKey: userWallets[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: shopWallets[0], + }, + { + shopId: "F000200", + name: "Shop2", + currency: "krw", + wallet: shopWallets[1], + }, + { + shopId: "F000300", + name: "Shop3", + currency: "krw", + wallet: shopWallets[2], + }, + { + shopId: "F000400", + name: "Shop4", + currency: "krw", + wallet: shopWallets[3], + }, + { + shopId: "F000500", + name: "Shop5", + currency: "krw", + wallet: shopWallets[4], + }, + { + shopId: "F000600", + name: "Shop6", + currency: "krw", + wallet: shopWallets[5], + }, + ]; + + before("Set Shop ID", async () => { + for (const elem of shopData) { + elem.shopId = ContractUtils.getShopId(elem.wallet.address, LoyaltyNetworkID.ACC_TESTNET); + } + }); + + before("Deploy", async () => { + deployments.setShopData(shopData); + await deployments.doDeploy(); + + ledgerContract = deployments.getContract("Ledger") as Ledger; + consumerContract = deployments.getContract("LoyaltyConsumer") as LoyaltyConsumer; + providerContract = deployments.getContract("LoyaltyProvider") as LoyaltyProvider; + shopContract = deployments.getContract("Shop") as Shop; + }); + + before("Create Config", async () => { + config.contracts.sideChain.tokenAddress = deployments.getContractAddress("TestLYT") || ""; + config.contracts.sideChain.currencyRateAddress = deployments.getContractAddress("CurrencyRate") || ""; + config.contracts.sideChain.phoneLinkerAddress = deployments.getContractAddress("PhoneLinkCollection") || ""; + config.contracts.sideChain.ledgerAddress = deployments.getContractAddress("Ledger") || ""; + config.contracts.sideChain.shopAddress = deployments.getContractAddress("Shop") || ""; + config.contracts.sideChain.loyaltyProviderAddress = deployments.getContractAddress("LoyaltyProvider") || ""; + config.contracts.sideChain.loyaltyConsumerAddress = deployments.getContractAddress("LoyaltyConsumer") || ""; + config.contracts.sideChain.loyaltyExchangerAddress = + deployments.getContractAddress("LoyaltyExchanger") || ""; + config.contracts.sideChain.loyaltyTransferAddress = deployments.getContractAddress("LoyaltyTransfer") || ""; + config.contracts.sideChain.loyaltyBridgeAddress = deployments.getContractAddress("LoyaltyBridge") || ""; + config.contracts.sideChain.chainBridgeAddress = deployments.getContractAddress("SideChainBridge") || ""; + + config.contracts.mainChain.tokenAddress = deployments.getContractAddress("MainChainKIOS") || ""; + config.contracts.mainChain.loyaltyBridgeAddress = + deployments.getContractAddress("MainChainLoyaltyBridge") || ""; + config.contracts.mainChain.chainBridgeAddress = deployments.getContractAddress("MainChainBridge") || ""; + + config.relay.certifiers = deployments.accounts.certifiers.map((m) => m.privateKey); + config.relay.callbackEndpoint = `http://127.0.0.1:${config.server.port}/callback`; + config.relay.relayEndpoint = `http://127.0.0.1:${config.server.port}`; + + client = new TestClient({ + headers: { + Authorization: config.relay.accessKey, + }, + }); + }); + + before("Create TestServer", async () => { + serverURL = new URL(`http://127.0.0.1:${config.server.port}`); + storage = await RelayStorage.make(config.database); + const graph_sidechain = await GraphStorage.make(config.graph_sidechain); + const graph_mainchain = await GraphStorage.make(config.graph_mainchain); + await contractManager.attach(); + server = new TestServer(config, contractManager, storage, graph_sidechain, graph_mainchain); + }); + + before("Start TestServer", async () => { + await server.start(); + }); + + after("Stop TestServer", async () => { + await server.stop(); + await storage.dropTestDB(); + }); + + 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 = ContractUtils.zeroGWEI(purchaseAmount.mul(purchase.providePercent).div(100)); + const amt = ContractUtils.zeroGWEI(purchaseAmount.mul(purchase.providePercent).div(100)); + const userAccount = + userData[purchase.userIndex].address.trim() !== "" + ? userData[purchase.userIndex].address.trim() + : AddressZero; + const purchaseParam = { + purchaseId: purchase.purchaseId, + 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, + contractManager.sideChainId + ); + const purchaseMessage = ContractUtils.getPurchasesMessage( + 0, + [purchaseParam], + contractManager.sideChainId + ); + const signatures = await Promise.all( + deployments.accounts.validators.map((m) => ContractUtils.signMessage(m, purchaseMessage)) + ); + const proposeMessage = ContractUtils.getPurchasesProposeMessage( + 0, + [purchaseParam], + signatures, + contractManager.sideChainId + ); + 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( + purchase.purchaseId, + purchaseAmount, + loyaltyAmount, + purchase.currency.toLowerCase(), + shopData[purchase.shopIndex].shopId, + userAccount, + phoneHash, + deployments.accounts.system.address + ) + .emit(ledgerContract, "ProvidedPoint") + .withNamedArgs({ + account: userAccount, + providedPoint: amt, + providedValue: amt, + purchaseId: purchase.purchaseId, + }); + } + }); + }); + + 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 nonce = await ledgerContract.nonceOf(userWallets[purchase.userIndex].address); + const paymentId = ContractUtils.getPaymentId(userWallets[purchase.userIndex].address, nonce); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const shop = shopData[purchase.shopIndex]; + const signature = await ContractUtils.signLoyaltyNewPayment( + userWallets[purchase.userIndex], + paymentId, + purchase.purchaseId, + purchaseAmount, + purchase.currency, + shop.shopId, + nonce, + contractManager.sideChainId + ); + + const [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: userWallets[purchase.userIndex].address, + signature, + secretLock, + }) + ).to.emit(consumerContract, "LoyaltyPaymentEvent"); + + const paymentData = await consumerContract.loyaltyPaymentOf(paymentId); + expect(paymentData.paymentId).to.deep.equal(paymentId); + expect(paymentData.purchaseId).to.deep.equal(purchase.purchaseId); + expect(paymentData.currency).to.deep.equal(purchase.currency); + expect(paymentData.shopId).to.deep.equal(shop.shopId); + expect(paymentData.account).to.deep.equal(userWallets[purchase.userIndex].address); + expect(paymentData.paidPoint).to.deep.equal(purchaseAmount); + expect(paymentData.paidValue).to.deep.equal(purchaseAmount); + + 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, + contractManager.sideChainId + ); + const signature = await ContractUtils.signMessage(shop.wallet, message); + + const uri = URI(serverURL).directory("/v1/shop/settlement/manager").filename("set"); + const url = uri.toString(); + const response = await client.post(url, { + shopId: shop.shopId, + account: shop.wallet.address, + managerId: managerShop.shopId, + signature, + }); + + expect(response.data.code).to.equal(0); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.txHash).to.match(/^0x[A-Fa-f0-9]{64}$/i); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const uri = URI(serverURL).directory("/v1/shop/settlement/manager/get").filename(shop.shopId); + const url = uri.toString(); + const response = await client.get(url); + expect(response.data.code).to.equal(0); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.managerId).to.be.equal(managerShop.shopId); + } + }); + + it("check client", async () => { + const uri = URI(serverURL) + .directory("/v1/shop/settlement/client/length") + .filename(managerShop.shopId) + .toString(); + const response = await client.get(uri); + expect(response.data.data.length).to.be.equal(clients.length); + + const response2 = await client.get( + URI(serverURL) + .directory("/v1/shop/settlement/client/list") + .filename(managerShop.shopId) + .addQuery("startIndex", 0) + .addQuery("endIndex", 2) + .toString() + ); + expect(response2.data.data.clients).to.deep.equal(clients.slice(0, 2)); + }); + + 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, + contractManager.sideChainId + ); + const signature = await ContractUtils.signMessage(shop.wallet, message); + + const uri = URI(serverURL).directory("/v1/shop/settlement/manager").filename("remove"); + const url = uri.toString(); + const response = await client.post(url, { + shopId: shop.shopId, + account: shop.wallet.address, + signature, + }); + + expect(response.data.code).to.equal(0); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.txHash).to.match(/^0x[A-Fa-f0-9]{64}$/i); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const uri = URI(serverURL).directory("/v1/shop/settlement/manager/get").filename(shop.shopId); + const url = uri.toString(); + const response = await client.get(url); + expect(response.data.code).to.equal(0); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.managerId).to.be.equal(HashZero); + } + }); + + it("check client", async () => { + const uri = URI(serverURL) + .directory("/v1/shop/settlement/client/length") + .filename(managerShop.shopId) + .toString(); + const response = await client.get(uri); + expect(response.data.data.length).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, + contractManager.sideChainId + ); + const signature = await ContractUtils.signMessage(shop.wallet, message); + + const uri = URI(serverURL).directory("/v1/shop/settlement/manager").filename("set"); + const url = uri.toString(); + const response = await client.post(url, { + shopId: shop.shopId, + account: shop.wallet.address, + managerId: managerShop.shopId, + signature, + }); + + expect(response.data.code).to.equal(0); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.txHash).to.match(/^0x[A-Fa-f0-9]{64}$/i); + } + }); + + it("check manager", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const uri = URI(serverURL).directory("/v1/shop/settlement/manager/get").filename(shop.shopId); + const url = uri.toString(); + const response = await client.get(url); + expect(response.data.code).to.equal(0); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.managerId).to.be.equal(managerShop.shopId); + } + }); + + it("check client", async () => { + const uri = URI(serverURL) + .directory("/v1/shop/settlement/client/length") + .filename(managerShop.shopId) + .toString(); + const response = await client.get(uri); + expect(response.data.data.length).to.be.equal(clients.length); + + const response2 = await client.get( + URI(serverURL) + .directory("/v1/shop/settlement/client/list") + .filename(managerShop.shopId) + .addQuery("startIndex", 0) + .addQuery("endIndex", 2) + .toString() + ); + expect(response2.data.data.clients).to.deep.equal(clients.slice(0, 2)); + }); + }); + + 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 url = URI(serverURL).directory("/v1/shop/refundable/").filename(shop.shopId).toString(); + const response = await client.get(url); + const refundableAmount = BigNumber.from(response.data.data.refundableAmount); + 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, + contractManager.sideChainId + ); + const signature = await ContractUtils.signMessage(managerShop.wallet, message); + + const uri = URI(serverURL).directory("/v1/shop/settlement/collect"); + const url = uri.toString(); + const response = await client.post(url, { + shopId: managerShop.shopId, + account: managerShop.wallet.address, + clients: clients.join(","), + signature, + }); + + expect(response.data.code).to.equal(0); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.txHash).to.match(/^0x[A-Fa-f0-9]{64}$/i); + }); + + it("Check refundable amount", async () => { + for (let shopIndex = 0; shopIndex < 4; shopIndex++) { + const shop = shopData[shopIndex]; + const url = URI(serverURL).directory("/v1/shop/refundable/").filename(shop.shopId).toString(); + const response = await client.get(url); + const refundableAmount = BigNumber.from(response.data.data.refundableAmount); + expect(refundableAmount).to.equal(0); + } + }); + + it("Check refundable amount of settlement manager", async () => { + const url = URI(serverURL).directory("/v1/shop/refundable/").filename(managerShop.shopId).toString(); + const response = await client.get(url); + const refundableAmount = BigNumber.from(response.data.data.refundableAmount); + expect(refundableAmount).to.equal(sumExpected); + amountToken = BigNumber.from(response.data.data.refundableToken); + }); + + it("refund", async () => { + const nonce = await shopContract.nonceOf(managerShop.wallet.address); + const message = ContractUtils.getShopRefundMessage( + managerShop.shopId, + managerShop.wallet.address, + sumExpected, + nonce, + contractManager.sideChainId + ); + const signature = await ContractUtils.signMessage(managerShop.wallet, message); + + const uri = URI(serverURL).directory("/v1/shop").filename("refund"); + const url = uri.toString(); + const response = await client.post(url, { + shopId: managerShop.shopId, + amount: sumExpected.toString(), + account: managerShop.wallet.address, + signature, + }); + + expect(response.data.code).to.equal(0); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.txHash).to.match(/^0x[A-Fa-f0-9]{64}$/i); + }); + + it("Check refundable amount", async () => { + const url = URI(serverURL).directory("/v1/shop/refundable/").filename(managerShop.shopId).toString(); + const response = await client.get(url); + const refundableAmount = BigNumber.from(response.data.data.refundableAmount); + expect(refundableAmount).to.equal(0); + }); + + it("Check balance of ledger", async () => { + const url = URI(serverURL) + .directory("/v1/ledger/balance/account/") + .filename(managerShop.wallet.address) + .toString(); + const response = await client.get(url); + const balance = BigNumber.from(response.data.data.token.balance); + expect(balance).to.equal(amountToken); + }); + }); + }); }); diff --git a/packages/relay/tspec/02_Shop.ts b/packages/relay/tspec/02_Shop.ts index d94338d..33d5335 100644 --- a/packages/relay/tspec/02_Shop.ts +++ b/packages/relay/tspec/02_Shop.ts @@ -626,5 +626,320 @@ export type ShopApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/v1/shop/refund": { + post: { + summary: ""; + body: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + /** + * Amount for refund (info. decimals are 18) + * @example "100000000000000000000000" + */ + amount: string; + /** + * Address of wallet + * @example "0xafFe745418Ad24c272175e5B58610A8a35e2EcDa" + */ + account: string; + /** + * Signature of shop owner + * @example "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a1b" + */ + signature: string; + }; + responses: { + 200: { + /** + * Result Code + * @example 0 + */ + code: ResultCode; + data: { + /** + * Hash of transaction + * @example "0x3798157a3f32c0ed7692f240eb83f3a3c2f6077c5ad7acf7a9a54d426d63632e" + */ + txHash: string; + }; + error?: { + /** + * Error Message + * @example "Failed to check the validity of parameters" + */ + message: string; + }; + }; + }; + }; + }; + "/v1/shop/settlement/manager/set": { + post: { + summary: "Set settlement manager of the shop(shopId)"; + body: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + /** + * Address of wallet + * @example "0xafFe745418Ad24c272175e5B58610A8a35e2EcDa" + */ + account: string; + /** + * ID of settlement manager + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + managerId: string; + /** + * Signature of shop owner + * @example "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a1b" + */ + signature: string; + }; + responses: { + 200: { + /** + * Result Code + * @example 0 + */ + code: ResultCode; + data: { + /** + * Hash of transaction + * @example "0x3798157a3f32c0ed7692f240eb83f3a3c2f6077c5ad7acf7a9a54d426d63632e" + */ + txHash: string; + }; + error?: { + /** + * Error Message + * @example "Failed to check the validity of parameters" + */ + message: string; + }; + }; + }; + }; + }; + "/v1/shop/settlement/manager/remove": { + post: { + summary: "Remove settlement manager of the shop(shopId)"; + body: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + /** + * Address of wallet + * @example "0xafFe745418Ad24c272175e5B58610A8a35e2EcDa" + */ + account: string; + /** + * Signature of shop owner + * @example "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a1b" + */ + signature: string; + }; + responses: { + 200: { + /** + * Result Code + * @example 0 + */ + code: ResultCode; + data: { + /** + * Hash of transaction + * @example "0x3798157a3f32c0ed7692f240eb83f3a3c2f6077c5ad7acf7a9a54d426d63632e" + */ + txHash: string; + }; + error?: { + /** + * Error Message + * @example "Failed to check the validity of parameters" + */ + message: string; + }; + }; + }; + }; + }; + "/v1/shop/settlement/manager/get/{shopId}": { + get: { + summary: "Provide settlement manager"; + path: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + }; + responses: { + 200: { + /** + * Result Code + * @example 0 + */ + code: ResultCode; + data: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + /** + * ID of Settlement Manager + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + managerId: string; + }; + error?: { + /** + * Error Message + * @example "Failed to check the validity of parameters" + */ + message: string; + }; + }; + }; + }; + }; + "/v1/shop/settlement/client/length/{shopId}": { + get: { + summary: "Provide number of registered shops"; + path: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + }; + responses: { + 200: { + /** + * Result Code + * @example 0 + */ + code: ResultCode; + data: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + /** + * Number of registered shop + * @example 10 + */ + length: number; + }; + error?: { + /** + * Error Message + * @example "Failed to check the validity of parameters" + */ + message: string; + }; + }; + }; + }; + }; + "/v1/shop/settlement/client/list/{shopId}": { + get: { + summary: "Provide registered shops"; + path: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + }; + responses: { + 200: { + /** + * Result Code + * @example 0 + */ + code: ResultCode; + data: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + /** + * registered shops + * @example 10 + */ + clients: string[]; + }; + error?: { + /** + * Error Message + * @example "Failed to check the validity of parameters" + */ + message: string; + }; + }; + }; + }; + }; + "/v1/shop/settlement/collect": { + post: { + summary: "Collect settlement amount"; + body: { + /** + * ID of Shop + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + shopId: string; + /** + * Address of wallet + * @example "0xafFe745418Ad24c272175e5B58610A8a35e2EcDa" + */ + account: string; + /** + * Address of wallet + * @example "0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874,0x0001be96d74202df38fd21462ffcef10dfe0fcbd7caa3947689a3903e8b6b874" + */ + clients: string; + /** + * Signature of shop owner + * @example "0x020d671b80fbd20466d8cb65cef79a24e3bca3fdf82e9dd89d78e7a4c4c045bd72944c20bb1d839e76ee6bb69fed61f64376c37799598b40b8c49148f3cdd88a1b" + */ + signature: string; + }; + responses: { + 200: { + /** + * Result Code + * @example 0 + */ + code: ResultCode; + data: { + /** + * Hash of transaction + * @example "0x3798157a3f32c0ed7692f240eb83f3a3c2f6077c5ad7acf7a9a54d426d63632e" + */ + txHash: string; + }; + error?: { + /** + * Error Message + * @example "Failed to check the validity of parameters" + */ + message: string; + }; + }; + }; + }; + }; }; }>; diff --git a/yarn.lock b/yarn.lock index e40232e..f1c7a51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1932,18 +1932,6 @@ 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"