diff --git a/packages/contracts/contracts/Ledger.sol b/packages/contracts/contracts/Ledger.sol index b4fbc13c..58694bbd 100644 --- a/packages/contracts/contracts/Ledger.sol +++ b/packages/contracts/contracts/Ledger.sol @@ -38,6 +38,8 @@ contract Ledger { address account; bytes32 phone; } + mapping(string => PurchaseData) private purchases; + string[] private purchaseIds; struct PaymentData { string purchaseId; @@ -55,8 +57,37 @@ contract Ledger { bytes32 shopId; } - mapping(string => PurchaseData) private purchases; - string[] private purchaseIds; + struct LoyaltyPaymentInputData { + bytes32 paymentId; + string purchaseId; + uint256 amount; + string currency; + bytes32 shopId; + address account; + bytes signature; + } + + enum LoyaltyPaymentStatus { + INVALID, + PAID, + CANCELLED + } + + struct LoyaltyPaymentData { + bytes32 paymentId; + string purchaseId; + uint256 amount; + string currency; + bytes32 shopId; + address account; + uint256 timestamp; + LoyaltyType loyaltyType; + uint256 amountLoyalty; // Amount to convert point or token + uint256 feeLoyalty; // Amount to convert point or token + LoyaltyPaymentStatus status; + } + + mapping(bytes32 => LoyaltyPaymentData) private loyaltyPayments; address public foundationAccount; address public settlementAccount; @@ -345,6 +376,173 @@ contract Ledger { emit ChangedToPayablePoint(_phone, _account, amount, value, pointBalances[_account]); } + /// @notice 이용할 수 있는 지불 아이디 인지 알려준다. + /// @param _paymentId 지불 아이디 + function isAvailablePaymentId(bytes32 _paymentId) public view returns (bool) { + if (loyaltyPayments[_paymentId].status == LoyaltyPaymentStatus.INVALID) return true; + else return false; + } + + /// @notice 로얄티(포인트/토큰)을 구매에 사용하는 함수 + /// @dev 중계서버를 통해서 호출됩니다. + function payLoyalty(LoyaltyPaymentInputData calldata _data) public { + require(loyaltyPayments[_data.paymentId].status == LoyaltyPaymentStatus.INVALID, "Payment ID already in use"); + + LoyaltyPaymentInputData memory data = _data; + bytes32 dataHash = keccak256( + abi.encode( + data.paymentId, + data.purchaseId, + data.amount, + data.currency, + data.shopId, + data.account, + nonce[data.account] + ) + ); + require( + ECDSA.recover(ECDSA.toEthSignedMessageHash(dataHash), data.signature) == data.account, + "Invalid signature" + ); + + if (loyaltyTypes[data.account] == LoyaltyType.POINT) { + _payLoyaltyPoint(data); + } else { + _payLoyaltyToken(data); + } + } + + /// @notice 포인트를 구매에 사용하는 함수 + function _payLoyaltyPoint(LoyaltyPaymentInputData memory _data) internal { + LoyaltyPaymentInputData memory data = _data; + uint256 purchasePoint = convertCurrencyToPoint(data.amount, data.currency); + uint256 feeValue = (data.amount * fee) / 100; + uint256 feePoint = convertCurrencyToPoint(feeValue, data.currency); + uint256 feeToken = convertPointToToken(feePoint); + + require(pointBalances[data.account] >= purchasePoint + feePoint, "Insufficient balance"); + require(tokenBalances[foundationAccount] >= feeToken, "Insufficient foundation balance"); + + pointBalances[data.account] -= (purchasePoint + feePoint); + + // 재단의 토큰으로 교환해 지급한다. + tokenBalances[foundationAccount] -= feeToken; + tokenBalances[feeAccount] += feeToken; + + shopCollection.addUsedPoint(data.shopId, purchasePoint, data.purchaseId); + + uint256 settlementPoint = shopCollection.getSettlementPoint(data.shopId); + if (settlementPoint > 0) { + uint256 settlementToken = convertPointToToken(settlementPoint); + if (tokenBalances[foundationAccount] >= settlementToken) { + tokenBalances[settlementAccount] += settlementToken; + tokenBalances[foundationAccount] -= settlementToken; + shopCollection.addSettledPoint(data.shopId, settlementPoint, data.purchaseId); + emit ProvidedTokenForSettlement( + settlementAccount, + data.shopId, + settlementPoint, + settlementToken, + tokenBalances[settlementAccount], + data.purchaseId + ); + } + } + + nonce[data.account]++; + + LoyaltyPaymentData memory payData = LoyaltyPaymentData({ + paymentId: data.paymentId, + purchaseId: data.purchaseId, + amount: data.amount, + currency: data.currency, + shopId: data.shopId, + account: data.account, + timestamp: block.timestamp, + loyaltyType: LoyaltyType.POINT, + amountLoyalty: purchasePoint, + feeLoyalty: feePoint, + status: LoyaltyPaymentStatus.PAID + }); + loyaltyPayments[payData.paymentId] = payData; + + emit PaidPoint( + data.account, + purchasePoint, + data.amount, + feePoint, + feeValue, + pointBalances[data.account], + data.purchaseId, + data.shopId + ); + } + + /// @notice 토큰을 구매에 사용하는 함수 + function _payLoyaltyToken(LoyaltyPaymentInputData memory _data) internal { + LoyaltyPaymentInputData memory data = _data; + + uint256 purchasePoint = convertCurrencyToPoint(data.amount, data.currency); + uint256 purchaseToken = convertPointToToken(purchasePoint); + uint256 feeValue = (data.amount * fee) / 100; + uint256 feePoint = convertCurrencyToPoint(feeValue, data.currency); + uint256 feeToken = convertPointToToken(feePoint); + + require(tokenBalances[data.account] >= purchaseToken + feeToken, "Insufficient balance"); + + tokenBalances[data.account] -= (purchaseToken + feeToken); + tokenBalances[foundationAccount] += purchaseToken; + tokenBalances[feeAccount] += feeToken; + + shopCollection.addUsedPoint(data.shopId, purchasePoint, data.purchaseId); + + uint256 settlementPoint = shopCollection.getSettlementPoint(data.shopId); + if (settlementPoint > 0) { + uint256 settlementToken = convertPointToToken(settlementPoint); + if (tokenBalances[foundationAccount] >= settlementToken) { + tokenBalances[settlementAccount] += settlementToken; + tokenBalances[foundationAccount] -= settlementToken; + shopCollection.addSettledPoint(data.shopId, settlementPoint, data.purchaseId); + emit ProvidedTokenForSettlement( + settlementAccount, + data.shopId, + settlementPoint, + settlementToken, + tokenBalances[settlementAccount], + data.purchaseId + ); + } + } + + nonce[data.account]++; + + LoyaltyPaymentData memory payData = LoyaltyPaymentData({ + paymentId: data.paymentId, + purchaseId: data.purchaseId, + amount: data.amount, + currency: data.currency, + shopId: data.shopId, + account: data.account, + timestamp: block.timestamp, + loyaltyType: LoyaltyType.TOKEN, + amountLoyalty: purchaseToken, + feeLoyalty: feeToken, + status: LoyaltyPaymentStatus.PAID + }); + loyaltyPayments[payData.paymentId] = payData; + + emit PaidToken( + data.account, + purchaseToken, + data.amount, + feeToken, + feeValue, + tokenBalances[data.account], + data.purchaseId, + data.shopId + ); + } + /// @notice 포인트를 구매에 사용하는 함수 /// @dev 중계서버를 통해서 호출됩니다. function payPoint(PaymentData calldata _data) public { diff --git a/packages/contracts/src/utils/ContractUtils.ts b/packages/contracts/src/utils/ContractUtils.ts index 708092cd..28f17a29 100644 --- a/packages/contracts/src/utils/ContractUtils.ts +++ b/packages/contracts/src/utils/ContractUtils.ts @@ -127,6 +127,43 @@ export class ContractUtils { return signer.signMessage(message); } + public static getLoyaltyPaymentMessage( + address: string, + paymentId: string, + purchaseId: string, + amount: BigNumberish, + currency: string, + shopId: string, + nonce: BigNumberish + ): Uint8Array { + const encodedResult = hre.ethers.utils.defaultAbiCoder.encode( + ["bytes32", "string", "uint256", "string", "bytes32", "address", "uint256"], + [paymentId, purchaseId, amount, currency, shopId, address, nonce] + ); + return arrayify(hre.ethers.utils.keccak256(encodedResult)); + } + + public static async signLoyaltyPayment( + signer: Signer, + paymentId: string, + purchaseId: string, + amount: BigNumberish, + currency: string, + shopId: string, + nonce: BigNumberish + ): Promise { + const message = ContractUtils.getLoyaltyPaymentMessage( + await signer.getAddress(), + paymentId, + purchaseId, + amount, + currency, + shopId, + nonce + ); + return signer.signMessage(message); + } + public static getChangePayablePointMessage(phone: string, address: string, nonce: BigNumberish): Uint8Array { const encodedResult = hre.ethers.utils.defaultAbiCoder.encode( ["bytes32", "address", "uint256"], @@ -204,4 +241,12 @@ export class ContractUtils { const message = ContractUtils.getShopIdMessage(shopId, await signer.getAddress(), nonce); return signer.signMessage(message); } + + public static getPaymentId(account: string): string { + const encodedResult = hre.ethers.utils.defaultAbiCoder.encode( + ["address", "bytes32"], + [account, crypto.randomBytes(32)] + ); + return hre.ethers.utils.keccak256(encodedResult); + } } diff --git a/packages/contracts/test/03-Ledger.test.ts b/packages/contracts/test/03-Ledger.test.ts index 7f0c1236..34d3e63e 100644 --- a/packages/contracts/test/03-Ledger.test.ts +++ b/packages/contracts/test/03-Ledger.test.ts @@ -1226,6 +1226,58 @@ describe("Test for Ledger", () => { const newFeeBalance = await ledgerContract.tokenBalanceOf(fee.address); expect(newFeeBalance).to.deep.equal(oldFeeBalance.add(feeToken)); }); + + it("Pay Loyalty Point - Success", async () => { + const purchase: IPurchaseData = { + purchaseId: "P000100", + timestamp: 1672849000, + amount: 100, + method: 0, + currency: "krw", + shopIndex: 0, + userIndex: 0, + }; + + const paymentId = ContractUtils.getPaymentId(userWallets[purchase.userIndex].address); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const shop = shopData[purchase.shopIndex]; + const nonce = await ledgerContract.nonceOf(userWallets[purchase.userIndex].address); + const signature = await ContractUtils.signLoyaltyPayment( + userWallets[purchase.userIndex], + paymentId, + purchase.purchaseId, + purchaseAmount, + purchase.currency, + shop.shopId, + nonce + ); + const feeAmount = purchaseAmount.mul(await ledgerContract.fee()).div(100); + const feeToken = feeAmount.mul(multiple).div(price); + const oldFeeBalance = await ledgerContract.tokenBalanceOf(fee.address); + await expect( + ledgerContract.connect(relay).payLoyalty({ + paymentId, + purchaseId: purchase.purchaseId, + amount: purchaseAmount, + currency: purchase.currency.toLowerCase(), + shopId: shop.shopId, + account: userWallets[purchase.userIndex].address, + signature, + }) + ) + .to.emit(ledgerContract, "PaidPoint") + .withNamedArgs({ + account: userWallets[purchase.userIndex].address, + paidPoint: purchaseAmount, + paidValue: purchaseAmount, + feePoint: feeAmount, + feeValue: feeAmount, + purchaseId: purchase.purchaseId, + shopId: shop.shopId, + }); + const newFeeBalance = await ledgerContract.tokenBalanceOf(fee.address); + expect(newFeeBalance.toString()).to.deep.equal(oldFeeBalance.add(feeToken).toString()); + }); }); context("Pay token", () => { @@ -1350,6 +1402,63 @@ describe("Test for Ledger", () => { const newFeeBalance = await ledgerContract.tokenBalanceOf(fee.address); expect(newFeeBalance).to.deep.equal(oldFeeBalance.add(feeToken)); }); + + it("Pay Loyalty Token - Success", async () => { + const purchase: IPurchaseData = { + purchaseId: "P000000", + timestamp: 1672849000, + amount: 100, + method: 0, + currency: "krw", + shopIndex: 0, + userIndex: 1, + }; + + const paymentId = ContractUtils.getPaymentId(userWallets[purchase.userIndex].address); + const purchaseAmount = Amount.make(purchase.amount, 18).value; + const tokenAmount = purchaseAmount.mul(multiple).div(price); + const oldFoundationTokenBalance = await ledgerContract.tokenBalanceOf(foundation.address); + const shop = shopData[purchase.shopIndex]; + const nonce = await ledgerContract.nonceOf(userWallets[purchase.userIndex].address); + const signature = await ContractUtils.signLoyaltyPayment( + userWallets[purchase.userIndex], + paymentId, + purchase.purchaseId, + purchaseAmount, + purchase.currency, + shop.shopId, + nonce + ); + const feeAmount = purchaseAmount.mul(await ledgerContract.fee()).div(100); + const feeToken = feeAmount.mul(multiple).div(price); + const oldFeeBalance = await ledgerContract.tokenBalanceOf(fee.address); + await expect( + ledgerContract.connect(relay).payLoyalty({ + paymentId, + purchaseId: purchase.purchaseId, + amount: purchaseAmount, + currency: purchase.currency.toLowerCase(), + shopId: shop.shopId, + account: userWallets[purchase.userIndex].address, + signature, + }) + ) + .to.emit(ledgerContract, "PaidToken") + .withNamedArgs({ + account: userWallets[purchase.userIndex].address, + paidToken: tokenAmount, + paidValue: purchaseAmount, + feeToken, + feeValue: feeAmount, + purchaseId: purchase.purchaseId, + shopId: shop.shopId, + }); + expect(await ledgerContract.tokenBalanceOf(foundation.address)).to.deep.equal( + oldFoundationTokenBalance.add(tokenAmount) + ); + const newFeeBalance = await ledgerContract.tokenBalanceOf(fee.address); + expect(newFeeBalance).to.deep.equal(oldFeeBalance.add(feeToken)); + }); }); });