diff --git a/client/src/actions/buckets.ts b/client/src/actions/buckets.ts new file mode 100644 index 0000000..fcad189 --- /dev/null +++ b/client/src/actions/buckets.ts @@ -0,0 +1,134 @@ +import { RootState } from '../reducers'; +import { Dispatch } from 'redux'; +import { bucketsAddresses } from "../config"; +import Bucket from '../contracts/Bucket.json'; +import { newBucketContract } from "../utils"; +import { TokenType } from "../reducers/buckets"; +import IERC20Detailed from '../contracts/IERC20Detailed.json'; +import { AbiItem } from "web3-utils"; +import { config } from "../config"; +import { TokenDetails, ERC20Details } from "../reducers/buckets"; + +export const BUCKETS_LOADING = "BUCKETS_LOADING"; +export interface BucketsLoadingAction { + type: typeof BUCKETS_LOADING + recipientAddress: string +} + +export const BUCKETS_LOADING_BUCKET = "BUCKETS_LOADING_BUCKET"; +export interface BucketsLoadingBucketAction { + type: typeof BUCKETS_LOADING_BUCKET + recipientAddress: string + bucketAddress: string +} + +export const BUCKETS_REDEEMABLE_TOKEN_ADDRESS_LOADED = "BUCKETS_REDEEMABLE_TOKEN_ADDRESS_LOADED"; +export interface BucketsRedeemableTokenAddressLoadedAction { + type: typeof BUCKETS_REDEEMABLE_TOKEN_ADDRESS_LOADED + recipientAddress: string + bucketAddress: string + tokenAddress: string +} + +export const BUCKETS_REDEEMABLE_TOKEN_TYPE_LOADED = "BUCKETS_REDEEMABLE_TOKEN_TYPE_LOADED"; +export interface BucketsRedeemableTokenTypeLoadedAction { + type: typeof BUCKETS_REDEEMABLE_TOKEN_TYPE_LOADED + recipientAddress: string + bucketAddress: string + tokenType: TokenType +} + +export const BUCKETS_REDEEMABLE_TOKEN_DETAILS_LOADED = "BUCKETS_REDEEMABLE_TOKEN_DETAILS_LOADED"; +export interface BucketsRedeemableTokenDetailsLoadedAction { + type: typeof BUCKETS_REDEEMABLE_TOKEN_DETAILS_LOADED + recipientAddress: string + bucketAddress: string + tokenDetails: TokenDetails +} + +export type BucketsActions = + BucketsLoadingAction | + BucketsLoadingBucketAction | + BucketsRedeemableTokenAddressLoadedAction | + BucketsRedeemableTokenTypeLoadedAction | + BucketsRedeemableTokenDetailsLoadedAction; + +export const loadingBuckets = (recipientAddress: string): BucketsLoadingAction => ({ + type: BUCKETS_LOADING, + recipientAddress, +}); + +export const loadingBucket = (recipientAddress: string, bucketAddress: string): BucketsLoadingBucketAction => ({ + type: BUCKETS_LOADING_BUCKET, + recipientAddress, + bucketAddress, +}); + +export const tokenAddressLoaded = (recipientAddress: string, bucketAddress: string, tokenAddress: string): BucketsRedeemableTokenAddressLoadedAction => ({ + type: BUCKETS_REDEEMABLE_TOKEN_ADDRESS_LOADED, + recipientAddress, + bucketAddress, + tokenAddress, +}); + +export const tokenTypeLoaded = (recipientAddress: string, bucketAddress: string, tokenType: TokenType): BucketsRedeemableTokenTypeLoadedAction => ({ + type: BUCKETS_REDEEMABLE_TOKEN_TYPE_LOADED, + recipientAddress, + bucketAddress, + tokenType, +}); + +export const tokenDetailsLoaded = (recipientAddress: string, bucketAddress: string, tokenDetails: TokenDetails): BucketsRedeemableTokenDetailsLoadedAction => ({ + type: BUCKETS_REDEEMABLE_TOKEN_DETAILS_LOADED, + recipientAddress, + bucketAddress, + tokenDetails, +}); + +export const loadBuckets = (recipientAddress: string) => { + return (dispatch: Dispatch, getState: () => RootState) => { + dispatch(loadingBuckets(recipientAddress)); + + const addresses = bucketsAddresses(); + addresses.forEach((bucketAddress) => { + dispatch(loadBucket(recipientAddress, bucketAddress)); + }); + } +} + +const loadBucket = (recipientAddress: string, bucketAddress: string) => { + return async (dispatch: Dispatch, getState: () => RootState) => { + dispatch(loadingBucket(recipientAddress, bucketAddress)); + const bucket = newBucketContract(bucketAddress); + + const _type = await bucket.methods.bucketType().call(); + const type = _type === "20" ? "erc20" : "ntf"; + dispatch(tokenTypeLoaded(recipientAddress, bucketAddress, type as TokenType)); + + const tokenAddress = await bucket.methods.tokenAddress().call(); + dispatch(tokenAddressLoaded(recipientAddress, bucketAddress, tokenAddress)); + + //FIXME: catch possible error + const tokenDetails = await loadERC20Token(tokenAddress); + dispatch(tokenDetailsLoaded(recipientAddress, bucketAddress, tokenDetails)); + } +} + +const loadERC20Token = (address: string): Promise => { + return new Promise(async (resolve, reject) => { + const erc20Abi = IERC20Detailed.abi as AbiItem[]; + const erc20 = new config.web3!.eth.Contract(erc20Abi, address); + try { + const name = await erc20.methods.name().call(); + const symbol = await erc20.methods.symbol().call(); + const decimals = parseInt(await erc20.methods.decimals().call()); + resolve({ + name, + symbol, + decimals, + }); + } catch(e) { + reject(e); + } + }); +} diff --git a/client/src/actions/redeem.ts b/client/src/actions/redeem.ts index f05e061..889ffdc 100644 --- a/client/src/actions/redeem.ts +++ b/client/src/actions/redeem.ts @@ -1,11 +1,10 @@ import { RootState } from '../reducers'; import { config } from "../config"; import { Dispatch } from 'redux'; -import { newBucketContract } from "./redeemable"; import { sha3 } from "web3-utils"; import { recoverTypedSignature } from 'eth-sig-util'; import { Web3Type } from "../actions/web3"; -import { KECCAK_EMPTY_STRING } from '../utils'; +import { KECCAK_EMPTY_STRING, newBucketContract} from '../utils'; import { debug } from "./debug"; interface RedeemMessage { diff --git a/client/src/actions/redeemable.ts b/client/src/actions/redeemable.ts index e5fe136..c71c45e 100644 --- a/client/src/actions/redeemable.ts +++ b/client/src/actions/redeemable.ts @@ -2,14 +2,16 @@ import { RootState } from '../reducers'; import ERC20BucketFactory from '../contracts/ERC20BucketFactory.json'; import NFTBucketFactory from '../contracts/NFTBucketFactory.json'; import ERC20Bucket from '../contracts/ERC20Bucket.json'; -import Bucket from '../contracts/Bucket.json'; import IERC20Detailed from '../contracts/IERC20Detailed.json'; import IERC721Metadata from '../contracts/IERC721Metadata.json'; import { config } from "../config"; import { Dispatch } from 'redux'; -import { ZERO_ADDRESS } from "../utils"; -import { debug } from "./debug"; import { AbiItem } from "web3-utils"; +import { + ZERO_ADDRESS , + newBucketContract, +} from "../utils"; +import { debug } from "./debug"; interface ContractSpecs { networks: { @@ -178,18 +180,6 @@ export const tokenMetadataLoaded = (tokenAddress: string, recipient: string, met metadata, }); -export const newBucketContract = (address: string) => { - const bucketAbi = Bucket.abi as AbiItem[]; - const bucket = new config.web3!.eth.Contract(bucketAbi, address); - return bucket; -} - -export const newERC20BucketContract = (address: string) => { - const bucketAbi = ERC20Bucket.abi as AbiItem[]; - const bucket = new config.web3!.eth.Contract(bucketAbi, address); - return bucket; -} - export const loadRedeemable = (bucketAddress: string, recipientAddress: string) => { return async (dispatch: Dispatch, getState: () => RootState) => { const networkID = getState().web3.networkID!; diff --git a/client/src/components/RecipientBuckets.tsx b/client/src/components/RecipientBuckets.tsx new file mode 100644 index 0000000..b115bdf --- /dev/null +++ b/client/src/components/RecipientBuckets.tsx @@ -0,0 +1,92 @@ +import React, { useEffect } from 'react'; +import { RootState } from '../reducers'; +import { useRouteMatch } from 'react-router-dom'; +import { + shallowEqual, + useSelector, + useDispatch, +} from 'react-redux'; +import { recipientBucketsPath } from '../config'; +import { loadBuckets } from "../actions/buckets"; +import { ERC20Details } from "../reducers/buckets"; + +interface BuckestListItemProps { + bucketAddress: string +} + +const BuckestListItem = (ownProps: BuckestListItemProps) => { + const props = useSelector((state: RootState) => { + const redeemable = state.buckets.redeemables[ownProps.bucketAddress]; + if (redeemable === undefined) { + return null; + } + + return { + bucketAddress: ownProps.bucketAddress, + loading: redeemable.loading, + tokenAddress: redeemable.tokenAddress, + tokenType: redeemable.tokenType, + tokenDetails: redeemable.tokenDetails, + } + }, shallowEqual); + + if (props === null) { + return null; + } + + return
+ Bucket: {props.bucketAddress} + {props.loading && (LOADING...)} +
+ Token address: {props.tokenAddress} +
+ Type: {props.tokenType} +
+ {props.tokenDetails && <> + Symbol: {props.tokenDetails.symbol} +
+ Name: {props.tokenDetails.name} + } +
+
; +} + +interface URLParams { + recipientAddress: string +} + +export default function(ownProps: any) { + const dispatch = useDispatch() + + const match = useRouteMatch({ + path: recipientBucketsPath, + exact: true, + }); + + if (match === null) { + return null; + } + + const recipientAddress = match.params.recipientAddress; + + const props = useSelector((state: RootState) => { + return { + loading: state.buckets.loading, + buckets: state.buckets.buckets, + } + }, shallowEqual); + + useEffect(() => { + console.log("loading buckets") + dispatch(loadBuckets(recipientAddress)); + }, [dispatch, recipientAddress]); // FIXME: unload buckets + + return
+
buckets for {recipientAddress}
+
    + {props.buckets.map(bucketAddress =>
  • + +
  • )} +
+
; +} diff --git a/client/src/components/Redeemable.tsx b/client/src/components/Redeemable.tsx index 20069a3..ca6db2e 100644 --- a/client/src/components/Redeemable.tsx +++ b/client/src/components/Redeemable.tsx @@ -40,7 +40,7 @@ import { } from '@fortawesome/free-solid-svg-icons' -const buckerErrorMessage = (error: RedeemableErrors): string => { +const bucketErrorMessage = (error: RedeemableErrors): string => { switch (error.type) { case ERROR_LOADING_REDEEMABLE: return "couldn't load redeemable"; @@ -116,7 +116,7 @@ export default function(ownProps: any) { if (props.error) { return
- {buckerErrorMessage(props.error)} + {bucketErrorMessage(props.error)}
; } diff --git a/client/src/config.ts b/client/src/config.ts index 37f7f3b..922ab12 100644 --- a/client/src/config.ts +++ b/client/src/config.ts @@ -8,4 +8,14 @@ export const config: Config = { web3: undefined }; +export const recipientBucketsPath = "/recipients/:recipientAddress/buckets"; export const redeemablePath = "/buckets/:bucketAddress/redeemables/:recipientAddress"; + +export const bucketsAddresses = (): Array => { + const s = process.env.REACT_APP_BUCKETS; + if (s === undefined) { + return []; + } + + return s.split(",").map((a) => a.trim()); +} diff --git a/client/src/index.tsx b/client/src/index.tsx index 77550da..795c14d 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -12,7 +12,11 @@ import ErrorBoundary from './components/ErrorBoundary'; import Layout from './components/Layout'; import Home from './components/Home'; import Redeemable from './components/Redeemable'; -import { redeemablePath } from './config'; +import RecipientBuckets from './components/RecipientBuckets'; +import { + recipientBucketsPath, + redeemablePath, +} from './config'; const logger: Middleware = ({ getState }: MiddlewareAPI) => (next: Dispatch) => action => { console.log('dispatch', action); @@ -49,6 +53,7 @@ ReactDOM.render( + "page not found"} /> diff --git a/client/src/reducers/buckets.ts b/client/src/reducers/buckets.ts new file mode 100644 index 0000000..65d7e3d --- /dev/null +++ b/client/src/reducers/buckets.ts @@ -0,0 +1,155 @@ +import { + BucketsActions, + BUCKETS_LOADING, + BUCKETS_LOADING_BUCKET, + BUCKETS_REDEEMABLE_TOKEN_ADDRESS_LOADED, + BUCKETS_REDEEMABLE_TOKEN_TYPE_LOADED, + BUCKETS_REDEEMABLE_TOKEN_DETAILS_LOADED, +} from '../actions/buckets'; + +export type TokenType = "erc20" | "nft"; + +export interface ERC20Details { + name: string + symbol: string + decimals: number +} + +export type TokenDetails = ERC20Details; + +interface Redeemable { + bucketAddress: string + tokenAddress: undefined | string + loading: boolean + tokenType: undefined | TokenType + tokenDetails: undefined | ERC20Details + value: undefined | string +} + +export interface BucketsState { + recipientAddress: undefined | string + loading: boolean + buckets: Array + redeemables: { + [bucketAddress: string]: Redeemable + } +} + +const initialRedeemableState = { + bucketAddress: "", + tokenAddress: undefined, + loading: true, + tokenType: undefined, + tokenDetails: undefined, + value: undefined +} + +const initialState: BucketsState = { + recipientAddress: undefined, + loading: false, + buckets: [], + redeemables: {}, +}; + +export const bucketsReducer = (state: BucketsState = initialState, action: BucketsActions): BucketsState => { + switch (action.type) { + case BUCKETS_LOADING: { + return { + ...initialState, + recipientAddress: action.recipientAddress, + loading: true, + }; + } + + case BUCKETS_LOADING_BUCKET: { + if (action.recipientAddress !== state.recipientAddress) { + return state; + } + + return { + ...state, + buckets: [ + ...state.buckets, + action.bucketAddress, + ], + redeemables: { + ...state.redeemables, + [action.bucketAddress]: { + ...initialRedeemableState, + bucketAddress: action.bucketAddress, + loading: true, + } + }, + }; + } + + case BUCKETS_REDEEMABLE_TOKEN_ADDRESS_LOADED: { + if (action.recipientAddress !== state.recipientAddress) { + return state; + } + + const redeemable = state.redeemables[action.bucketAddress]; + if (redeemable === undefined) { + return state; + } + + return { + ...state, + redeemables: { + ...state.redeemables, + [action.bucketAddress]: { + ...redeemable, + tokenAddress: action.tokenAddress, + } + }, + }; + } + + case BUCKETS_REDEEMABLE_TOKEN_TYPE_LOADED: { + if (action.recipientAddress !== state.recipientAddress) { + return state; + } + + const redeemable = state.redeemables[action.bucketAddress]; + if (redeemable === undefined) { + return state; + } + + return { + ...state, + redeemables: { + ...state.redeemables, + [action.bucketAddress]: { + ...redeemable, + tokenType: action.tokenType, + } + }, + }; + } + + case BUCKETS_REDEEMABLE_TOKEN_DETAILS_LOADED: { + if (action.recipientAddress !== state.recipientAddress) { + return state; + } + + const redeemable = state.redeemables[action.bucketAddress]; + if (redeemable === undefined) { + return state; + } + + return { + ...state, + redeemables: { + ...state.redeemables, + [action.bucketAddress]: { + ...redeemable, + tokenDetails: action.tokenDetails, + } + }, + }; + } + + default: + return state; + } +} diff --git a/client/src/reducers/index.ts b/client/src/reducers/index.ts index 96e2b43..a83d9c1 100644 --- a/client/src/reducers/index.ts +++ b/client/src/reducers/index.ts @@ -21,6 +21,10 @@ import { DebugState, debugReducer, } from './debug'; +import { + BucketsState, + bucketsReducer, +} from './buckets'; export interface RootState { web3: Web3State, @@ -28,6 +32,7 @@ export interface RootState { redeem: RedeemState, layout: LayoutState, debug: DebugState, + buckets: BucketsState, } export default function(history: History) { @@ -38,5 +43,6 @@ export default function(history: History) { redeem: redeemReducer, layout: layoutReducer, debug: debugReducer, + buckets: bucketsReducer, }); } diff --git a/client/src/utils.ts b/client/src/utils.ts index 9f2baf8..db4d1c7 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -4,6 +4,9 @@ import { Token, TokenERC20, } from "./actions/redeemable"; +import { AbiItem } from "web3-utils"; +import Bucket from './contracts/Bucket.json'; +import { config } from "./config"; // keccak256("") export const KECCAK_EMPTY_STRING = "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"; @@ -30,3 +33,9 @@ export const isTokenERC20 = (token: Token): token is TokenERC20 => { export const compressAddress = (a: string, padding: number = 4) => { return `${a.slice(0, padding + 2)}...${a.slice(a.length - padding)}`; } + +export const newBucketContract = (address: string) => { + const bucketAbi = Bucket.abi as AbiItem[]; + const bucket = new config.web3!.eth.Contract(bucketAbi, address); + return bucket; +} diff --git a/contracts/erc20/IERC20Detailed.sol b/contracts/erc20/IERC20Detailed.sol index 3865937..d4fc5a3 100644 --- a/contracts/erc20/IERC20Detailed.sol +++ b/contracts/erc20/IERC20Detailed.sol @@ -3,6 +3,7 @@ pragma solidity ^0.5.16; import "./IERC20.sol"; contract IERC20Detailed is IERC20 { + function name() public view returns (string memory); function symbol() public view returns (string memory); function decimals() public view returns (uint8); } diff --git a/contracts/erc20/TestToken.sol b/contracts/erc20/TestToken.sol index 7a0d780..ffe37c9 100644 --- a/contracts/erc20/TestToken.sol +++ b/contracts/erc20/TestToken.sol @@ -6,10 +6,12 @@ import "./StandardToken.sol"; * @notice ERC20Token for test scripts, can be minted by anyone. */ contract TestToken is StandardToken { + string private _name; string private _symbol; uint256 private _decimals; - constructor(string memory symbol, uint256 decimals) public { + constructor(string memory name, string memory symbol, uint256 decimals) public { + _name = name; _symbol = symbol; _decimals = decimals; } @@ -20,6 +22,10 @@ contract TestToken is StandardToken { mint(amount * uint256(10)**_decimals); } + function name() public view returns (string memory) { + return _name; + } + function symbol() public view returns (string memory) { return _symbol; } diff --git a/migrations/01_initial_migration.js b/migrations/01_initial_migration.js index 91e5510..a62b1ca 100644 --- a/migrations/01_initial_migration.js +++ b/migrations/01_initial_migration.js @@ -9,6 +9,6 @@ module.exports = function(deployer, network) { deployer.deploy(ERC20BucketFactory); if (network === "development") { - deployer.deploy(TestToken, "TEST", 18); + deployer.deploy(TestToken, "Dev Test Token", "DTT", 18); } }; diff --git a/scripts/create-redeemable.js b/scripts/create-redeemable.js index 9e34b7c..3aa1282 100644 --- a/scripts/create-redeemable.js +++ b/scripts/create-redeemable.js @@ -25,8 +25,7 @@ const KECCAK_EMPTY_STRING = "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b const KECCAK_EMPTY_STRING2 = web3.utils.sha3(KECCAK_EMPTY_STRING); async function deployFactory() { - let code = "0x" + BucketFactoryCode; - let methodCall = BucketFactory.deploy({data: code}); + let methodCall = BucketFactory.deploy({data: BucketFactoryCode}); let receipt = await account.sendMethod(methodCall, null); return receipt.contractAddress; } @@ -191,7 +190,7 @@ async function run() { for (let keycard of keycards) { const create = argv["nft"] ? transferNFT : createRedeemable; await create(keycard); - console.log(`http://test-pn.keycard.cash/redeem/#/buckets/${bucket}/redeemables/${keycard.keycard}`) + console.log(`http://localhost:3000/redeem/#/buckets/${bucket}/redeemables/${keycard.keycard}`) } } else if (!hasDoneSomething) { console.error("the --file option must be specified");