diff --git a/packages/relay/src/routers/DefaultRouter.ts b/packages/relay/src/routers/DefaultRouter.ts index ffcbb0ad..cff7cb1c 100644 --- a/packages/relay/src/routers/DefaultRouter.ts +++ b/packages/relay/src/routers/DefaultRouter.ts @@ -222,6 +222,21 @@ export class DefaultRouter { ], this.setPointType.bind(this) ); + + // 사용가능한 포인트로 전환 + this.app.post( + "/changeToPayablePoint", + [ + body("phone") + .exists() + .matches(/^(0x)[0-9a-f]{64}$/i), + body("account").exists().isEthereumAddress(), + body("signature") + .exists() + .matches(/^(0x)[0-9a-f]{130}$/i), + ], + this.changeToPayablePoint.bind(this) + ); } private async getHealthStatus(req: express.Request, res: express.Response) { @@ -342,6 +357,7 @@ export class DefaultRouter { this.releaseRelaySigner(signerItem); } } + /** * 포인트의 종류를 선택한다. * POST /setPointType @@ -394,4 +410,57 @@ export class DefaultRouter { this.releaseRelaySigner(signerItem); } } + + /** + * 포인트의 종류를 선택한다. + * POST /changeToPayablePoint + * @private + */ + private async changeToPayablePoint(req: express.Request, res: express.Response) { + logger.http(`POST /changeToPayablePoint`); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(200).json( + this.makeResponseData(501, undefined, { + message: "Failed to check the validity of parameters.", + validation: errors.array(), + }) + ); + } + + const signerItem = await this.getRelaySigner(); + try { + const phone: string = String(req.body.phone); + const account: string = String(req.body.account); // 구매자의 주소 + const signature: string = String(req.body.signature); // 서명 + + // 서명검증 + const userNonce = await (await this.getLedgerContract()).nonceOf(account); + if (!ContractUtils.verifyChangePayablePoint(phone, account, userNonce, signature)) + return res.status(200).json( + this.makeResponseData(500, undefined, { + message: "Signature is not valid.", + }) + ); + + const tx = await (await this.getLedgerContract()) + .connect(signerItem.signer) + .changeToPayablePoint(phone, account, signature); + + logger.http(`TxHash(setPointType): `, tx.hash); + return res.status(200).json(this.makeResponseData(200, { txHash: tx.hash })); + } catch (error: any) { + let message = ContractUtils.cacheEVMError(error as any); + if (message === "") message = "Failed change point type"; + logger.error(`POST /setPointType :`, message); + return res.status(200).json( + this.makeResponseData(500, undefined, { + message, + }) + ); + } finally { + this.releaseRelaySigner(signerItem); + } + } } diff --git a/packages/relay/src/utils/ContractUtils.ts b/packages/relay/src/utils/ContractUtils.ts index 385477e7..74dc2907 100644 --- a/packages/relay/src/utils/ContractUtils.ts +++ b/packages/relay/src/utils/ContractUtils.ts @@ -1,5 +1,5 @@ import * as crypto from "crypto"; -import { BigNumberish, ethers, Signer } from "ethers"; +import { BigNumberish, BytesLike, ethers, Signer } from "ethers"; // tslint:disable-next-line:no-submodule-imports import { arrayify } from "ethers/lib/utils"; import * as hre from "hardhat"; @@ -85,15 +85,30 @@ export class ContractUtils { return hre.ethers.utils.keccak256(encodedResult); } - public static async sign(signer: Signer, hash: string, nonce: BigNumberish): Promise { - const encodedResult = ethers.utils.defaultAbiCoder.encode( + public static getRequestHash(hash: string, address: string, nonce: BigNumberish): Uint8Array { + const encodedResult = hre.ethers.utils.defaultAbiCoder.encode( ["bytes32", "address", "uint256"], - [hash, await signer.getAddress(), nonce] + [hash, address, nonce] ); - const message = arrayify(ethers.utils.keccak256(encodedResult)); + return arrayify(hre.ethers.utils.keccak256(encodedResult)); + } + + public static async signRequestHash(signer: Signer, hash: string, nonce: BigNumberish): Promise { + const message = ContractUtils.getRequestHash(hash, await signer.getAddress(), nonce); return signer.signMessage(message); } + public static verifyRequestHash(address: string, hash: string, nonce: BigNumberish, signature: string): boolean { + const message = ContractUtils.getRequestHash(hash, address, nonce); + let res: string; + try { + res = hre.ethers.utils.verifyMessage(message, signature); + } catch (error) { + return false; + } + return res.toLowerCase() === address.toLowerCase(); + } + public static getPaymentMessage( address: string, purchaseId: string, @@ -135,7 +150,7 @@ export class ContractUtils { shopId: string, account: string, nonce: BigNumberish, - signature: string + signature: BytesLike ): boolean { const message = ContractUtils.getPaymentMessage(account, purchaseId, amount, currency, shopId, nonce); let res: string; @@ -175,4 +190,33 @@ export class ContractUtils { } return res.toLowerCase() === account.toLowerCase(); } + + public static getChangePayablePointMessage(phone: BytesLike, address: string, nonce: BigNumberish): Uint8Array { + const encodedResult = hre.ethers.utils.defaultAbiCoder.encode( + ["bytes32", "address", "uint256"], + [phone, address, nonce] + ); + return arrayify(hre.ethers.utils.keccak256(encodedResult)); + } + + public static async signChangePayablePoint(signer: Signer, phone: BytesLike, nonce: BigNumberish): Promise { + const message = ContractUtils.getChangePayablePointMessage(phone, await signer.getAddress(), nonce); + return signer.signMessage(message); + } + + public static verifyChangePayablePoint( + phone: BytesLike, + account: string, + nonce: BigNumberish, + signature: BytesLike + ): boolean { + const message = ContractUtils.getChangePayablePointMessage(phone, account, nonce); + let res: string; + try { + res = ethers.utils.verifyMessage(message, signature); + } catch (error) { + return false; + } + return res.toLowerCase() === account.toLowerCase(); + } } diff --git a/packages/relay/test/Endpoints.test.ts b/packages/relay/test/Endpoints.test.ts index 7aa29edb..437f8e4f 100644 --- a/packages/relay/test/Endpoints.test.ts +++ b/packages/relay/test/Endpoints.test.ts @@ -19,7 +19,7 @@ import * as path from "path"; import { URL } from "url"; import * as assert from "assert"; -import { BigNumber } from "ethers"; +import { BigNumber, Wallet } from "ethers"; // tslint:disable-next-line:no-implicit-dependencies import { AddressZero } from "@ethersproject/constants"; @@ -207,6 +207,7 @@ describe("Test of Server", function () { interface IUserData { phone: string; + wallet: Wallet; address: string; privateKey: string; } @@ -214,16 +215,19 @@ describe("Test of Server", function () { const userData: IUserData[] = [ { phone: "08201012341001", + wallet: users[0], address: users[0].address, privateKey: users[0].privateKey, }, { phone: "08201012341002", + wallet: users[1], address: users[1].address, privateKey: users[1].privateKey, }, { phone: "08201012341003", + wallet: users[2], address: users[2].address, privateKey: users[2].privateKey, }, @@ -566,4 +570,160 @@ describe("Test of Server", function () { }); }); }); + + context("Test token & point relay endpoints - using phone", () => { + before("Deploy", async () => { + await deployAllContract(); + }); + + before("Prepare Token", async () => { + for (const elem of users) { + await tokenContract.connect(deployer).transfer(elem.address, amount.value.mul(10)); + } + }); + + before("Create Config", async () => { + config = new Config(); + config.readFromFile(path.resolve(process.cwd(), "config", "config_test.yaml")); + config.contracts.tokenAddress = tokenContract.address; + config.contracts.phoneLinkerAddress = linkCollectionContract.address; + config.contracts.ledgerAddress = ledgerContract.address; + + config.relay.managerKeys = [ + relay1.privateKey, + relay2.privateKey, + relay3.privateKey, + relay4.privateKey, + relay5.privateKey, + ]; + }); + + before("Create TestServer", async () => { + serverURL = new URL(`http://127.0.0.1:${config.server.port}`); + server = new TestServer(config); + }); + + before("Start TestServer", async () => { + await server.start(); + }); + + after("Stop TestServer", async () => { + await server.stop(); + }); + + context("Prepare shop data", () => { + it("Add Shop Data", async () => { + for (const elem of shopData) { + const phoneHash = ContractUtils.getPhoneHash(elem.phone); + await expect( + shopCollection + .connect(validator1) + .add(elem.shopId, elem.provideWaitTime, elem.providePercent, phoneHash) + ) + .to.emit(shopCollection, "AddedShop") + .withArgs(elem.shopId, elem.provideWaitTime, elem.providePercent, phoneHash); + } + expect(await shopCollection.shopsLength()).to.equal(shopData.length); + }); + }); + + context("Save Purchase Data", () => { + const userIndex = 0; + const purchase: IPurchaseData = { + purchaseId: "P000001", + timestamp: 1672844400, + amount: 10000, + method: 0, + currency: "krw", + shopIndex: 1, + userIndex, + }; + + it("Save Purchase Data", async () => { + const phoneHash = ContractUtils.getPhoneHash(userData[userIndex].phone); + const userAccount = AddressZero; + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const shop = shopData[purchase.shopIndex]; + const pointAmount = purchaseAmount.mul(shop.providePercent).div(100); + await expect( + ledgerContract.connect(validators[0]).savePurchase({ + purchaseId: purchase.purchaseId, + timestamp: purchase.timestamp, + amount: purchaseAmount, + currency: purchase.currency.toLowerCase(), + shopId: shop.shopId, + method: purchase.method, + account: userAccount, + phone: phoneHash, + }) + ) + .to.emit(ledgerContract, "SavedPurchase") + .withNamedArgs({ + purchaseId: purchase.purchaseId, + timestamp: purchase.timestamp, + amount: purchaseAmount, + currency: purchase.currency.toLowerCase(), + shopId: shop.shopId, + method: purchase.method, + account: userAccount, + phone: phoneHash, + }) + .emit(ledgerContract, "ProvidedUnPayablePoint") + .withNamedArgs({ + phone: phoneHash, + providedAmountPoint: pointAmount, + value: pointAmount, + balancePoint: pointAmount, + purchaseId: purchase.purchaseId, + shopId: shop.shopId, + }); + }); + + it("Link phone and wallet address", async () => { + const phoneHash = ContractUtils.getPhoneHash(userData[userIndex].phone); + const nonce = await linkCollectionContract.nonceOf(userData[userIndex].address); + const signature = await ContractUtils.signRequestHash(userData[userIndex].wallet, phoneHash, nonce); + const requestId = ContractUtils.getRequestId(phoneHash, userData[userIndex].address, nonce); + await expect( + linkCollectionContract + .connect(relay1) + .addRequest(requestId, phoneHash, userData[userIndex].address, signature) + ) + .to.emit(linkCollectionContract, "AddedRequestItem") + .withArgs(requestId, phoneHash, userData[userIndex].address); + await linkCollectionContract.connect(linkValidators[0]).voteRequest(requestId); + await linkCollectionContract.connect(linkValidators[0]).countVote(requestId); + }); + + it("Change to payable point", async () => { + const phoneHash = ContractUtils.getPhoneHash(userData[userIndex].phone); + const payableBalance = await ledgerContract.pointBalanceOf(userData[userIndex].address); + const unPayableBalance = await ledgerContract.unPayablePointBalanceOf(phoneHash); + + const nonce = await ledgerContract.nonceOf(userData[userIndex].address); + const signature = await ContractUtils.signChangePayablePoint( + userData[userIndex].wallet, + phoneHash, + nonce + ); + + const uri = URI(serverURL).directory("changeToPayablePoint"); + const url = uri.toString(); + const response = await client.post(url, { + phone: phoneHash, + account: users[userIndex].address, + signature, + }); + + expect(response.data.code).to.equal(200); + expect(response.data.data).to.not.equal(undefined); + expect(response.data.data.txHash).to.match(/^0x[A-Fa-f0-9]{64}$/i); + + expect(await ledgerContract.pointBalanceOf(userData[userIndex].address)).to.equal( + payableBalance.add(unPayableBalance) + ); + expect(await ledgerContract.unPayablePointBalanceOf(phoneHash)).to.equal(0); + }); + }); + }); });