From d25d756aea7e50ddbc8b8020f34cd840a2775991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Thu, 5 May 2022 14:07:48 +0200 Subject: [PATCH 01/19] Add a common interface for both versions of the contract --- contracts/IPrepaidCardMarket.sol | 17 +++++++++++++---- contracts/PrepaidCardManager.sol | 4 ++-- contracts/PrepaidCardMarket.sol | 2 -- .../RemovePrepaidCardInventoryHandler.sol | 4 ++-- .../SetPrepaidCardInventoryHandler.sol | 7 ++----- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/contracts/IPrepaidCardMarket.sol b/contracts/IPrepaidCardMarket.sol index 16ddf744..83f3d7ad 100644 --- a/contracts/IPrepaidCardMarket.sol +++ b/contracts/IPrepaidCardMarket.sol @@ -2,11 +2,13 @@ pragma solidity ^0.8.9; pragma abicoder v1; interface IPrepaidCardMarket { - function setItem(address issuer, address prepaidCard) external returns (bool); + // mapping + function asks(bytes32) external view returns (uint256); - function removeItems(address issuer, address[] calldata prepaidCards) - external - returns (bool); + // property + function paused() external view returns (bool); + + function getQuantity(bytes32 sku) external view returns (uint256); function setAsk( address issuer, @@ -27,4 +29,11 @@ interface IPrepaidCardMarket { uint256 faceValue, string memory customizationDID ); + + function getSKU( + address issuer, + address token, + uint256 faceValue, + string memory customizationDID + ) external view returns (bytes32); } diff --git a/contracts/PrepaidCardManager.sol b/contracts/PrepaidCardManager.sol index 2faed98b..207e2fb1 100644 --- a/contracts/PrepaidCardManager.sol +++ b/contracts/PrepaidCardManager.sol @@ -9,7 +9,7 @@ import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeab import "./core/Ownable.sol"; import "./token/IERC677.sol"; -import "./IPrepaidCardMarket.sol"; +import "./PrepaidCardMarket.sol"; import "./TokenManager.sol"; import "./core/Safe.sol"; import "./core/Versionable.sol"; @@ -624,7 +624,7 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { ); if (marketAddress != address(0)) { - IPrepaidCardMarket(marketAddress).setItem(owner, card); + PrepaidCardMarket(marketAddress).setItem(owner, card); } return card; diff --git a/contracts/PrepaidCardMarket.sol b/contracts/PrepaidCardMarket.sol index ae813b9d..0304de29 100644 --- a/contracts/PrepaidCardMarket.sol +++ b/contracts/PrepaidCardMarket.sol @@ -122,7 +122,6 @@ contract PrepaidCardMarket is Ownable, Versionable, IPrepaidCardMarket { function setItem(address issuer, address prepaidCard) external - override onlyHandlersOrPrepaidCardManager returns (bool) { @@ -155,7 +154,6 @@ contract PrepaidCardMarket is Ownable, Versionable, IPrepaidCardMarket { function removeItems(address issuer, address[] calldata prepaidCards) external - override onlyHandlers returns (bool) { diff --git a/contracts/action-handlers/RemovePrepaidCardInventoryHandler.sol b/contracts/action-handlers/RemovePrepaidCardInventoryHandler.sol index 43120488..497e6261 100644 --- a/contracts/action-handlers/RemovePrepaidCardInventoryHandler.sol +++ b/contracts/action-handlers/RemovePrepaidCardInventoryHandler.sol @@ -6,7 +6,7 @@ import "../core/Versionable.sol"; import "../token/IERC677.sol"; import "../PrepaidCardManager.sol"; import "../TokenManager.sol"; -import "../IPrepaidCardMarket.sol"; +import "../PrepaidCardMarket.sol"; import "../VersionManager.sol"; contract RemovePrepaidCardInventoryHandler is Ownable, Versionable { @@ -90,7 +90,7 @@ contract RemovePrepaidCardInventoryHandler is Ownable, Versionable { } prepaidCardMgr.setPrepaidCardUsed(prepaidCard); - return IPrepaidCardMarket(marketAddress).removeItems(owner, prepaidCards); + return PrepaidCardMarket(marketAddress).removeItems(owner, prepaidCards); } function cardpayVersion() external view returns (string memory) { diff --git a/contracts/action-handlers/SetPrepaidCardInventoryHandler.sol b/contracts/action-handlers/SetPrepaidCardInventoryHandler.sol index 046921c9..bb4f4956 100644 --- a/contracts/action-handlers/SetPrepaidCardInventoryHandler.sol +++ b/contracts/action-handlers/SetPrepaidCardInventoryHandler.sol @@ -6,7 +6,7 @@ import "../core/Ownable.sol"; import "../token/IERC677.sol"; import "../PrepaidCardManager.sol"; import "../TokenManager.sol"; -import "../IPrepaidCardMarket.sol"; +import "../PrepaidCardMarket.sol"; import "../VersionManager.sol"; contract SetPrepaidCardInventoryHandler is Ownable, Versionable { @@ -102,10 +102,7 @@ contract SetPrepaidCardInventoryHandler is Ownable, Versionable { ); return - IPrepaidCardMarket(marketAddress).setItem( - issuer, - prepaidCardForInventory - ); + PrepaidCardMarket(marketAddress).setItem(issuer, prepaidCardForInventory); } function cardpayVersion() external view returns (string memory) { From fd9ce78f81eb863d6aa4563149c626607e757c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Thu, 5 May 2022 14:30:18 +0200 Subject: [PATCH 02/19] Allow PrepaidCardManager to create cards with issuer when tokens are received --- contracts/PrepaidCardManager.sol | 89 +++++++++++++++---- .../SplitPrepaidCardHandler.sol | 4 +- test/ActionDispatcher-test.js | 1 + test/PrepaidCardManager-test.js | 9 ++ test/PrepaidCardMarket-test.js | 1 + test/RevenuePool-test.js | 1 + test/RewardManager-test.js | 1 + test/utils/helper.js | 23 ++++- test/utils/setup.js | 1 + 9 files changed, 107 insertions(+), 23 deletions(-) diff --git a/contracts/PrepaidCardManager.sol b/contracts/PrepaidCardManager.sol index 207e2fb1..9844d4a4 100644 --- a/contracts/PrepaidCardManager.sol +++ b/contracts/PrepaidCardManager.sol @@ -100,6 +100,8 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { EnumerableSetUpgradeable.AddressSet internal contractSigners; mapping(string => GasPolicyV2) public gasPoliciesV2; address public versionManager; + EnumerableSetUpgradeable.AddressSet + internal trustedCallersForCreatingPrepaidCardsWithIssuer; modifier onlyHandlers() { require( @@ -142,6 +144,7 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { uint256 _minAmount, uint256 _maxAmount, address[] calldata _contractSigners, + address[] calldata _trustedCallersForCreatingPrepaidCardsWithIssuer, address _versionManager ) external onlyOwner { require(_tokenManager != address(0), "tokenManager not set"); @@ -170,6 +173,15 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { contractSigners.add(_contractSigners[i]); } + for ( + uint256 i = 0; + i < _trustedCallersForCreatingPrepaidCardsWithIssuer.length; + i++ + ) { + trustedCallersForCreatingPrepaidCardsWithIssuer.add( + _trustedCallersForCreatingPrepaidCardsWithIssuer[i] + ); + } emit Setup(); } @@ -215,7 +227,7 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { * ) */ function onTokenTransfer( - address from, // solhint-disable-line no-unused-vars + address from, uint256 amount, bytes calldata data ) external returns (bool) { @@ -223,36 +235,73 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { TokenManager(tokenManager).isValidToken(msg.sender), "calling token is unaccepted" ); + ( address owner, uint256[] memory issuingTokenAmounts, uint256[] memory spendAmounts, string memory customizationDID, - address marketAddress - ) = abi.decode(data, (address, uint256[], uint256[], string, address)); + address marketAddress, + address issuer, + address issuerSafe + ) = abi.decode( + data, + (address, uint256[], uint256[], string, address, address, address) + ); + require( owner != address(0) && issuingTokenAmounts.length > 0, "Prepaid card data invalid" ); - require( - issuingTokenAmounts.length == spendAmounts.length, - "the amount arrays have differing lengths" - ); // The spend amounts are for reporting purposes only, there is no on-chain // effect from this value. Although, it might not be a bad idea that spend // amounts line up with the issuing token amounts--albiet we'd need to // introduce a rate lock mechanism if we wanted to validate this - createMultiplePrepaidCards( - owner, - from, - _msgSender(), - amount, - issuingTokenAmounts, - spendAmounts, - customizationDID, - marketAddress + require( + issuingTokenAmounts.length == spendAmounts.length, + "the amount arrays have differing lengths" ); + + // When issuer and issuerSafe are blank, it means the call is related to the + // process where a prepaid card is first created, using the provided issuer as + // the owner, and later provisioned and transfered to the customer (customer's + // EOA is the new owner). + + if ( + (issuer == address(0) && issuerSafe == address(0)) || + !trustedCallersForCreatingPrepaidCardsWithIssuer.contains(from) + ) { + createPrepaidCards( + owner, // issuer + owner, + from, // depot + _msgSender(), // token + amount, + issuingTokenAmounts, + spendAmounts, + customizationDID, + marketAddress + ); + } else { + // In case when issuer and issuerSafe are provided, it means the tokens are being + // sent from the PrepaidCardMarketV2 contract where the prepaid cards are being + // created and provisioned in a single step, where the issuer and owner (customer's EOA) + // are provided during the creation of the prepaid cards. + + createPrepaidCards( + issuer, + owner, + issuerSafe, // depot + _msgSender(), // token + amount, + issuingTokenAmounts, + spendAmounts, + customizationDID, + marketAddress + ); + } + return true; } @@ -523,7 +572,8 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { * @param spendAmounts array of spend amounts that represent the desired face value (for reporting only) * @param customizationDID the customization DID for the new prepaid cards */ - function createMultiplePrepaidCards( + function createPrepaidCards( + address issuer, address owner, address depot, address token, @@ -554,6 +604,7 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { ); for (uint256 i = 0; i < numberCard; i++) { createPrepaidCard( + issuer, owner, depot, token, @@ -574,6 +625,7 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { /** * @dev Create Prepaid card + * @param issuer issuer address * @param owner owner address * @param token token address * @param issuingTokenAmount amount of issuing token to use to fund the new prepaid card @@ -582,6 +634,7 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { * @return PrepaidCard address */ function createPrepaidCard( + address issuer, address owner, address depot, address token, @@ -598,7 +651,7 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { address card = createSafe(owners, 2); // card was created - cardDetails[card].issuer = owner; + cardDetails[card].issuer = issuer; cardDetails[card].issueToken = token; cardDetails[card].customizationDID = customizationDID; cardDetails[card].blockNumber = block.number; diff --git a/contracts/action-handlers/SplitPrepaidCardHandler.sol b/contracts/action-handlers/SplitPrepaidCardHandler.sol index 4fbb46d5..04294b58 100644 --- a/contracts/action-handlers/SplitPrepaidCardHandler.sol +++ b/contracts/action-handlers/SplitPrepaidCardHandler.sol @@ -126,7 +126,9 @@ contract SplitPrepaidCardHandler is Ownable, Versionable { issuingTokenAmounts, spendAmounts, customizationDID, - marketAddress == address(0) ? defaultMarketAddress : marketAddress + marketAddress == address(0) ? defaultMarketAddress : marketAddress, + address(0), // issuer (not used but necessary) + address(0) // issuer safe (not used but necessary) ) ); diff --git a/test/ActionDispatcher-test.js b/test/ActionDispatcher-test.js index 9ccff84e..adf3f242 100644 --- a/test/ActionDispatcher-test.js +++ b/test/ActionDispatcher-test.js @@ -102,6 +102,7 @@ contract("Action Dispatcher", (accounts) => { 100, 500000, [], + [], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); diff --git a/test/PrepaidCardManager-test.js b/test/PrepaidCardManager-test.js index 3705c675..3261b488 100644 --- a/test/PrepaidCardManager-test.js +++ b/test/PrepaidCardManager-test.js @@ -198,6 +198,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [contractSigner], + [], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); @@ -266,6 +267,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [contractSigner], + [], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); @@ -587,6 +589,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [], + [], versionManager.address ); }); @@ -610,6 +613,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [], + [], versionManager.address ); }); @@ -780,6 +784,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [], + [], versionManager.address ); @@ -862,6 +867,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [], + [], versionManager.address ); }); @@ -938,6 +944,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [contractSigner], + [], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); @@ -1193,6 +1200,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [contractSigner], + [], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); @@ -1376,6 +1384,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [contractSigner], + [], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); diff --git a/test/PrepaidCardMarket-test.js b/test/PrepaidCardMarket-test.js index 670f8aac..d9b08f28 100644 --- a/test/PrepaidCardMarket-test.js +++ b/test/PrepaidCardMarket-test.js @@ -120,6 +120,7 @@ contract("PrepaidCardMarket", (accounts) => { 100, 500000, [prepaidCardMarket.address], + [], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); diff --git a/test/RevenuePool-test.js b/test/RevenuePool-test.js index 3cb1034b..922c8333 100644 --- a/test/RevenuePool-test.js +++ b/test/RevenuePool-test.js @@ -133,6 +133,7 @@ contract("RevenuePool", (accounts) => { 100, 500000, [], + [], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); diff --git a/test/RewardManager-test.js b/test/RewardManager-test.js index 11fdaef7..25fd8df1 100644 --- a/test/RewardManager-test.js +++ b/test/RewardManager-test.js @@ -218,6 +218,7 @@ contract("RewardManager", (accounts) => { 100, 500000, [], + [], versionManager.address ); await revenuePool.setup( diff --git a/test/utils/helper.js b/test/utils/helper.js index 258d8b1d..16164d53 100644 --- a/test/utils/helper.js +++ b/test/utils/helper.js @@ -68,16 +68,28 @@ function encodeCreateCardsData( issuingTokenAmounts = [], spendAmounts = [], customizationDID = "", - marketAddress = ZERO_ADDRESS + marketAddress = ZERO_ADDRESS, + issuer = ZERO_ADDRESS, + issuerSafe = ZERO_ADDRESS ) { return AbiCoder.encodeParameters( - ["address", "uint256[]", "uint256[]", "string", "address"], + [ + "address", + "uint256[]", + "uint256[]", + "string", + "address", + "address", + "address", + ], [ account, issuingTokenAmounts, spendAmounts, customizationDID, marketAddress, + issuer, + issuerSafe, ] ); } @@ -716,7 +728,8 @@ const createPrepaidCards = async function ( issuingTokenAmounts, amountToSend, customizationDID, - marketAddress + marketAddress, + issuerSafe ) { let createCardData = encodeCreateCardsData( issuer, @@ -727,7 +740,9 @@ const createPrepaidCards = async function ( typeof amount === "string" ? amount : amount.toString() ), customizationDID, - marketAddress + marketAddress, + issuer, + issuerSafe || ZERO_ADDRESS ); if (amountToSend == null) { diff --git a/test/utils/setup.js b/test/utils/setup.js index 71346da4..e7c49178 100644 --- a/test/utils/setup.js +++ b/test/utils/setup.js @@ -118,6 +118,7 @@ const setupProtocol = async (accounts) => { 100, 500000, [], + [], versionManager.address ); await revenuePool.setup( From 407f6cda0c2f73d7eef371626c65800ab9e1e26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Thu, 5 May 2022 14:47:41 +0200 Subject: [PATCH 03/19] Add event ABIS for the new market contract --- test/utils/constant/eventABIs.js | 120 +++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/test/utils/constant/eventABIs.js b/test/utils/constant/eventABIs.js index 1b9c1b5f..a9c52bbd 100644 --- a/test/utils/constant/eventABIs.js +++ b/test/utils/constant/eventABIs.js @@ -284,6 +284,126 @@ const eventABIs = { }, ], }, + PREPAID_CARD_MARKET_V2_DEPOSIT_TOKENS: { + topic: web3EthAbi.encodeEventSignature( + "TokensDeposited(address,uint256,address,address)" + ), + abis: [ + { + type: "address", + name: "issuer", + }, + { + type: "uint256", + name: "amount", + }, + { + type: "address", + name: "token", + }, + { + type: "address", + name: "safe", + }, + ], + }, + PREPAID_CARD_MARKET_V2_TOKENS_WITHDRAWN: { + topic: web3EthAbi.encodeEventSignature( + "TokensWithdrawn(address,address,address,uint256)" + ), + abis: [ + { + type: "address", + name: "safe", + }, + { + type: "address", + name: "issuer", + }, + { + type: "address", + name: "token", + }, + { + type: "uint256", + name: "amount", + }, + ], + }, + PREPAID_CARD_MARKET_V2_SKU_ADDED: { + topic: web3EthAbi.encodeEventSignature( + "SkuAdded(address,address,uint256,string,bytes32)" + ), + abis: [ + { + type: "address", + name: "issuer", + }, + { + type: "address", + name: "token", + }, + { + type: "uint256", + name: "faceValue", + }, + { + type: "string", + name: "customizationDID", + }, + { + type: "bytes32", + name: "sku", + }, + ], + }, + PREPAID_CARD_MARKET_V2_ASK_SET: { + topic: web3EthAbi.encodeEventSignature( + "AskSet(address,address,bytes32,uint256)" + ), + abis: [ + { + type: "address", + name: "issuer", + }, + { + type: "address", + name: "issuingToken", + }, + { + type: "bytes32", + name: "sku", + }, + { + type: "uint256", + name: "askPrice", + }, + ], + }, + PREPAID_CARD_MARKET_V2_PAUSED_TOGGLED: { + topic: web3EthAbi.encodeEventSignature("PausedToggled(bool)"), + abis: [ + { + type: "bool", + name: "paused", + }, + ], + }, + PREPAID_CARD_MARKET_V2_PREPAID_CARD_PROVISIONED: { + topic: web3EthAbi.encodeEventSignature( + "PrepaidCardProvisioned(address,bytes32)" + ), + abis: [ + { + type: "address", + name: "owner", + }, + { + type: "bytes32", + name: "sku", + }, + ], + }, }; module.exports = eventABIs; From 41c2d7e8db7d91fa10f39f79d502808a258bb452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Thu, 5 May 2022 14:49:43 +0200 Subject: [PATCH 04/19] Add an action handler to add SKUs to the new contract --- .../AddPrepaidCardSKUHandler.sol | 95 ++++++++++++++++++ test/utils/helper.js | 96 +++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 contracts/action-handlers/AddPrepaidCardSKUHandler.sol diff --git a/contracts/action-handlers/AddPrepaidCardSKUHandler.sol b/contracts/action-handlers/AddPrepaidCardSKUHandler.sol new file mode 100644 index 00000000..c4691aab --- /dev/null +++ b/contracts/action-handlers/AddPrepaidCardSKUHandler.sol @@ -0,0 +1,95 @@ +pragma solidity ^0.8.9; +pragma abicoder v1; + +import "../core/Ownable.sol"; +import "../core/Versionable.sol"; +import "../token/IERC677.sol"; +import "../PrepaidCardManager.sol"; +import "../PrepaidCardMarketV2.sol"; +import "../TokenManager.sol"; +import "../IPrepaidCardMarket.sol"; +import "../VersionManager.sol"; + +contract AddPrepaidCardSKUHandler is Ownable, Versionable { + address public actionDispatcher; + address public prepaidCardManagerAddress; + address public tokenManagerAddress; + address public versionManager; + + event Setup(); + + function setup( + address _actionDispatcher, + address _prepaidCardManager, + address _tokenManagerAddress, + address _versionManager + ) external onlyOwner returns (bool) { + actionDispatcher = _actionDispatcher; + prepaidCardManagerAddress = _prepaidCardManager; + tokenManagerAddress = _tokenManagerAddress; + versionManager = _versionManager; + emit Setup(); + return true; + } + + /** + * @dev onTokenTransfer(ERC677) - this is the ERC677 token transfer callback. + * + * This handles adding the SKU to the market. + * + * See AddPrepaidCardSKUHandler in README for more information. + * + * @param from the token sender (should be the revenue pool) + * @param data encoded as: ( + * address prepaidCard, + * uint256 spendAmount, + * bytes actionData, encoded as: ( + * uint256 faceValue, + * string customizationDID, + * address tokenAddress, + * address marketAddress, + * address issuerSafe + * ) + * ) + */ + function onTokenTransfer( + address payable from, + uint256 amount, + bytes calldata data + ) external returns (bool) { + require( + TokenManager(tokenManagerAddress).isValidToken(msg.sender), + "calling token is unaccepted" + ); + require( + from == actionDispatcher, + "can only accept tokens from action dispatcher" + ); + + require(amount == 0, "amount must be 0"); + + (, , bytes memory actionData) = abi.decode(data, (address, uint256, bytes)); + + ( + uint256 faceValue, + string memory customizationDID, + address tokenAddress, + address marketAddress, + address issuerSafe + ) = abi.decode(actionData, (uint256, string, address, address, address)); + + PrepaidCardMarketV2 prepaidCardMarket = PrepaidCardMarketV2(marketAddress); + + return + prepaidCardMarket.addSKU( + issuerSafe, + faceValue, + customizationDID, + tokenAddress + ); + } + + function cardpayVersion() external view returns (string memory) { + return VersionManager(versionManager).version(); + } +} diff --git a/test/utils/helper.js b/test/utils/helper.js index 16164d53..f50d9ea6 100644 --- a/test/utils/helper.js +++ b/test/utils/helper.js @@ -20,6 +20,7 @@ const RemovePrepaidCardInventoryHandler = artifacts.require( "RemovePrepaidCardInventoryHandler" ); const SetPrepaidCardAskHandler = artifacts.require("SetPrepaidCardAskHandler"); +const AddPrepaidCardSKUHandler = artifacts.require("AddPrepaidCardSKUHandler"); const TransferPrepaidCardHandler = artifacts.require( "TransferPrepaidCardHandler" ); @@ -337,6 +338,7 @@ exports.addActionHandlers = async function ({ setPrepaidCardInventoryHandler, removePrepaidCardInventoryHandler, setPrepaidCardAskHandler, + addPrepaidCardSKUHandler, transferPrepaidCardHandler, registerRewardeeHandler, registerRewardProgramHandler, @@ -442,6 +444,14 @@ exports.addActionHandlers = async function ({ tokenManager.address, versionManagerAddress ); + addPrepaidCardSKUHandler = await AddPrepaidCardSKUHandler.new(); + await addPrepaidCardSKUHandler.initialize(owner); + await addPrepaidCardSKUHandler.setup( + actionDispatcher.address, + prepaidCardManager.address, + tokenManager.address, + versionManagerAddress + ); } if (owner && actionDispatcher && prepaidCardManager && tokenManager) { @@ -606,6 +616,13 @@ exports.addActionHandlers = async function ({ ); } + if (addPrepaidCardSKUHandler) { + await actionDispatcher.addHandler( + addPrepaidCardSKUHandler.address, + "addPrepaidCardSKU" + ); + } + if (transferPrepaidCardHandler) { await actionDispatcher.addHandler( transferPrepaidCardHandler.address, @@ -668,6 +685,7 @@ exports.addActionHandlers = async function ({ setPrepaidCardInventoryHandler, removePrepaidCardInventoryHandler, setPrepaidCardAskHandler, + addPrepaidCardSKUHandler, transferPrepaidCardHandler, registerRewardeeHandler, registerRewardProgramHandler, @@ -1055,6 +1073,84 @@ exports.removePrepaidCardInventory = async function ( { from: relayer } ); }; + +exports.addPrepaidCardSKU = async function ( + prepaidCardManager, + fundingPrepaidCard, + faceValue, + customizationDID, + tokenAddress, + prepaidCardMarket, + issuer, + relayer, + gasPrice, + usdRate, + issuerSafe +) { + if (usdRate == null) { + usdRate = 100000000; + } + if (gasPrice == null) { + gasPrice = DEFAULT_GAS_PRICE; + } + let issuingToken = await getIssuingToken( + prepaidCardManager, + fundingPrepaidCard + ); + + let marketAddress = + typeof prepaidCardMarket === "string" + ? prepaidCardMarket + : prepaidCardMarket.address; + + let payload = AbiCoder.encodeParameters( + ["uint256", "string", "address", "address", "address"], + [ + faceValue, + customizationDID, + tokenAddress, + marketAddress, + issuerSafe.address, + ] + ); + + let data = await prepaidCardManager.getSendData( + fundingPrepaidCard.address, + 0, + usdRate, + "addPrepaidCardSKU", + payload + ); + + let signature = await signSafeTransaction( + issuingToken.address, + 0, + data, + CALL, + BLOCK_GAS_LIMIT, + 0, + gasPrice, + issuingToken.address, + ZERO_ADDRESS, + await fundingPrepaidCard.nonce(), + issuer, + fundingPrepaidCard + ); + + return await prepaidCardManager.send( + fundingPrepaidCard.address, + 0, + usdRate, + gasPrice, + BLOCK_GAS_LIMIT, + 0, + "addPrepaidCardSKU", + payload, + signature, + { from: relayer } + ); +}; + exports.setPrepaidCardAsk = async function ( prepaidCardManager, fundingPrepaidCard, From 125908604fde2b3769d5e59fcb99f83a12f5caf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Thu, 5 May 2022 14:50:38 +0200 Subject: [PATCH 05/19] Add a new market contract for on demand card provisioning --- contracts/PrepaidCardMarketV2.sol | 359 ++++++++++++++ test/PrepaidCardMarketV2-test.js | 754 ++++++++++++++++++++++++++++++ 2 files changed, 1113 insertions(+) create mode 100644 contracts/PrepaidCardMarketV2.sol create mode 100644 test/PrepaidCardMarketV2-test.js diff --git a/contracts/PrepaidCardMarketV2.sol b/contracts/PrepaidCardMarketV2.sol new file mode 100644 index 00000000..698a21fe --- /dev/null +++ b/contracts/PrepaidCardMarketV2.sol @@ -0,0 +1,359 @@ +pragma solidity ^0.8.9; +pragma abicoder v1; + +import "./core/Ownable.sol"; + +import "@openzeppelin/contracts-upgradeable/utils/structs/EnumerableSetUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import "./core/Versionable.sol"; +import "./IPrepaidCardMarket.sol"; +import "./PrepaidCardManager.sol"; +import "./ActionDispatcher.sol"; +import "./VersionManager.sol"; +import "./TokenManager.sol"; +import "./IPrepaidCardMarket.sol"; +import "./libraries/SafeERC677.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +contract PrepaidCardMarketV2 is + Ownable, + Versionable, + ReentrancyGuardUpgradeable, + IPrepaidCardMarket +{ + using EnumerableSetUpgradeable for EnumerableSetUpgradeable.AddressSet; + using SafeERC20Upgradeable for IERC677; + using SafeERC677 for IERC677; + + struct SKU { + address issuerSafe; + address issuer; + address issuingToken; + uint256 faceValue; + string customizationDID; + } + + address public prepaidCardManagerAddress; + address public tokenManager; + address public provisioner; + address public actionDispatcher; + address public versionManager; + mapping(address => mapping(address => uint256)) public balance; // issuer safe address -> token -> balance + mapping(address => address) public issuers; // issuer safe address -> issuer EOA + mapping(bytes32 => SKU) public skus; // sku => sku data + mapping(bytes32 => uint256) public asks; // sku => ask price (in issuing token) + + bool public paused; + + EnumerableSetUpgradeable.AddressSet internal trustedProvisioners; + + event Setup(); + + event TokensDeposited( + address issuer, + uint256 amount, + address token, + address safe + ); + event TokensWithdrawn( + address safe, + address issuer, + address token, + uint256 amount + ); + event SkuAdded( + address issuer, + address issuingToken, + uint256 faceValue, + string customizationDID, + bytes32 sku + ); + event AskSet( + address issuer, + address issuingToken, + bytes32 sku, + uint256 askPrice + ); + event PrepaidCardProvisioned(address owner, bytes32 sku); + event TrustedProvisionerAdded(address token); + event TrustedProvisionerRemoved(address token); + event PausedToggled(bool paused); + + modifier onlyHandlers() { + require( + ActionDispatcher(actionDispatcher).isHandler(msg.sender), + "caller is not a registered action handler" + ); + _; + } + + function initialize(address owner) public override initializer { + paused = false; + OwnableInitialize(owner); + } + + function setup( + address _prepaidCardManagerAddress, + address _provisioner, + address _tokenManager, + address _actionDispatcher, + address[] calldata _trustedProvisioners, + address _versionManager + ) external onlyOwner { + require( + _prepaidCardManagerAddress != address(0), + "prepaidCardManagerAddress not set" + ); + require(_provisioner != address(0), "provisioner not set"); + require(_tokenManager != address(0), "tokenManager not set"); + require(_actionDispatcher != address(0), "actionDispatcher not set"); + require(_versionManager != address(0), "versionManager not set"); + + prepaidCardManagerAddress = _prepaidCardManagerAddress; + provisioner = _provisioner; + tokenManager = _tokenManager; + actionDispatcher = _actionDispatcher; + versionManager = _versionManager; + + for (uint256 i = 0; i < _trustedProvisioners.length; i++) { + _addTrustedProvisioner(_trustedProvisioners[i]); + } + + emit Setup(); + } + + function setPaused(bool _paused) external onlyOwner { + paused = _paused; + emit PausedToggled(_paused); + } + + function getTrustedProvisioners() external view returns (address[] memory) { + return trustedProvisioners.values(); + } + + function _addTrustedProvisioner(address provisionerAddress) + internal + onlyOwner + { + trustedProvisioners.add(provisionerAddress); + emit TrustedProvisionerAdded(provisionerAddress); + } + + function removeTrustedProvisioner(address provisionerAddress) + external + onlyOwner + { + trustedProvisioners.remove(provisionerAddress); + emit TrustedProvisionerRemoved(provisionerAddress); + } + + function provisionPrepaidCard(address customer, bytes32 sku) + external + nonReentrant + returns (bool) + { + require(!paused, "Contract is paused"); + require( + trustedProvisioners.contains(msg.sender), + "Only trusted provisioners allowed" + ); + require(asks[sku] > 0, "Can't provision SKU with 0 askPrice"); + + PrepaidCardManager prepaidCardManager = PrepaidCardManager( + prepaidCardManagerAddress + ); + + address token = skus[sku].issuingToken; + uint256 faceValue = skus[sku].faceValue; + string memory customizationDID = skus[sku].customizationDID; + uint256 priceToCreatePrepaidCard = prepaidCardManager.priceForFaceValue( + token, + faceValue + ); // in Wei + + address issuer = skus[sku].issuer; + address issuerSafe = skus[sku].issuerSafe; + + require( + balance[issuerSafe][token] >= priceToCreatePrepaidCard, + "Not enough balance" + ); + + uint256[] memory issuingTokenAmounts = new uint256[](1); + uint256[] memory spendAmounts = new uint256[](1); + + issuingTokenAmounts[0] = priceToCreatePrepaidCard; + spendAmounts[0] = faceValue; + + balance[issuerSafe][token] -= priceToCreatePrepaidCard; + + IERC677(token).safeTransferAndCall( + prepaidCardManagerAddress, + priceToCreatePrepaidCard, + abi.encode( + customer, + issuingTokenAmounts, + spendAmounts, + customizationDID, + address(0), // marketAddress - we don't need it in this case + issuer, + issuerSafe + ) + ); + + emit PrepaidCardProvisioned(customer, sku); + + return true; + } + + function getQuantity(bytes32 sku) public view returns (uint256) { + PrepaidCardManager prepaidCardManager = PrepaidCardManager( + prepaidCardManagerAddress + ); + + address token = skus[sku].issuingToken; + uint256 faceValue = skus[sku].faceValue; + address issuerSafe = skus[sku].issuerSafe; + + uint256 price = prepaidCardManager.priceForFaceValue(token, faceValue); + + return balance[issuerSafe][token] / price; + } + + function setAsk( + address issuer, + bytes32 sku, + uint256 askPrice + ) external onlyHandlers returns (bool) { + require(skus[sku].issuer != address(0), "Non-existent SKU"); + require(skus[sku].issuer == issuer, "SKU not owned by issuer"); + + asks[sku] = askPrice; + + emit AskSet(issuer, skus[sku].issuingToken, sku, askPrice); + return true; + } + + function addSKU( + address issuerSafe, + uint256 faceValue, + string memory customizationDID, + address token + ) external onlyHandlers returns (bool) { + require(faceValue > 0, "Face value must be greater than 0"); + require(token != address(0), "Token address must be set"); + + address _issuer = issuers[issuerSafe]; + require(_issuer != address(0), "Issuer has no balance"); + + bytes32 sku = getSKU(_issuer, token, faceValue, customizationDID); + + require(skus[sku].issuer == address(0), "SKU already exists"); + + skus[sku] = SKU({ + issuerSafe: issuerSafe, + issuer: _issuer, + issuingToken: token, + faceValue: faceValue, + customizationDID: customizationDID + }); + + emit SkuAdded(_issuer, token, faceValue, customizationDID, sku); + + return true; + } + + function getSKU( + address issuer, + address token, + uint256 faceValue, + string memory customizationDID + ) public pure override returns (bytes32) { + return + keccak256(abi.encodePacked(issuer, token, faceValue, customizationDID)); + } + + function withdrawTokens(uint256 amount, address token) external { + address _issuer = issuers[msg.sender]; + require(_issuer != address(0), "Issuer not found"); + + uint256 balanceForToken = balance[msg.sender][token]; + + require(amount <= balanceForToken, "Insufficient funds for withdrawal"); + + balance[msg.sender][token] -= amount; + + IERC677(token).safeTransfer(msg.sender, amount); + + emit TokensWithdrawn(msg.sender, _issuer, token, amount); + } + + function getSkuInfo(bytes32 sku) + external + view + returns ( + address issuer, + address issuingToken, + uint256 faceValue, + string memory customizationDID + ) + { + issuer = skus[sku].issuer; + issuingToken = skus[sku].issuingToken; + faceValue = skus[sku].faceValue; + customizationDID = skus[sku].customizationDID; + } + + /** + * @dev onTokenTransfer(ERC677) - this is the ERC677 token transfer callback. + * + * When tokens are sent to this contract, this function will set the balance + * for the issuer, which will be used to fund and provision prepaid cards. + * + * @param from issuer's safe address + * @param amount number of tokens sent + * @param data encoded as ( + * address issuer (issuer's address) + * ) + */ + function onTokenTransfer( + address payable from, // safe address + uint256 amount, + bytes calldata data + ) external returns (bool) { + // Only CARD.CPXD, DAI.CPXD are accepted + require( + TokenManager(tokenManager).isValidToken(msg.sender), + "token is unaccepted" + ); + + address _issuer = abi.decode(data, (address)); + + require(_issuer != address(0), "issuer should be provided"); + + address[] memory _owners = GnosisSafe(from).getOwners(); + bool _foundOwner = false; + + // Caution: block gas limit could be too high for big arrays + require(_owners.length < 100, "too many safe owners"); + + for (uint256 i = 0; i < _owners.length; i++) { + if (_owners[i] == _issuer) { + _foundOwner = true; + break; + } + } + + require(_foundOwner, "issuer is not one of the safe owners"); + + balance[from][msg.sender] = balance[from][msg.sender] + amount; + issuers[from] = _issuer; + emit TokensDeposited(_issuer, amount, msg.sender, from); + + return true; + } + + function cardpayVersion() external view returns (string memory) { + return VersionManager(versionManager).version(); + } +} diff --git a/test/PrepaidCardMarketV2-test.js b/test/PrepaidCardMarketV2-test.js new file mode 100644 index 00000000..8f7fb1f4 --- /dev/null +++ b/test/PrepaidCardMarketV2-test.js @@ -0,0 +1,754 @@ +const PrepaidCardManager = artifacts.require("PrepaidCardManager"); +const PrepaidCardMarketV2 = artifacts.require("PrepaidCardMarketV2"); +const ProxyFactory = artifacts.require("GnosisSafeProxyFactory"); +const GnosisSafe = artifacts.require("GnosisSafe"); +const ActionDispatcher = artifacts.require("ActionDispatcher"); +const TokenManager = artifacts.require("TokenManager"); +const SupplierManager = artifacts.require("SupplierManager"); +const utils = require("./utils/general"); +const eventABIs = require("./utils/constant/eventABIs"); +const BridgeUtils = artifacts.require("BridgeUtils"); +const { ZERO_ADDRESS, getParamsFromEvent } = utils; +const { expect } = require("./setup"); + +const { + toTokenUnit, + setupExchanges, + createDepotFromSupplierMgr, + setupVersionManager, + signAndSendSafeTransaction, + createPrepaidCards, + addPrepaidCardSKU, + addActionHandlers, + setPrepaidCardAsk, + transferOwner, +} = require("./utils/helper"); + +const AbiCoder = require("web3-eth-abi"); +const { toWei } = require("web3-utils"); + +contract("PrepaidCardMarketV2", (accounts) => { + let daicpxdToken, + cardcpxdToken, + issuer, + owner, + relayer, + actionDispatcher, + proxyFactory, + gnosisSafeMasterCopy, + provisioner, + customer, + exchange, + prepaidCardManager, + prepaidCardMarket, + prepaidCardMarketV2, + versionManager, + depot, + depositTokens, + withdrawTokens, + addPrepaidCardSKUHandler; + + async function makePrepaidCards(amounts, marketAddress, issuerSafe) { + let { prepaidCards } = await createPrepaidCards( + depot, + prepaidCardManager, + daicpxdToken, + issuer, + relayer, + amounts, + null, + "did:cardstack:test", + marketAddress, + issuerSafe + ); + return prepaidCards; + } + + beforeEach(async () => { + owner = accounts[0]; + issuer = accounts[1]; + relayer = accounts[4]; + provisioner = accounts[5]; + prepaidCardMarket = accounts[6]; + customer = accounts[7]; + + proxyFactory = await ProxyFactory.new(); + gnosisSafeMasterCopy = await utils.deployContract( + "deploying Gnosis Safe Mastercopy", + GnosisSafe + ); + + versionManager = await setupVersionManager(owner); + prepaidCardManager = await PrepaidCardManager.new(); + await prepaidCardManager.initialize(owner); + prepaidCardMarketV2 = await PrepaidCardMarketV2.new(); + await prepaidCardMarketV2.initialize(owner); + let supplierManager = await SupplierManager.new(); + await supplierManager.initialize(owner); + actionDispatcher = await ActionDispatcher.new(); + await actionDispatcher.initialize(owner); + let bridgeUtils = await BridgeUtils.new(); + await bridgeUtils.initialize(owner); + + let tokenManager = await TokenManager.new(); + await tokenManager.initialize(owner); + + ({ daicpxdToken, cardcpxdToken, exchange } = await setupExchanges(owner)); + + await daicpxdToken.mint(owner, toTokenUnit(100)); + + await tokenManager.setup( + ZERO_ADDRESS, + [daicpxdToken.address, cardcpxdToken.address], + versionManager.address + ); + + await supplierManager.setup( + bridgeUtils.address, + gnosisSafeMasterCopy.address, + proxyFactory.address, + versionManager.address + ); + await prepaidCardManager.setup( + tokenManager.address, + supplierManager.address, + exchange.address, + gnosisSafeMasterCopy.address, + proxyFactory.address, + actionDispatcher.address, + ZERO_ADDRESS, + 0, + 100, + 500000, + [prepaidCardMarket], + [prepaidCardMarketV2.address], + versionManager.address + ); + await prepaidCardManager.addGasPolicy("addPrepaidCardSKU", true); + await prepaidCardManager.addGasPolicy("setPrepaidCardAsk", true); + + await actionDispatcher.setup( + tokenManager.address, + exchange.address, + prepaidCardManager.address, + versionManager.address + ); + + await prepaidCardMarketV2.setup( + prepaidCardManager.address, + provisioner, + tokenManager.address, + actionDispatcher.address, + [relayer], // trusted provisioners + versionManager.address + ); + + ({ addPrepaidCardSKUHandler } = await addActionHandlers({ + prepaidCardManager, + prepaidCardMarketV2, + actionDispatcher, + tokenManager, + owner, + versionManager, + })); + + depot = await createDepotFromSupplierMgr(supplierManager, issuer); + await daicpxdToken.mint(depot.address, toTokenUnit(1000)); + + await cardcpxdToken.mint(depot.address, toTokenUnit(1000)); + + depositTokens = async (amount) => { + let transferAndCall = daicpxdToken.contract.methods.transferAndCall( + prepaidCardMarketV2.address, + amount, + AbiCoder.encodeParameters(["address"], [issuer]) + ); + + let payload = transferAndCall.encodeABI(); + let gasEstimate = await transferAndCall.estimateGas({ + from: depot.address, + }); + + let safeTxData = { + to: daicpxdToken.address, + data: payload, + txGasEstimate: gasEstimate, + gasPrice: 1000000000, + txGasToken: daicpxdToken.address, + refundReceiver: relayer, + }; + + return await signAndSendSafeTransaction( + safeTxData, + issuer, + depot, + relayer + ); + }; + + withdrawTokens = async (amount) => { + let withdrawTokens = prepaidCardMarketV2.contract.methods.withdrawTokens( + amount, + daicpxdToken.address + ); + + let payload = withdrawTokens.encodeABI(); + let gasEstimate = await withdrawTokens.estimateGas({ + from: depot.address, + }); + let safeTxData = { + to: prepaidCardMarketV2.address, + data: payload, + txGasEstimate: gasEstimate, + gasPrice: 1000000000, + txGasToken: daicpxdToken.address, + refundReceiver: relayer, + }; + + return await signAndSendSafeTransaction( + safeTxData, + issuer, + depot, + relayer + ); + }; + }); + + describe("setup", () => { + it("should set trusted provisioners", async () => { + await prepaidCardMarketV2.setup( + prepaidCardManager.address, + provisioner, + ( + await TokenManager.new() + ).address, + actionDispatcher.address, + [relayer], + versionManager.address + ); + expect( + await prepaidCardMarketV2.getTrustedProvisioners() + ).to.have.members([relayer]); + }); + }); + + describe("removing trusted provisioners", () => { + it("can remove trusted provisioners", async () => { + await prepaidCardMarketV2.setup( + prepaidCardManager.address, + provisioner, + ( + await TokenManager.new() + ).address, + actionDispatcher.address, + [relayer], + versionManager.address + ); + expect( + await prepaidCardMarketV2.getTrustedProvisioners() + ).to.have.members([relayer]); + + await prepaidCardMarketV2.removeTrustedProvisioner(relayer); + + expect(await prepaidCardMarketV2.getTrustedProvisioners()).to.be.empty; + }); + + it("rejects when non-owner tries to remove a trusted provisioner", async () => { + await prepaidCardMarketV2 + .removeTrustedProvisioner(relayer, { from: issuer }) // from is just something else than the owner + .should.be.rejectedWith(Error, "caller is not the owner"); + }); + }); + + describe("manage balance", () => { + describe("send tokens", () => { + it(`can send tokens to the balance`, async function () { + let { + safeTx, + executionResult: { success }, + } = await depositTokens(toTokenUnit(5)); + + expect(success).to.be.true; + + expect( + await prepaidCardMarketV2.balance(depot.address, daicpxdToken.address) + ).to.be.bignumber.equal(toTokenUnit(5)); + + expect(await prepaidCardMarketV2.issuers(depot.address)).to.be.equal( + issuer + ); + + let [event] = getParamsFromEvent( + safeTx, + eventABIs.PREPAID_CARD_MARKET_V2_DEPOSIT_TOKENS, + prepaidCardMarketV2.address + ); + + expect(event.issuer).to.be.equal(issuer); + expect(event.token).to.be.equal(daicpxdToken.address); + expect(event.amount).to.be.equal(toWei("5")); + expect(event.safe).to.be.equal(depot.address); + }); + }); + + describe("withdraw tokens", () => { + it("can withdraw tokens", async function () { + await depositTokens(toTokenUnit(5)); + + let { + safeTx, + executionResult: { success }, + } = await withdrawTokens(toTokenUnit(4)); + + expect(success).to.be.true; + expect( + await prepaidCardMarketV2.balance(depot.address, daicpxdToken.address) + ).to.be.bignumber.equal(toTokenUnit(1)); // We started with 5 and we withdrew 4 + + let [event] = getParamsFromEvent( + safeTx, + eventABIs.PREPAID_CARD_MARKET_V2_TOKENS_WITHDRAWN, + prepaidCardMarketV2.address + ); + + expect(event.issuer).to.be.equal(issuer); + expect(event.token).to.be.equal(daicpxdToken.address); + expect(event.amount).to.be.equal(toWei("4")); + expect(event.safe).to.be.equal(depot.address); + }); + + it("fails when there is no issuer", async function () { + // The failure happens when we want to withdraw when no deposit has been made yet + let withdrawTokens = + prepaidCardMarketV2.contract.methods.withdrawTokens( + toTokenUnit(5), + daicpxdToken.address + ); + + // We're using call (https://web3js.readthedocs.io/en/v1.2.4/web3-eth-contract.html#methods-mymethod-call) + // which is simulating a gnosis safe transaction - it doesn't change the state. + // If we actually use the real gnosis safe transactions + // we can't see the rejection reason (problem described here: https://ethereum.stackexchange.com/questions/83528/how-can-i-get-the-revert-reason-of-a-call-in-solidity-so-that-i-can-use-it-in-th) + // so we resort to the call and expect it to fail. + await expect( + withdrawTokens.call({ + from: depot.address, + }) + ).to.be.rejectedWith("Issuer not found"); + }); + + it("fails when there is no funds", async function () { + // First send some tokens, then withdraw all, and try to do another withdraw + let { + executionResult: { success }, + } = await depositTokens(toTokenUnit(5)); + + expect(success).to.be.true; + + // Withdraw all + await withdrawTokens(toTokenUnit(5)); + + // Try to withdraw again + // Simulate a gnosis safe transaction - more details in the other comment regarding `call` + withdrawTokens = prepaidCardMarketV2.contract.methods.withdrawTokens( + toTokenUnit(5), + daicpxdToken.address + ); + + await expect( + withdrawTokens.call({ + from: depot.address, + }) + ).to.be.rejectedWith("Insufficient funds for withdrawal"); + }); + }); + + describe("SKUs", () => { + it("can add a SKU", async function () { + await depositTokens(toTokenUnit(1)); + + let [fundingPrepaidCard] = await makePrepaidCards( + [toTokenUnit(1)], + ZERO_ADDRESS, + depot.address + ); + + await cardcpxdToken.mint(fundingPrepaidCard.address, toTokenUnit(1)); + + let startingFundingCardBalance = await daicpxdToken.balanceOf( + fundingPrepaidCard.address + ); + + let safeTx = await addPrepaidCardSKU( + prepaidCardManager, + fundingPrepaidCard, + "1000", + "did:cardstack:test", + daicpxdToken.address, + prepaidCardMarketV2, + issuer, + relayer, + null, + null, + depot + ); + + let [event] = getParamsFromEvent( + safeTx, + eventABIs.PREPAID_CARD_MARKET_V2_SKU_ADDED, + prepaidCardMarketV2.address + ); + let [safeEvent] = getParamsFromEvent( + safeTx, + eventABIs.EXECUTION_SUCCESS, + fundingPrepaidCard.address + ); + + expect(event.issuer).to.be.equal(issuer); + expect(event.token).to.be.equal(daicpxdToken.address); + expect(event.faceValue).to.be.equal("1000"); + expect(event.customizationDID).to.be.equal("did:cardstack:test"); + + let endingFundingCardBalance = await daicpxdToken.balanceOf( + fundingPrepaidCard.address + ); + expect(parseInt(safeEvent.payment)).to.be.greaterThan(0); + expect( + startingFundingCardBalance.sub(endingFundingCardBalance).toString() + ).to.equal(safeEvent.payment, "prepaid card paid actual cost of gas"); + }); + + it("can't add a SKU when issuer has no balance", async function () { + let [fundingPrepaidCard] = await makePrepaidCards( + [toTokenUnit(1)], + ZERO_ADDRESS, + depot.address + ); + + await addPrepaidCardSKU( + prepaidCardManager, + fundingPrepaidCard, + "1000", + "did:cardstack:test", + daicpxdToken.address, + prepaidCardMarketV2, + issuer, + relayer, + null, + null, + depot + ).should.be.rejectedWith( + Error, + // the real revert reason ("Issuer has no balance") is behind the + // gnosis safe execTransaction boundary, so we just get this generic error + "safe transaction was reverted" + ); + }); + }); + + describe("getQuantity", () => { + it("can get the quantity of a SKU", async function () { + await depositTokens(toTokenUnit(500)); // 500 daicpxd = 500 USD + let [fundingPrepaidCard] = await makePrepaidCards( + [toTokenUnit(1)], + ZERO_ADDRESS, + depot.address + ); + + let safeTx = await addPrepaidCardSKU( + prepaidCardManager, + fundingPrepaidCard, + "1000", + "did:cardstack:test", + daicpxdToken.address, + prepaidCardMarketV2, + issuer, + relayer, + null, + null, + depot + ); + + let [event] = getParamsFromEvent( + safeTx, + eventABIs.PREPAID_CARD_MARKET_V2_SKU_ADDED, + prepaidCardMarketV2.address + ); + + let quantity = await prepaidCardMarketV2.getQuantity(event.sku); + expect(quantity).to.be.bignumber.eq("49"); + }); + }); + + describe("Asks", () => { + it("can set the asking price for a sku", async function () { + await depositTokens(toTokenUnit(1)); + + let [fundingPrepaidCard] = await makePrepaidCards( + [toTokenUnit(10)], + ZERO_ADDRESS, + depot.address + ); + + await cardcpxdToken.mint(fundingPrepaidCard.address, toTokenUnit(1)); + + let addSKUTx = await addPrepaidCardSKU( + prepaidCardManager, + fundingPrepaidCard, + "1000", + "did:cardstack:test", + daicpxdToken.address, + prepaidCardMarketV2, + issuer, + relayer, + null, + null, + depot + ); + + let [skuAddedEvent] = getParamsFromEvent( + addSKUTx, + eventABIs.PREPAID_CARD_MARKET_V2_SKU_ADDED, + prepaidCardMarketV2.address + ); + + let startingFundingCardBalance = await daicpxdToken.balanceOf( + fundingPrepaidCard.address + ); + + let safeTx = await setPrepaidCardAsk( + prepaidCardManager, + fundingPrepaidCard, + 10, + skuAddedEvent.sku, + prepaidCardMarketV2, + issuer, + relayer + ); + + let [askSetEvent] = getParamsFromEvent( + safeTx, + eventABIs.PREPAID_CARD_MARKET_V2_ASK_SET, + prepaidCardMarketV2.address + ); + + let [safeEvent] = getParamsFromEvent( + safeTx, + eventABIs.EXECUTION_SUCCESS, + fundingPrepaidCard.address + ); + + expect(askSetEvent.issuer).to.be.equal(issuer); + expect(askSetEvent.issuingToken).to.be.equal(daicpxdToken.address); + expect(askSetEvent.sku).to.be.equal(skuAddedEvent.sku); + expect(askSetEvent.askPrice).to.be.equal("10"); + + expect( + (await prepaidCardMarketV2.asks(skuAddedEvent.sku)).toString() + ).to.equal("10"); + + let endingFundingCardBalance = await daicpxdToken.balanceOf( + fundingPrepaidCard.address + ); + expect(parseInt(safeEvent.payment)).to.be.greaterThan(0); + expect( + startingFundingCardBalance.sub(endingFundingCardBalance).toString() + ).to.equal(safeEvent.payment, "prepaid card paid actual cost of gas"); + }); + + it("should reject when when the sku is not owned by issuer", async function () { + await depositTokens(toTokenUnit(1)); + let [customerCard] = await makePrepaidCards([toTokenUnit(10)]); + + let addSKUTx = await addPrepaidCardSKU( + prepaidCardManager, + customerCard, + "1000", + "did:cardstack:test", + daicpxdToken.address, + prepaidCardMarketV2, + issuer, + relayer, + null, + null, + depot + ); + + let [skuAddedEvent] = getParamsFromEvent( + addSKUTx, + eventABIs.PREPAID_CARD_MARKET_V2_SKU_ADDED, + prepaidCardMarketV2.address + ); + + await transferOwner( + prepaidCardManager, + customerCard, + issuer, + customer, + relayer + ); + + await setPrepaidCardAsk( + prepaidCardManager, + customerCard, + 10, + skuAddedEvent.sku, + prepaidCardMarket, + customer, + relayer + ).should.be.rejectedWith( + Error, + // the real revert reason is behind the gnosis safe execTransaction + // boundary, so we just get this generic error + "safe transaction was reverted" + ); + }); + }); + }); + + describe("Create prepaid card", () => { + let skuAddEvent; + + beforeEach(async function () { + await depositTokens(toTokenUnit(100)); + + let [fundingPrepaidCard] = await makePrepaidCards( + [toTokenUnit(10)], + ZERO_ADDRESS, + depot.address + ); + + await cardcpxdToken.mint(fundingPrepaidCard.address, toTokenUnit(1)); + + let addSKUTx = await addPrepaidCardSKU( + prepaidCardManager, + fundingPrepaidCard, + "5000", + "did:cardstack:test", + daicpxdToken.address, + prepaidCardMarketV2, + issuer, + relayer, + null, + null, + depot + ); + + [skuAddEvent] = getParamsFromEvent( + addSKUTx, + eventABIs.PREPAID_CARD_MARKET_V2_SKU_ADDED, + prepaidCardMarketV2.address + ); + + await setPrepaidCardAsk( + prepaidCardManager, + fundingPrepaidCard, + 10, + skuAddEvent.sku, + prepaidCardMarketV2, + issuer, + relayer + ); + }); + + it("can provision a prepaid card", async function () { + // relay server will call this function + let tx = await prepaidCardMarketV2.provisionPrepaidCard( + customer, + skuAddEvent.sku, + { + from: relayer, + } + ); + + let [createPrepaidCardEvent] = getParamsFromEvent( + tx, + eventABIs.CREATE_PREPAID_CARD, + prepaidCardManager.address + ); + + let [provisionPrepaidCardEvent] = getParamsFromEvent( + tx, + eventABIs.PREPAID_CARD_MARKET_V2_PREPAID_CARD_PROVISIONED, + prepaidCardMarketV2.address + ); + + let balance = await prepaidCardMarketV2.balance( + depot.address, + daicpxdToken.address + ); + + // 5000 spend tokens = 50 xdai + // balance should be toTokenUnits(100 - 50) - 100 (100 is a constant fee added in priceForFaceValue) + expect(balance).to.be.bignumber.eq("49999999999999999900"); + + expect( + (await daicpxdToken.balanceOf(createPrepaidCardEvent.card)).toString() + ).to.equal("50000000000000000100"); + + expect( + await prepaidCardManager.getPrepaidCardOwner( + createPrepaidCardEvent.card + ) + ).to.equal(customer); + + expect(provisionPrepaidCardEvent.owner).to.eq(customer); + expect(provisionPrepaidCardEvent.sku).to.eq(skuAddEvent.sku); + }); + + it("can't provision a prepaid card when there is not enough funds", async function () { + await withdrawTokens(toTokenUnit(100)); + + await expect( + prepaidCardMarketV2.contract.methods + .provisionPrepaidCard(customer, skuAddEvent.sku) + .call({ + from: relayer, + }) + ).to.be.rejectedWith("Not enough balance"); + }); + + it(`rejects when contract is paused`, async function () { + let tx = await prepaidCardMarketV2.setPaused(true); + let [pauseToggledEvent] = getParamsFromEvent( + tx, + eventABIs.PREPAID_CARD_MARKET_V2_PAUSED_TOGGLED, + prepaidCardMarketV2.address + ); + expect(pauseToggledEvent.paused).to.be.true; + + await prepaidCardMarketV2 + .provisionPrepaidCard(customer, skuAddEvent.sku, { + from: relayer, + }) + .should.be.rejectedWith(Error, "Contract is paused"); + }); + + it("can provision a prepaid card when unpaused", async function () { + await prepaidCardMarketV2.setPaused(true); + await prepaidCardMarketV2.setPaused(false); + let tx = await prepaidCardMarketV2.provisionPrepaidCard( + customer, + skuAddEvent.sku, + { + from: relayer, + } + ); + + let [createPrepaidCardEvent] = getParamsFromEvent( + tx, + eventABIs.CREATE_PREPAID_CARD, + prepaidCardManager.address + ); + + expect(createPrepaidCardEvent.card).to.be.ok; + }); + }); + + describe("versioning", () => { + it("can get version of contract", async () => { + expect(await prepaidCardMarketV2.cardpayVersion()).to.equal("1.0.0"); + expect(await addPrepaidCardSKUHandler.cardpayVersion()).to.equal("1.0.0"); + }); + }); +}); From 4c4925181032134913090b5ea2ce58aeb73586a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Thu, 5 May 2022 14:50:51 +0200 Subject: [PATCH 06/19] Tidy up params --- test/utils/general.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/utils/general.js b/test/utils/general.js index 79c55756..c36a2a0f 100644 --- a/test/utils/general.js +++ b/test/utils/general.js @@ -65,10 +65,10 @@ async function signSafeTransaction( value, data, operation, - txGasEstimate, - baseGasEstimate, + safeTxGas, + baseGas, gasPrice, - txGasToken, + gasToken, refundReceiver, nonce, owner, @@ -137,15 +137,15 @@ async function signSafeTransaction( }, primaryType: "SafeTx", message: { - to: to, - value: value, - data: data, - operation: operation, - safeTxGas: txGasEstimate, - baseGas: baseGasEstimate, - gasPrice: gasPrice, - gasToken: txGasToken, - refundReceiver: refundReceiver, + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, nonce: nonce.toNumber(), }, }; From 0ffee4bf7eed33feccb52d235a1ea1775363a7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Thu, 5 May 2022 14:51:10 +0200 Subject: [PATCH 07/19] Increase number of accounts for less chance of test failures where lexigraphic order is necessary --- hardhat.config.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/hardhat.config.ts b/hardhat.config.ts index 1b48e92f..a2d81329 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -64,11 +64,8 @@ let hardhat = { initialBaseFeePerGas: 0, // workaround from https://github.com/sc-forks/solidity-coverage/issues/652#issuecomment-896330136 . Remove when that issue is closed. accounts: { // This is here because we need to test at some points for lexigraphical ordering, and in some cases need an account - // "before" or "after" another address. If you're not getting what you need you can tweak this mnemonic to get a new - // random set of accounts that are hopefully ordered better. This is only necessary to make tests pass, if they're passing - // without it ever then it can be removed and use the hardhat default (test test test test test test test test test test test junk) - mnemonic: - "fix burden relax exact quick orbit ticket peasant apology outer lady police", + // "before" or "after" another address. If tests fail then bumping this number may help + count: 100, }, forking, }; From f2e0f92d9d1825e6a4459eb38237c60bae359c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 10:04:51 +0200 Subject: [PATCH 08/19] Add deploy configuration for the new contracts --- .../deploy/config/default/ActionDispatcher.ts | 5 +++ .../default/AddPrepaidCardSKUHandler.ts | 29 +++++++++++++ .../config/default/PrepaidCardManager.ts | 4 ++ .../config/default/PrepaidCardMarketV2.ts | 42 +++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts create mode 100644 scripts/deploy/config/default/PrepaidCardMarketV2.ts diff --git a/scripts/deploy/config/default/ActionDispatcher.ts b/scripts/deploy/config/default/ActionDispatcher.ts index 76cc58ed..d4e575fb 100644 --- a/scripts/deploy/config/default/ActionDispatcher.ts +++ b/scripts/deploy/config/default/ActionDispatcher.ts @@ -92,6 +92,11 @@ export default async function ( value: address("PayRewardTokensHandler"), params: ["{VALUE}", "{NAME}"], }, + addPrepaidCardSKU: { + mapping: "actions", + value: address("AddPrepaidCardSKUHandler"), + params: ["{VALUE}", "{NAME}"], + }, }, }); } diff --git a/scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts b/scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts new file mode 100644 index 00000000..69a718a2 --- /dev/null +++ b/scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts @@ -0,0 +1,29 @@ +import { getAddress, AddressFile, ContractConfig } from "../../config-utils"; + +export default async function ( + proxyAddresses: AddressFile +): Promise { + function address(name: string) { + return getAddress(name, proxyAddresses); + } + return Promise.resolve({ + setup: [ + { + name: "actionDispatcher", + value: address("ActionDispatcher"), + }, + { + name: "prepaidCardManager", + value: address("PrepaidCardManager"), + }, + { + name: "tokenManagerAddress", + value: address("TokenManager"), + }, + { + name: "versionManager", + value: address("VersionManager"), + }, + ], + }); +} diff --git a/scripts/deploy/config/default/PrepaidCardManager.ts b/scripts/deploy/config/default/PrepaidCardManager.ts index b80b8242..6af29340 100644 --- a/scripts/deploy/config/default/PrepaidCardManager.ts +++ b/scripts/deploy/config/default/PrepaidCardManager.ts @@ -52,6 +52,10 @@ export default async function ( { name: "minimumFaceValue", value: MINIMUM_AMOUNT }, { name: "maximumFaceValue", value: MAXIMUM_AMOUNT }, { name: "getContractSigners", value: [address("PrepaidCardMarket")] }, + { + name: "getTrustedCallersForCreatingPrepaidCardsWithIssuer", + value: [address("PrepaidCardMarketV2")], + }, { name: "versionManager", value: address("VersionManager"), diff --git a/scripts/deploy/config/default/PrepaidCardMarketV2.ts b/scripts/deploy/config/default/PrepaidCardMarketV2.ts new file mode 100644 index 00000000..cf2d25ff --- /dev/null +++ b/scripts/deploy/config/default/PrepaidCardMarketV2.ts @@ -0,0 +1,42 @@ +import { + getAddress, + AddressFile, + ContractConfig, + PREPAID_CARD_PROVISIONER, +} from "../../config-utils"; + +export default async function ( + proxyAddresses: AddressFile +): Promise { + function address(name: string) { + return getAddress(name, proxyAddresses); + } + return Promise.resolve({ + setup: [ + { + name: "prepaidCardManagerAddress", + value: address("PrepaidCardManager"), + }, + { + name: "provisioner", + value: PREPAID_CARD_PROVISIONER, + }, + { + name: "tokenManager", + value: address("TokenManager"), + }, + { + name: "actionDispatcher", + value: address("ActionDispatcher"), + }, + { + name: "getTrustedProvisioners", + value: [PREPAID_CARD_PROVISIONER], + }, + { + name: "versionManager", + value: address("VersionManager"), + }, + ], + }); +} From 3aaf3d28e45109db5ab91b20742b1041bf6fdd38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 11:36:30 +0200 Subject: [PATCH 09/19] When adding a SKU, require the owner of the prepaid card owns the issuer safe --- .../AddPrepaidCardSKUHandler.sol | 31 ++++++++++++++- test/PrepaidCardMarketV2-test.js | 39 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/contracts/action-handlers/AddPrepaidCardSKUHandler.sol b/contracts/action-handlers/AddPrepaidCardSKUHandler.sol index c4691aab..0afd3370 100644 --- a/contracts/action-handlers/AddPrepaidCardSKUHandler.sol +++ b/contracts/action-handlers/AddPrepaidCardSKUHandler.sol @@ -9,6 +9,7 @@ import "../PrepaidCardMarketV2.sol"; import "../TokenManager.sol"; import "../IPrepaidCardMarket.sol"; import "../VersionManager.sol"; +import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol"; contract AddPrepaidCardSKUHandler is Ownable, Versionable { address public actionDispatcher; @@ -68,16 +69,42 @@ contract AddPrepaidCardSKUHandler is Ownable, Versionable { require(amount == 0, "amount must be 0"); - (, , bytes memory actionData) = abi.decode(data, (address, uint256, bytes)); + (address payable prepaidCard, , bytes memory actionData) = abi.decode( + data, + (address, uint256, bytes) + ); ( uint256 faceValue, string memory customizationDID, address tokenAddress, address marketAddress, - address issuerSafe + address payable issuerSafe ) = abi.decode(actionData, (uint256, string, address, address, address)); + // require that the owner of the prepaid card is the same as the owner + // of the issuer safe + PrepaidCardManager prepaidCardMgr = PrepaidCardManager( + prepaidCardManagerAddress + ); + + address prepaidCardOwner = prepaidCardMgr.getPrepaidCardOwner(prepaidCard); + address[] memory issuerSafeOwners = GnosisSafe(issuerSafe).getOwners(); + + bool foundOwner = false; + + // Safety measure to prevent big gas costs on huge arrays + require(issuerSafeOwners.length < 100, "too many safe owners"); + + for (uint256 i = 0; i < issuerSafeOwners.length; i++) { + if (issuerSafeOwners[i] == prepaidCardOwner) { + foundOwner = true; + break; + } + } + + require(foundOwner, "owner of the prepaid card does not own issuer safe"); + PrepaidCardMarketV2 prepaidCardMarket = PrepaidCardMarketV2(marketAddress); return diff --git a/test/PrepaidCardMarketV2-test.js b/test/PrepaidCardMarketV2-test.js index 8f7fb1f4..be3dd80b 100644 --- a/test/PrepaidCardMarketV2-test.js +++ b/test/PrepaidCardMarketV2-test.js @@ -444,6 +444,45 @@ contract("PrepaidCardMarketV2", (accounts) => { "safe transaction was reverted" ); }); + + it("can't add a SKU when prepaid card owner does not own the issuer safe", async function () { + await depositTokens(toTokenUnit(1)); + + let [fundingPrepaidCard] = await makePrepaidCards( + [toTokenUnit(1)], + ZERO_ADDRESS, + depot.address + ); + + await cardcpxdToken.mint(fundingPrepaidCard.address, toTokenUnit(1)); + + await transferOwner( + prepaidCardManager, + fundingPrepaidCard, + issuer, + customer, + relayer + ); + + await addPrepaidCardSKU( + prepaidCardManager, + fundingPrepaidCard, + "1000", + "did:cardstack:test", + daicpxdToken.address, + prepaidCardMarketV2, + customer, + relayer, + null, + null, + depot + ).should.be.rejectedWith( + Error, + // the real revert reason ("owner of the prepaid card does not own issuer safe") is behind the + // gnosis safe execTransaction boundary, so we just get this generic error + "safe transaction was reverted" + ); + }); }); describe("getQuantity", () => { From 1bdf9ceb3b46f6ca7c3a0b48a8ddc835f0d3efc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 12:23:02 +0200 Subject: [PATCH 10/19] Add ability to remove trusted callers for creating prepaid cards with issuer --- contracts/PrepaidCardManager.sol | 24 ++++++++++++++++++++++++ test/PrepaidCardManager-test.js | 28 +++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/contracts/PrepaidCardManager.sol b/contracts/PrepaidCardManager.sol index 9844d4a4..08484462 100644 --- a/contracts/PrepaidCardManager.sol +++ b/contracts/PrepaidCardManager.sol @@ -67,6 +67,7 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { ); event GasPolicyAdded(string action, bool useGasPrice); event ContractSignerRemoved(address signer); + event TrustedCallerForCreatingPrepaidCardsWithIssuerRemoved(address caller); event PrepaidCardSend( address prepaidCard, uint256 spendAmount, @@ -208,6 +209,18 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { emit ContractSignerRemoved(signer); } + function removeTrustedCallerForCreatingPrepaidCardsWithIssuer(address caller) + external + onlyOwner + { + require( + trustedCallersForCreatingPrepaidCardsWithIssuer.contains(caller), + "caller not present" + ); + trustedCallersForCreatingPrepaidCardsWithIssuer.remove(caller); + emit TrustedCallerForCreatingPrepaidCardsWithIssuerRemoved(caller); + } + /** * @dev onTokenTransfer(ERC677) - this is the ERC677 token transfer callback. * @@ -351,6 +364,17 @@ contract PrepaidCardManager is Ownable, Versionable, Safe { return contractSigners.values(); } + /** + * @dev get the addresses that are allowed to create cards with provided issuer + */ + function getTrustedCallersForCreatingPrepaidCardsWithIssuer() + external + view + returns (address[] memory) + { + return trustedCallersForCreatingPrepaidCardsWithIssuer.values(); + } + /** * @dev returns a boolean indicating if the prepaid card's owner is an EIP-1271 signer * @param prepaidCard prepaid card address diff --git a/test/PrepaidCardManager-test.js b/test/PrepaidCardManager-test.js index 3261b488..a788a153 100644 --- a/test/PrepaidCardManager-test.js +++ b/test/PrepaidCardManager-test.js @@ -72,6 +72,7 @@ contract("PrepaidCardManager", (accounts) => { versionManager, merchantSafe, contractSigner, + trustedCallerForCreatingPrepaidCardsWithIssuer, relayer, depot, prepaidCards = [], @@ -86,6 +87,7 @@ contract("PrepaidCardManager", (accounts) => { gasFeeReceiver = accounts[5]; merchantFeeReceiver = accounts[6]; contractSigner = accounts[7]; + trustedCallerForCreatingPrepaidCardsWithIssuer = accounts[8]; walletAmount = toTokenUnit(1000); versionManager = await setupVersionManager(owner); @@ -198,7 +200,7 @@ contract("PrepaidCardManager", (accounts) => { MINIMUM_AMOUNT, MAXIMUM_AMOUNT, [contractSigner], - [], + [trustedCallerForCreatingPrepaidCardsWithIssuer], versionManager.address ); await prepaidCardManager.addGasPolicy("transfer", false); @@ -230,6 +232,12 @@ contract("PrepaidCardManager", (accounts) => { expect(await prepaidCardManager.getContractSigners()).to.deep.equal([ contractSigner, ]); + expect(await prepaidCardManager.getContractSigners()).to.deep.equal([ + contractSigner, + ]); + expect( + await prepaidCardManager.getTrustedCallersForCreatingPrepaidCardsWithIssuer() + ).to.deep.equal([trustedCallerForCreatingPrepaidCardsWithIssuer]); }); it("can get version of contract", async () => { @@ -251,6 +259,24 @@ contract("PrepaidCardManager", (accounts) => { await prepaidCardManager.removeContractSigner(contractSigner); expect(await prepaidCardManager.getContractSigners()).to.deep.equal([]); }); + + it("rejects when non-owner removes a contract signer", async () => { + await prepaidCardManager + .removeTrustedCallerForCreatingPrepaidCardsWithIssuer( + trustedCallerForCreatingPrepaidCardsWithIssuer, + { + from: customer, + } + ) + .should.be.rejectedWith(Error, "Ownable: caller is not the owner"); + }); + + it("can remove a contract signer", async () => { + await prepaidCardManager.removeTrustedCallerForCreatingPrepaidCardsWithIssuer( + trustedCallerForCreatingPrepaidCardsWithIssuer + ); + expect(await prepaidCardManager.getContractSigners()).to.deep.equal([]); + }); }); describe("create prepaid card", () => { From da76948269d35e50e4516d2c965ccf8e3750200c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 12:26:44 +0200 Subject: [PATCH 11/19] Add a comment on division in Solidity Co-authored-by: Alex <93225030+alex-cardstack@users.noreply.github.com> --- contracts/PrepaidCardMarketV2.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/PrepaidCardMarketV2.sol b/contracts/PrepaidCardMarketV2.sol index 698a21fe..6c047f3e 100644 --- a/contracts/PrepaidCardMarketV2.sol +++ b/contracts/PrepaidCardMarketV2.sol @@ -217,6 +217,8 @@ contract PrepaidCardMarketV2 is uint256 price = prepaidCardManager.priceForFaceValue(token, faceValue); + // Division in solidity rounds towards zero, so this calculation won't overestimate the quantity available + // https://docs.soliditylang.org/en/latest/types.html#division return balance[issuerSafe][token] / price; } From 4b0acbb373bbe651f0638cf0cf6e168a77f09d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 12:27:31 +0200 Subject: [PATCH 12/19] Add a note on who the msg.sender is Co-authored-by: Alex <93225030+alex-cardstack@users.noreply.github.com> --- contracts/PrepaidCardMarketV2.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/PrepaidCardMarketV2.sol b/contracts/PrepaidCardMarketV2.sol index 6c047f3e..b4600880 100644 --- a/contracts/PrepaidCardMarketV2.sol +++ b/contracts/PrepaidCardMarketV2.sol @@ -275,6 +275,7 @@ contract PrepaidCardMarketV2 is keccak256(abi.encodePacked(issuer, token, faceValue, customizationDID)); } + // Note: msg.sender is issuer safe function withdrawTokens(uint256 amount, address token) external { address _issuer = issuers[msg.sender]; require(_issuer != address(0), "Issuer not found"); From 11b462ca34fb0975e845d14b0edbf89055b26f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 12:28:40 +0200 Subject: [PATCH 13/19] Improve the note about whitelisted tokens Co-authored-by: Alex <93225030+alex-cardstack@users.noreply.github.com> --- contracts/PrepaidCardMarketV2.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/PrepaidCardMarketV2.sol b/contracts/PrepaidCardMarketV2.sol index b4600880..78f9a25a 100644 --- a/contracts/PrepaidCardMarketV2.sol +++ b/contracts/PrepaidCardMarketV2.sol @@ -324,7 +324,7 @@ contract PrepaidCardMarketV2 is uint256 amount, bytes calldata data ) external returns (bool) { - // Only CARD.CPXD, DAI.CPXD are accepted + // Only whitelisted tokens are accepted require( TokenManager(tokenManager).isValidToken(msg.sender), "token is unaccepted" From fb220b3eee134843738c212bbdca44c5c5cf3d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 12:40:23 +0200 Subject: [PATCH 14/19] Change name - token -> issuingToken --- test/PrepaidCardMarketV2-test.js | 2 +- test/utils/constant/eventABIs.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/PrepaidCardMarketV2-test.js b/test/PrepaidCardMarketV2-test.js index be3dd80b..da4e3803 100644 --- a/test/PrepaidCardMarketV2-test.js +++ b/test/PrepaidCardMarketV2-test.js @@ -405,7 +405,7 @@ contract("PrepaidCardMarketV2", (accounts) => { ); expect(event.issuer).to.be.equal(issuer); - expect(event.token).to.be.equal(daicpxdToken.address); + expect(event.issuingToken).to.be.equal(daicpxdToken.address); expect(event.faceValue).to.be.equal("1000"); expect(event.customizationDID).to.be.equal("did:cardstack:test"); diff --git a/test/utils/constant/eventABIs.js b/test/utils/constant/eventABIs.js index a9c52bbd..0e557d28 100644 --- a/test/utils/constant/eventABIs.js +++ b/test/utils/constant/eventABIs.js @@ -341,7 +341,7 @@ const eventABIs = { }, { type: "address", - name: "token", + name: "issuingToken", }, { type: "uint256", From 7f38bbaa638b6dfe31af8f646c06f94bd5c16674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 15:34:20 +0200 Subject: [PATCH 15/19] Refactor pause functionaility to be compatible with OZ defender --- contracts/IPrepaidCardMarket.sol | 4 ++ contracts/PrepaidCardMarket.sol | 13 ++++ contracts/PrepaidCardMarketV2.sol | 13 ++++ test/PrepaidCardMarket-test.js | 51 +++++++++++----- test/PrepaidCardMarketV2-test.js | 98 ++++++++++++++++++++++--------- 5 files changed, 135 insertions(+), 44 deletions(-) diff --git a/contracts/IPrepaidCardMarket.sol b/contracts/IPrepaidCardMarket.sol index 83f3d7ad..1461b52e 100644 --- a/contracts/IPrepaidCardMarket.sol +++ b/contracts/IPrepaidCardMarket.sol @@ -36,4 +36,8 @@ interface IPrepaidCardMarket { uint256 faceValue, string memory customizationDID ) external view returns (bytes32); + + function pause() external; + + function unpause() external; } diff --git a/contracts/PrepaidCardMarket.sol b/contracts/PrepaidCardMarket.sol index 0304de29..4b29f69a 100644 --- a/contracts/PrepaidCardMarket.sol +++ b/contracts/PrepaidCardMarket.sol @@ -115,11 +115,24 @@ contract PrepaidCardMarket is Ownable, Versionable, IPrepaidCardMarket { emit Setup(); } + // TODO: Remove once we refactor relayer and hub to use pause() and unpause(). + // The latter is used to be compatible with OpenZeppelin's defender + // (https://docs.openzeppelin.com/defender/admin#pauseunpause) function setPaused(bool _paused) external onlyOwner { paused = _paused; emit PausedToggled(_paused); } + function pause() external onlyOwner { + paused = true; + emit PausedToggled(true); + } + + function unpause() external onlyOwner { + paused = false; + emit PausedToggled(false); + } + function setItem(address issuer, address prepaidCard) external onlyHandlersOrPrepaidCardManager diff --git a/contracts/PrepaidCardMarketV2.sol b/contracts/PrepaidCardMarketV2.sol index 78f9a25a..aa87dec2 100644 --- a/contracts/PrepaidCardMarketV2.sol +++ b/contracts/PrepaidCardMarketV2.sol @@ -122,11 +122,24 @@ contract PrepaidCardMarketV2 is emit Setup(); } + // TODO: Remove setPaused once we refactor relayer and hub to use pause() and unpause(). + // The latter is used to be compatible with OpenZeppelin's defender + // (https://docs.openzeppelin.com/defender/admin#pauseunpause) function setPaused(bool _paused) external onlyOwner { paused = _paused; emit PausedToggled(_paused); } + function pause() external onlyOwner { + paused = true; + emit PausedToggled(true); + } + + function unpause() external onlyOwner { + paused = false; + emit PausedToggled(false); + } + function getTrustedProvisioners() external view returns (address[] memory) { return trustedProvisioners.values(); } diff --git a/test/PrepaidCardMarket-test.js b/test/PrepaidCardMarket-test.js index d9b08f28..c798b641 100644 --- a/test/PrepaidCardMarket-test.js +++ b/test/PrepaidCardMarket-test.js @@ -164,7 +164,7 @@ contract("PrepaidCardMarket", (accounts) => { }); beforeEach(async () => { - await prepaidCardMarket.setPaused(false); + await prepaidCardMarket.unpause(); }); describe("manage inventory", () => { @@ -531,7 +531,7 @@ contract("PrepaidCardMarket", (accounts) => { let testCards = await Promise.all( inventory.slice(0, 1).map((a) => GnosisSafe.at(a)) ); - await prepaidCardMarket.setPaused(true); + await prepaidCardMarket.pause(); await removePrepaidCardInventory( prepaidCardManager, fundingCard, @@ -874,7 +874,7 @@ contract("PrepaidCardMarket", (accounts) => { expect( (await prepaidCardMarket.getInventory(sku)).length ).to.be.greaterThanOrEqual(1); - await prepaidCardMarket.setPaused(true); + await prepaidCardMarket.pause(); await prepaidCardMarket .provisionPrepaidCard(customer, sku, { from: provisioner, @@ -896,21 +896,42 @@ contract("PrepaidCardMarket", (accounts) => { }); describe("contract management", () => { - it("owner can pause contract", async function () { - await prepaidCardMarket.setPaused(true); - expect(await prepaidCardMarket.paused()).to.equal(true); - }); + describe("pausing using setPause (legacy)", () => { + it("owner can pause contract", async function () { + await prepaidCardMarket.setPaused(true); + expect(await prepaidCardMarket.paused()).to.equal(true); + }); - it("owner can resume contract", async function () { - await prepaidCardMarket.setPaused(true); - await prepaidCardMarket.setPaused(false); - expect(await prepaidCardMarket.paused()).to.equal(false); + it("owner can resume contract", async function () { + await prepaidCardMarket.setPaused(true); + await prepaidCardMarket.setPaused(false); + expect(await prepaidCardMarket.paused()).to.equal(false); + }); + + it("rejects when non-owner pauses contract", async function () { + await prepaidCardMarket + .setPaused(true, { from: customer }) + .should.be.rejectedWith(Error, "Ownable: caller is not the owner"); + }); }); - it("rejects when non-owner pauses contract", async function () { - await prepaidCardMarket - .setPaused(true, { from: customer }) - .should.be.rejectedWith(Error, "Ownable: caller is not the owner"); + describe("pausing using pause/unpause", () => { + it("owner can pause contract", async function () { + await prepaidCardMarket.pause(); + expect(await prepaidCardMarket.paused()).to.equal(true); + }); + + it("owner can resume contract", async function () { + await prepaidCardMarket.pause(); + await prepaidCardMarket.unpause(); + expect(await prepaidCardMarket.paused()).to.equal(false); + }); + + it("rejects when non-owner pauses contract", async function () { + await prepaidCardMarket + .pause({ from: customer }) + .should.be.rejectedWith(Error, "Ownable: caller is not the owner"); + }); }); }); diff --git a/test/PrepaidCardMarketV2-test.js b/test/PrepaidCardMarketV2-test.js index da4e3803..f82bbb85 100644 --- a/test/PrepaidCardMarketV2-test.js +++ b/test/PrepaidCardMarketV2-test.js @@ -747,40 +747,80 @@ contract("PrepaidCardMarketV2", (accounts) => { ).to.be.rejectedWith("Not enough balance"); }); - it(`rejects when contract is paused`, async function () { - let tx = await prepaidCardMarketV2.setPaused(true); - let [pauseToggledEvent] = getParamsFromEvent( - tx, - eventABIs.PREPAID_CARD_MARKET_V2_PAUSED_TOGGLED, - prepaidCardMarketV2.address - ); - expect(pauseToggledEvent.paused).to.be.true; + describe("pausing using setPause (legacy)", () => { + it(`rejects when contract is paused`, async function () { + let tx = await prepaidCardMarketV2.setPaused(true); + let [pauseToggledEvent] = getParamsFromEvent( + tx, + eventABIs.PREPAID_CARD_MARKET_V2_PAUSED_TOGGLED, + prepaidCardMarketV2.address + ); + expect(pauseToggledEvent.paused).to.be.true; - await prepaidCardMarketV2 - .provisionPrepaidCard(customer, skuAddEvent.sku, { - from: relayer, - }) - .should.be.rejectedWith(Error, "Contract is paused"); + await prepaidCardMarketV2 + .provisionPrepaidCard(customer, skuAddEvent.sku, { + from: relayer, + }) + .should.be.rejectedWith(Error, "Contract is paused"); + }); + + it("can provision a prepaid card when unpaused", async function () { + await prepaidCardMarketV2.setPaused(true); + await prepaidCardMarketV2.setPaused(false); + let tx = await prepaidCardMarketV2.provisionPrepaidCard( + customer, + skuAddEvent.sku, + { + from: relayer, + } + ); + + let [createPrepaidCardEvent] = getParamsFromEvent( + tx, + eventABIs.CREATE_PREPAID_CARD, + prepaidCardManager.address + ); + + expect(createPrepaidCardEvent.card).to.be.ok; + }); }); - it("can provision a prepaid card when unpaused", async function () { - await prepaidCardMarketV2.setPaused(true); - await prepaidCardMarketV2.setPaused(false); - let tx = await prepaidCardMarketV2.provisionPrepaidCard( - customer, - skuAddEvent.sku, - { - from: relayer, - } - ); + describe("pausing using pause/unpause", () => { + it(`rejects when contract is paused`, async function () { + let tx = await prepaidCardMarketV2.pause(); + let [pauseToggledEvent] = getParamsFromEvent( + tx, + eventABIs.PREPAID_CARD_MARKET_V2_PAUSED_TOGGLED, + prepaidCardMarketV2.address + ); + expect(pauseToggledEvent.paused).to.be.true; - let [createPrepaidCardEvent] = getParamsFromEvent( - tx, - eventABIs.CREATE_PREPAID_CARD, - prepaidCardManager.address - ); + await prepaidCardMarketV2 + .provisionPrepaidCard(customer, skuAddEvent.sku, { + from: relayer, + }) + .should.be.rejectedWith(Error, "Contract is paused"); + }); + + it("can provision a prepaid card when unpaused", async function () { + await prepaidCardMarketV2.pause(); + await prepaidCardMarketV2.unpause(); + let tx = await prepaidCardMarketV2.provisionPrepaidCard( + customer, + skuAddEvent.sku, + { + from: relayer, + } + ); - expect(createPrepaidCardEvent.card).to.be.ok; + let [createPrepaidCardEvent] = getParamsFromEvent( + tx, + eventABIs.CREATE_PREPAID_CARD, + prepaidCardManager.address + ); + + expect(createPrepaidCardEvent.card).to.be.ok; + }); }); }); From 0cb4bce3dc3c2917f1723578350781a391fb95d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Mon, 9 May 2022 16:26:10 +0200 Subject: [PATCH 16/19] Initialize the new contracts for deploy --- scripts/deploy/001_initialize_contracts.js | 8 ++++++++ scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/deploy/001_initialize_contracts.js b/scripts/deploy/001_initialize_contracts.js index 10e0f09e..d596003c 100644 --- a/scripts/deploy/001_initialize_contracts.js +++ b/scripts/deploy/001_initialize_contracts.js @@ -49,6 +49,10 @@ async function main() { contractName: "PrepaidCardMarket", init: [owner], }, + PrepaidCardMarketV2: { + contractName: "PrepaidCardMarketV2", + init: [owner], + }, RevenuePool: { contractName: "RevenuePool", init: [owner] }, RewardPool: { contractName: "RewardPool", init: [owner] }, Exchange: { contractName: "Exchange", init: [owner] }, @@ -84,6 +88,10 @@ async function main() { contractName: "SetPrepaidCardAskHandler", init: [owner], }, + AddPrepaidCardSKUHandler: { + contractName: "SetPrepaidCardAskHandler", + init: [owner], + }, BridgeUtils: { contractName: "BridgeUtils", init: [owner] }, TokenManager: { contractName: "TokenManager", init: [owner] }, MerchantManager: { diff --git a/scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts b/scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts index 69a718a2..09c1b304 100644 --- a/scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts +++ b/scripts/deploy/config/default/AddPrepaidCardSKUHandler.ts @@ -13,7 +13,7 @@ export default async function ( value: address("ActionDispatcher"), }, { - name: "prepaidCardManager", + name: "prepaidCardManagerAddress", value: address("PrepaidCardManager"), }, { From 7500421e1d2597b408abb6ad354437a22f7afd2b Mon Sep 17 00:00:00 2001 From: Alex Cardstack <93225030+alex-cardstack@users.noreply.github.com> Date: Tue, 10 May 2022 21:31:42 +0100 Subject: [PATCH 17/19] Add test to ensure withdraw still works after safe changes owners --- test/PrepaidCardMarketV2-test.js | 89 +++++++++++++++++++++++++++++++- test/utils/helper.js | 1 + 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/test/PrepaidCardMarketV2-test.js b/test/PrepaidCardMarketV2-test.js index f82bbb85..97cc70a1 100644 --- a/test/PrepaidCardMarketV2-test.js +++ b/test/PrepaidCardMarketV2-test.js @@ -22,6 +22,7 @@ const { addActionHandlers, setPrepaidCardAsk, transferOwner, + SENTINEL_OWNER, } = require("./utils/helper"); const AbiCoder = require("web3-eth-abi"); @@ -38,6 +39,7 @@ contract("PrepaidCardMarketV2", (accounts) => { gnosisSafeMasterCopy, provisioner, customer, + newSafeOwner, exchange, prepaidCardManager, prepaidCardMarket, @@ -71,6 +73,7 @@ contract("PrepaidCardMarketV2", (accounts) => { provisioner = accounts[5]; prepaidCardMarket = accounts[6]; customer = accounts[7]; + newSafeOwner = accounts[8]; proxyFactory = await ProxyFactory.new(); gnosisSafeMasterCopy = await utils.deployContract( @@ -186,7 +189,7 @@ contract("PrepaidCardMarketV2", (accounts) => { ); }; - withdrawTokens = async (amount) => { + withdrawTokens = async (amount, owner = issuer) => { let withdrawTokens = prepaidCardMarketV2.contract.methods.withdrawTokens( amount, daicpxdToken.address @@ -207,7 +210,7 @@ contract("PrepaidCardMarketV2", (accounts) => { return await signAndSendSafeTransaction( safeTxData, - issuer, + owner, depot, relayer ); @@ -295,6 +298,8 @@ contract("PrepaidCardMarketV2", (accounts) => { it("can withdraw tokens", async function () { await depositTokens(toTokenUnit(5)); + let balanceBefore = await daicpxdToken.balanceOf(depot.address); + let { safeTx, executionResult: { success }, @@ -315,6 +320,13 @@ contract("PrepaidCardMarketV2", (accounts) => { expect(event.token).to.be.equal(daicpxdToken.address); expect(event.amount).to.be.equal(toWei("4")); expect(event.safe).to.be.equal(depot.address); + + let balanceAfter = await daicpxdToken.balanceOf(depot.address); + + let withdrawnAmount = balanceAfter.sub(balanceBefore); + let remainder = toTokenUnit(4).sub(withdrawnAmount); + expect(remainder).to.be.bignumber.lt(toWei("0.0001")); + expect(remainder).to.be.bignumber.gt("0"); }); it("fails when there is no issuer", async function () { @@ -361,6 +373,79 @@ contract("PrepaidCardMarketV2", (accounts) => { }) ).to.be.rejectedWith("Insufficient funds for withdrawal"); }); + + it("can withdraw tokens after the issuer safe changes owners", async function () { + await depositTokens(toTokenUnit(5)); + + let owners = await depot.getOwners(); + expect(owners.length).to.eq(1); + expect(owners[0]).to.eq(issuer); + + let swapOwner = depot.contract.methods.swapOwner( + SENTINEL_OWNER, + issuer, + newSafeOwner + ); + + let payload = swapOwner.encodeABI(); + let gasEstimate = await swapOwner.estimateGas({ + from: depot.address, + }); + + let safeTxData = { + to: depot.address, + data: payload, + txGasEstimate: gasEstimate, + gasPrice: 1000000000, + txGasToken: daicpxdToken.address, + refundReceiver: relayer, + }; + + let { + executionResult: { success: swapOwnerSuccess }, + } = await signAndSendSafeTransaction( + safeTxData, + issuer, + depot, + relayer + ); + + expect(swapOwnerSuccess).to.be.true; + + owners = await depot.getOwners(); + expect(owners.length).to.eq(1); + expect(owners[0]).to.eq(newSafeOwner); + + let balanceBefore = await daicpxdToken.balanceOf(depot.address); + + let { + safeTx, + executionResult: { success }, + } = await withdrawTokens(toTokenUnit(4), newSafeOwner); + + expect(success).to.be.true; + expect( + await prepaidCardMarketV2.balance(depot.address, daicpxdToken.address) + ).to.be.bignumber.equal(toTokenUnit(1)); // We started with 5 and we withdrew 4 + + let [event] = getParamsFromEvent( + safeTx, + eventABIs.PREPAID_CARD_MARKET_V2_TOKENS_WITHDRAWN, + prepaidCardMarketV2.address + ); + + expect(event.issuer).to.be.equal(issuer); + expect(event.token).to.be.equal(daicpxdToken.address); + expect(event.amount).to.be.equal(toWei("4")); + expect(event.safe).to.be.equal(depot.address); + + let balanceAfter = await daicpxdToken.balanceOf(depot.address); + + let withdrawnAmount = balanceAfter.sub(balanceBefore); + let remainder = toTokenUnit(4).sub(withdrawnAmount); + expect(remainder).to.be.bignumber.lt(toWei("0.0001")); + expect(remainder).to.be.bignumber.gt("0"); + }); }); describe("SKUs", () => { diff --git a/test/utils/helper.js b/test/utils/helper.js index f50d9ea6..f9a952eb 100644 --- a/test/utils/helper.js +++ b/test/utils/helper.js @@ -2052,3 +2052,4 @@ exports.transferOwner = transferOwner; exports.createPrepaidCards = createPrepaidCards; exports.transferRewardSafe = transferRewardSafe; exports.withdrawFromRewardSafe = withdrawFromRewardSafe; +exports.SENTINEL_OWNER = SENTINEL_OWNER; From eb5f3cad5227de11ee399d938dc776357860c5b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Wed, 11 May 2022 10:42:52 +0200 Subject: [PATCH 18/19] Use array of provisioners --- contracts/PrepaidCardMarketV2.sol | 42 +++++++------------ .../config/default/PrepaidCardMarketV2.ts | 6 +-- test/PrepaidCardMarketV2-test.js | 35 +++++++--------- 3 files changed, 33 insertions(+), 50 deletions(-) diff --git a/contracts/PrepaidCardMarketV2.sol b/contracts/PrepaidCardMarketV2.sol index aa87dec2..94cb6e51 100644 --- a/contracts/PrepaidCardMarketV2.sol +++ b/contracts/PrepaidCardMarketV2.sol @@ -35,7 +35,6 @@ contract PrepaidCardMarketV2 is address public prepaidCardManagerAddress; address public tokenManager; - address public provisioner; address public actionDispatcher; address public versionManager; mapping(address => mapping(address => uint256)) public balance; // issuer safe address -> token -> balance @@ -45,7 +44,7 @@ contract PrepaidCardMarketV2 is bool public paused; - EnumerableSetUpgradeable.AddressSet internal trustedProvisioners; + EnumerableSetUpgradeable.AddressSet internal provisioners; event Setup(); @@ -75,8 +74,8 @@ contract PrepaidCardMarketV2 is uint256 askPrice ); event PrepaidCardProvisioned(address owner, bytes32 sku); - event TrustedProvisionerAdded(address token); - event TrustedProvisionerRemoved(address token); + event ProvisionerAdded(address token); + event ProvisionerRemoved(address token); event PausedToggled(bool paused); modifier onlyHandlers() { @@ -94,29 +93,26 @@ contract PrepaidCardMarketV2 is function setup( address _prepaidCardManagerAddress, - address _provisioner, address _tokenManager, address _actionDispatcher, - address[] calldata _trustedProvisioners, + address[] calldata _provisioners, address _versionManager ) external onlyOwner { require( _prepaidCardManagerAddress != address(0), "prepaidCardManagerAddress not set" ); - require(_provisioner != address(0), "provisioner not set"); require(_tokenManager != address(0), "tokenManager not set"); require(_actionDispatcher != address(0), "actionDispatcher not set"); require(_versionManager != address(0), "versionManager not set"); prepaidCardManagerAddress = _prepaidCardManagerAddress; - provisioner = _provisioner; tokenManager = _tokenManager; actionDispatcher = _actionDispatcher; versionManager = _versionManager; - for (uint256 i = 0; i < _trustedProvisioners.length; i++) { - _addTrustedProvisioner(_trustedProvisioners[i]); + for (uint256 i = 0; i < _provisioners.length; i++) { + _addProvisioner(_provisioners[i]); } emit Setup(); @@ -140,24 +136,18 @@ contract PrepaidCardMarketV2 is emit PausedToggled(false); } - function getTrustedProvisioners() external view returns (address[] memory) { - return trustedProvisioners.values(); + function getProvisioners() external view returns (address[] memory) { + return provisioners.values(); } - function _addTrustedProvisioner(address provisionerAddress) - internal - onlyOwner - { - trustedProvisioners.add(provisionerAddress); - emit TrustedProvisionerAdded(provisionerAddress); + function _addProvisioner(address provisionerAddress) internal onlyOwner { + provisioners.add(provisionerAddress); + emit ProvisionerAdded(provisionerAddress); } - function removeTrustedProvisioner(address provisionerAddress) - external - onlyOwner - { - trustedProvisioners.remove(provisionerAddress); - emit TrustedProvisionerRemoved(provisionerAddress); + function removeProvisioner(address provisionerAddress) external onlyOwner { + provisioners.remove(provisionerAddress); + emit ProvisionerRemoved(provisionerAddress); } function provisionPrepaidCard(address customer, bytes32 sku) @@ -167,8 +157,8 @@ contract PrepaidCardMarketV2 is { require(!paused, "Contract is paused"); require( - trustedProvisioners.contains(msg.sender), - "Only trusted provisioners allowed" + provisioners.contains(msg.sender), + "Only provisioners are allowed to provision prepaid cards" ); require(asks[sku] > 0, "Can't provision SKU with 0 askPrice"); diff --git a/scripts/deploy/config/default/PrepaidCardMarketV2.ts b/scripts/deploy/config/default/PrepaidCardMarketV2.ts index cf2d25ff..3085560f 100644 --- a/scripts/deploy/config/default/PrepaidCardMarketV2.ts +++ b/scripts/deploy/config/default/PrepaidCardMarketV2.ts @@ -17,10 +17,6 @@ export default async function ( name: "prepaidCardManagerAddress", value: address("PrepaidCardManager"), }, - { - name: "provisioner", - value: PREPAID_CARD_PROVISIONER, - }, { name: "tokenManager", value: address("TokenManager"), @@ -30,7 +26,7 @@ export default async function ( value: address("ActionDispatcher"), }, { - name: "getTrustedProvisioners", + name: "getProvisioners", value: [PREPAID_CARD_PROVISIONER], }, { diff --git a/test/PrepaidCardMarketV2-test.js b/test/PrepaidCardMarketV2-test.js index 97cc70a1..3445c018 100644 --- a/test/PrepaidCardMarketV2-test.js +++ b/test/PrepaidCardMarketV2-test.js @@ -70,10 +70,10 @@ contract("PrepaidCardMarketV2", (accounts) => { owner = accounts[0]; issuer = accounts[1]; relayer = accounts[4]; - provisioner = accounts[5]; - prepaidCardMarket = accounts[6]; - customer = accounts[7]; - newSafeOwner = accounts[8]; + provisioner = relayer; + prepaidCardMarket = accounts[5]; + customer = accounts[6]; + newSafeOwner = accounts[7]; proxyFactory = await ProxyFactory.new(); gnosisSafeMasterCopy = await utils.deployContract( @@ -139,10 +139,9 @@ contract("PrepaidCardMarketV2", (accounts) => { await prepaidCardMarketV2.setup( prepaidCardManager.address, - provisioner, tokenManager.address, actionDispatcher.address, - [relayer], // trusted provisioners + [provisioner], // provisioners versionManager.address ); @@ -221,17 +220,16 @@ contract("PrepaidCardMarketV2", (accounts) => { it("should set trusted provisioners", async () => { await prepaidCardMarketV2.setup( prepaidCardManager.address, - provisioner, ( await TokenManager.new() ).address, actionDispatcher.address, - [relayer], + [provisioner], versionManager.address ); - expect( - await prepaidCardMarketV2.getTrustedProvisioners() - ).to.have.members([relayer]); + expect(await prepaidCardMarketV2.getProvisioners()).to.have.members([ + provisioner, + ]); }); }); @@ -239,26 +237,25 @@ contract("PrepaidCardMarketV2", (accounts) => { it("can remove trusted provisioners", async () => { await prepaidCardMarketV2.setup( prepaidCardManager.address, - provisioner, ( await TokenManager.new() ).address, actionDispatcher.address, - [relayer], + [provisioner], versionManager.address ); - expect( - await prepaidCardMarketV2.getTrustedProvisioners() - ).to.have.members([relayer]); + expect(await prepaidCardMarketV2.getProvisioners()).to.have.members([ + relayer, + ]); - await prepaidCardMarketV2.removeTrustedProvisioner(relayer); + await prepaidCardMarketV2.removeProvisioner(relayer); - expect(await prepaidCardMarketV2.getTrustedProvisioners()).to.be.empty; + expect(await prepaidCardMarketV2.getProvisioners()).to.be.empty; }); it("rejects when non-owner tries to remove a trusted provisioner", async () => { await prepaidCardMarketV2 - .removeTrustedProvisioner(relayer, { from: issuer }) // from is just something else than the owner + .removeProvisioner(provisioner, { from: issuer }) // from is just something else than the owner .should.be.rejectedWith(Error, "caller is not the owner"); }); }); From 049688c9145b575298f975f49975206d8af1dd80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Thu, 12 May 2022 12:01:44 +0200 Subject: [PATCH 19/19] Improve README by adding docs on PrepaidCardMarketV2, PrepaidCardManager, and AddPrepaidCardSKUHandler --- README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4ffe9b0c..248b161e 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,22 @@ The `SupplierManager` contract is used to register suppliers (entities that brin The `BridgeUtils` contract manages the point of interaction between the token bridge's home mediator contract and the Card Protocol. When the token bridge encounters an allowed stablecoin that it hasn't encountered before, it will create a new token contract in layer 2 for that token, as part of this, the token bridge will also inform the Card Protocol about the new token contract address, such that the Card Protocol can accept the new CPXD form of the stablecoin as payment for the creation of new prepaid cards, as well as, payments by customers to merchants. Additionally, as part of the token bridging process, the bridged tokens are placed in a gnosis safe that is owned by the *Suppliers* (the initiators of the token bridging process). This allows for easy gas-less (from the perspective of the users of the protocol) transactions. The gnosis safe as part of the token bridging process is actually created by the `SupplierManager` contract that the `BridgeUtils` contract refers to. ### PrepaidCardManager -The `PrepaidCardManager` contract is responsible for creating the gnosis safes that are considered as *Prepaid Cards*. As part of this process, a gnosis safe is created when layer 2 CPXD tokens are sent to the `PrepaidCardManager` Contract (as part of the layer 2 CPXD token's ERC-677 `onTokenTransfer()` function). This gnosis safe represents a *Prepaid Card*. This safe is created with 2 owners: -1. The sender of the transaction, i.e. the *Supplier's* gnosis safe + +The `PrepaidCardManager` contract is responsible for creating the gnosis safes that are considered as _Prepaid Cards_. As part of this process, a gnosis safe is created when layer 2 CPXD tokens are sent to the `PrepaidCardManager` Contract (as part of the layer 2 CPXD token's ERC-677 `onTokenTransfer()` function). This gnosis safe represents a _Prepaid Card_. + +There are two ways of creating prepaid cards when the tokens are sent to this contract: + +1. When `issuer` and `issuerSafe` are provided in the `onTokenTransfer` data field, and the tokens are coming from a trusted caller (the `PrepaidCardMarketV2` contract): + +- Prepaid cards will be created using the provided issuer as the issuer, and issuerSafe will be emitted as depot in the `CreatePrepaidCard` event + +2. When `issuer` and `issuerSafe` are provided as zero addresses: + +- Prepaid cards will be created using the provided owner as the issuer + +This safe is created with 2 owners: + +1. The provided owner (case 1 explained above, i.e. the _Customer_), OR the sender of the transaction, i.e. the _Supplier's_ gnosis safe (case 2) 2. The `PrepaidCardManager` contract itself. As well as a threshold of 2 signatures in order to execute gnosis safe transactions. This approach means that the `PrepaidCardManager` contract needs to sign off on all transactions involving *Prepaid Cards*. As such the `PrepaidCardManager` contract allows *Prepaid Cards* to be able "send actions" by calling the `send()` function. The caller of the `PrepaidCardManager.send()` function (which is generally the txn sender of a gnosis safe relay server) specifies: @@ -54,8 +68,11 @@ The `PrepaidCardManager` contract stores signers that are authorized to sign saf ### PrepaidCardMarket The PrepaidCardMarket contract is responsible for provisioning already created prepaid card safes to customers. The intent is that Prepaid Card issuers can add their prepaid cards as inventory to this contract, thereby creating a SKU that represents the class of Prepaid Card which becomes available to provision to a customer. The act of adding a Prepaid Card to inventory means that the issuer of the Prepaid Card transfers their ownership of the Prepaid Card safe contract to the PrepaidCardMarket contract, such that the Prepaid Card safe has 2 contract owners: the PrepaidCardManager and the PrepaidCardMarket. Once a Prepaid Card is part of the inventory of the PrepaidCardMarket contract a "provisioner" role (which is a special EOA) is permitted to provision a Prepaid Card safe to a customer's EOA. This process entails leveraging an EIP-1271 contract signature to transfer ownership of the Prepaid Card safe from the PrepaidCardMarket contract to the specified customer EOA. Additionally the issuer of a Prepaid Card may also decide to remove Prepaid Cards from their inventory. This process also leverages EIP-1271 contract signatures to transfer ownership of the Prepaid Card safe from the PrepaidCardMarket contract back to the issuer that originally placed the Prepaid Card safe into their inventory. -A future update to this contract will provide the ability for EOAs to directly purchase Prepaid Card safes from the PrepaidCardMarket using native coin and/or CPXD tokens. In that future scenario we would leverage the "ask price" that can be set against a SKU to be able to purchase Prepaid Card safes directly. To support this eventual feature we have added the ability to set an "ask price" for SKUs ni the inventory, however, capability to directly purchase a Prepaid Card from this contract is still TBD. +A future update to this contract will provide the ability for EOAs to directly purchase Prepaid Card safes from the PrepaidCardMarket using native coin and/or CPXD tokens. In that future scenario we would leverage the "ask price" that can be set against a SKU to be able to purchase Prepaid Card safes directly. To support this eventual feature we have added the ability to set an "ask price" for SKUs in the inventory, however, capability to directly purchase a Prepaid Card from this contract is still TBD. + +### PrepaidCardMarketV2 +The PrepaidCardMarketV2 market is responsible for provisioning prepaid cards without creating them beforehand. Once the issuer deposits their funds into the contract from their safe, their new balance will be saved and then they can continue to add SKUs and set the SKU asking price. It is then possible for them to call `provisionPrepaidCard(customer, sku)` on the contract, which will determine the price to create the prepaid card, create it by sending tokens to `PrepaidCardManager` (which will set the customer as the owner), and deduct the price to create the prepaid card from the issuer safe's balance recorded in the `PrepaidCardMarketV2` contract. ### ActionDispatcher The `ActionDispatcher` receives actions that have been issued from the `PrepaidCardManager.send()` as gnosis safe transactions. The `ActionDispatcher` will confirm that the requested USD rate for the §SPEND amount falls within an acceptable range, and then will forward (via an ERC677 `transferAndCall()`) the action to the contract address that has been configured to handle the requested action. @@ -78,6 +95,10 @@ The `SetPrepaidCardInventoryHandler` is a contract that handles the `setPrepaidC ### RemovePrepaidCardInventoryHandler The `RemovePrepaidCardInventoryHandler` is a contract that handles the `removePrepaidCardInventory` action. This contract will receive a "removePrepaidCardInventory" action accompanied by ABI encoded fields that include a list of prepaid card safe addresses to be removed from an issuer's inventory in the PrepaidCardMarket contract. This contract will iterate over the list of prepaid card safe addresses and leverage an EIP-1271 contract signature to transfer the prepaid card safes back to the issuer that originally added these prepaid card safes to the inventory, thereby removing these prepaid card safes from the PrepaidCardMarket contract inventory. +### AddPrepaidCardSKUHandler + +The `AddPrepaidCardSKUHandler` is a contract that handles the `addPrepaidCardSKU` action. This contract will receive a "addPrepaidCardSKU" action accompanied by ABI encoded fields that include the face value, customization DID, and the addresses of the issuing token, market and issuer's safe. It will then proceed to supply these params in the `addSKU` call to the `PrepaidCardMarketV2` contract. Before the issuer can add SKUs, they first need to have some balance in the `PrepaidCardMarketV2` contract. + ### SetPrepaidCardAskHandler The `SetPrepaidCardAskHandler` is a contract that handles the `setPrepaidCardAsk` action. This contract will receive a "setPrepaidCardAsk" action accompanied by ABI encoded fields that include the SKU for prepaid card inventory and the ask price for a single item in the inventory in units of the issuing token for the prepaid card. Until an ask price is set for a SKU, the prepaid cards that are a part of the SKU cannot be provisioned. This is to prevent the scenario where prepaid cards could be purchased before a price is set when the TBD direct purchase functionality is added to the PrepaidCardMarket contract. Eventually when the TBD direct purchase functionality is added, the ask price will be used to charge EOAs that wish to purchase prepaid card inventory from the PrepaidCardMarket contract.