From 5d9e3bcb6cad58d3d08a1b4d3546c2cb78425283 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Wed, 1 Apr 2020 18:27:06 +0200 Subject: [PATCH] add redeem actions --- app/js/actions/bucket.ts | 39 ++++--- app/js/actions/redeem.ts | 206 +++++++++++++++++++++++++++++++++++ app/js/actions/web3.ts | 5 +- app/js/components/App.tsx | 26 ++++- app/js/components/Redeem.tsx | 49 +++++++-- app/js/reducers/bucket.ts | 10 +- app/js/reducers/index.ts | 6 + app/js/reducers/redeem.ts | 60 ++++++++++ app/js/reducers/web3.ts | 4 + package.json | 1 + test/contract_spec.js | 10 +- yarn.lock | 12 ++ 12 files changed, 394 insertions(+), 34 deletions(-) create mode 100644 app/js/actions/redeem.ts create mode 100644 app/js/reducers/redeem.ts diff --git a/app/js/actions/bucket.ts b/app/js/actions/bucket.ts index 0e1e3a5..b91ab23 100644 --- a/app/js/actions/bucket.ts +++ b/app/js/actions/bucket.ts @@ -16,14 +16,15 @@ export interface ErrLoadingGift { message: string } -export type BucketError = - ErrGiftNotFound; +export type BucketErrors = + ErrGiftNotFound | + ErrLoadingGift; -const errGiftNotFound = () => ({ +const errGiftNotFound = (): ErrGiftNotFound => ({ type: ERROR_GIFT_NOT_FOUND, }); -const errLoadingGift = (message: string) => ({ +const errLoadingGift = (message: string): ErrLoadingGift => ({ type: ERROR_LOADING_GIFT, message, }); @@ -32,17 +33,19 @@ export const BUCKET_GIFT_LOADING = "BUCKET_GIFT_LOADING"; export interface BucketGiftLoadingAction { type: typeof BUCKET_GIFT_LOADING address: string + recipient: string } export const BUCKET_GIFT_LOADING_ERROR = "BUCKET_GIFT_LOADING_ERROR"; export interface BucketGiftLoadingErrorAction { type: typeof BUCKET_GIFT_LOADING_ERROR - error: BucketError + error: ErrLoadingGift } export const BUCKET_GIFT_LOADED = "BUCKET_GIFT_LOADED"; export interface BucketGiftLoadedAction { type: typeof BUCKET_GIFT_LOADED + expirationTime: number recipient: string amount: string codeHash: string @@ -51,7 +54,7 @@ export interface BucketGiftLoadedAction { export const BUCKET_GIFT_NOT_FOUND = "BUCKET_GIFT_NOT_FOUND"; export interface BucketGiftNotFoundAction { type: typeof BUCKET_GIFT_NOT_FOUND - error: BucketError + error: ErrGiftNotFound } export const BUCKET_TOKEN_LOADING = "BUCKET_TOKEN_LOADING"; @@ -64,7 +67,7 @@ export const BUCKET_TOKEN_LOADED = "BUCKET_TOKEN_LOADED"; export interface BucketTokenLoadedAction { type: typeof BUCKET_TOKEN_LOADED symbol: string - decimal: number + decimals: number } export type BucketActions = @@ -75,25 +78,27 @@ export type BucketActions = BucketTokenLoadingAction | BucketTokenLoadedAction; -export const loadingGift = (address: string): BucketLoadingAction => ({ +export const loadingGift = (address: string, recipient: string): BucketGiftLoadingAction => ({ type: BUCKET_GIFT_LOADING, address, + recipient, }); -export const giftLoaded = (recipient: string, amount: string, codeHash: string): BucketGiftLoadedAction => ({ +export const giftLoaded = (expirationTime: number, recipient: string, amount: string, codeHash: string): BucketGiftLoadedAction => ({ type: BUCKET_GIFT_LOADED, + expirationTime, recipient, amount, codeHash, }); -export const giftNotFound = (recipient: string, amount: string, codeHash: string): BucketGiftNotFoundAction => ({ +export const giftNotFound = (): BucketGiftNotFoundAction => ({ type: BUCKET_GIFT_NOT_FOUND, error: errGiftNotFound(), }); -export const errorLoadingGift = (errorMessage: string): BucketGiftNotFoundAction => ({ - type: BUCKET_GIFT_NOT_FOUND, +export const errorLoadingGift = (errorMessage: string): BucketGiftLoadingErrorAction => ({ + type: BUCKET_GIFT_LOADING_ERROR, error: errLoadingGift(errorMessage), }); @@ -108,7 +113,7 @@ export const tokenLoaded = (symbol: string, decimals: number): BucketTokenLoaded decimals, }); -const newBucketContract = (address: string) => { +export const newBucketContract = (address: string) => { const bucketAbi = GiftBucket.options.jsonInterface; const bucket = new config.web3!.eth.Contract(bucketAbi, address); return bucket; @@ -124,16 +129,16 @@ export const loadGift = (bucketAddress: string, recipientAddress: string) => { return async (dispatch: Dispatch, getState: () => RootState) => { dispatch(loadingGift(bucketAddress, recipientAddress)); const bucket = newBucketContract(bucketAddress); - - bucket.methods.gifts(recipientAddress).call().then((result: Any) => { + const expirationTime = await bucket.methods.expirationTime().call(); + bucket.methods.gifts(recipientAddress).call().then((result: any) => { const { recipient, amount, code } = result; if (amount === "0") { dispatch(giftNotFound()) return; } - dispatch(giftLoaded(recipient, amount, code)); - dispatch(loadToken(bucket)) + dispatch(giftLoaded(expirationTime, recipient, amount, code)); + dispatch(loadToken(bucket)) }).catch(err => { dispatch(errorLoadingGift(err)) console.error("err: ", err) diff --git a/app/js/actions/redeem.ts b/app/js/actions/redeem.ts new file mode 100644 index 0000000..acab5d4 --- /dev/null +++ b/app/js/actions/redeem.ts @@ -0,0 +1,206 @@ +import { RootState } from '../reducers'; +import GiftBucket from '../../../embarkArtifacts/contracts/GiftBucket'; +import IERC20Detailed from '../../../embarkArtifacts/contracts/IERC20Detailed'; +import { config } from "../config"; +import { Dispatch } from 'redux'; +import { newBucketContract } from "./bucket"; +import { sha3 } from "web3-utils"; +import { recoverTypedSignature } from 'eth-sig-util'; +import { Web3Type } from "../actions/web3"; + +const sleep = (ms: number) => { + return new Promise(resolve => { + window.setTimeout(resolve, ms); + }); +} + +interface RedeemMessage { + receiver: string + code: string +} + +export const ERROR_REDEEMING = "ERROR_REDEEMING"; +export interface ErrRedeeming { + type: typeof ERROR_REDEEMING + message: string +} + +export const ERROR_WRONG_SIGNER = "ERROR_WRONG_SIGNER"; +export interface ErrWrongSigner { + type: typeof ERROR_WRONG_SIGNER + expected: string + actual: string +} + +export type RedeemErrors = + ErrRedeeming | + ErrWrongSigner; + +export const REDEEM_LOADING = "REDEEM_LOADING"; +export interface RedeemLoadingAction { + type: typeof REDEEM_LOADING +} + +export const REDEEM_ERROR = "REDEEM_ERROR"; +export interface RedeemErrorAction { + type: typeof REDEEM_ERROR + error: RedeemErrors +} + +export const REDEEM_DONE = "REDEEM_DONE"; +export interface RedeemDoneAction { + type: typeof REDEEM_DONE + txHash: string +} + +export type RedeemActions = + RedeemLoadingAction | + RedeemErrorAction | + RedeemDoneAction; + +const redeeming = () => ({ + type: REDEEM_LOADING, +}); + +const wrongSigner = (expected: string, actual: string) => ({ + type: REDEEM_ERROR, + error: { + type: ERROR_WRONG_SIGNER, + expected, + actual, + } +}); + +const redeemError = (message: string) => ({ + type: REDEEM_ERROR, + error: { + type: ERROR_REDEEMING, + message, + } +}); + +const redeemDone = (txHash: string) => ({ + type: REDEEM_DONE, + txHash, +}); + +export const redeem = (bucketAddress: string, recipientAddress: string, code: string) => { + return (dispatch: Dispatch, getState: () => RootState) => { + dispatch(redeeming()); + const state = getState(); + const web3Type = state.web3.type; + const bucketAddress = state.bucket.address; + const bucket = newBucketContract(bucketAddress); + const codeHash = sha3(code); + const account = state.web3.account; + + const message = { + receiver: state.web3.account, + code: codeHash, + }; + + //FIXME: is signer needed? + signRedeem(web3Type, bucketAddress, state.web3.account, message).then(async ({ sig, address }: SignRedeemResponse) => { + const recipient = state.bucket.recipient; + //FIXME: remove! hack to wait for the request screen to slide down + await sleep(3000); + if (address.toLowerCase() != recipient.toLowerCase()) { + //FIXME: handle error + dispatch(wrongSigner(recipient, address)); + return; + } + + const redeem = bucket.methods.redeem(message, sig); + const gas = await redeem.estimateGas(); + redeem.send({ from: account, gas }).then(resp => { + dispatch(redeemDone(resp.transactionHash)); + }).catch(err => { + console.error("redeem error: ", err); + dispatch(redeemError(err)) + }); + }).catch(err => { + console.error("sign redeem error: ", err); + dispatch(redeemError(err)) + }); + } +} + +interface SignRedeemResponse { + sig: string + address: string +} + +async function signRedeem(web3Type: Web3Type, contractAddress: string, signer: string, message: RedeemMessage): Promise { + const chainId = await config.web3!.eth.net.getId(); + const domain = [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" } + ]; + + const redeem = [ + { name: "receiver", type: "address" }, + { name: "code", type: "bytes32" }, + ]; + + const domainData = { + name: "KeycardGift", + version: "1", + chainId: chainId, + verifyingContract: contractAddress + }; + + const data = { + types: { + EIP712Domain: domain, + Redeem: redeem, + }, + primaryType: ("Redeem" as const), + domain: domainData, + message: message + }; + + if (web3Type === Web3Type.Status) { + return signWithKeycard(signer, data); + } else { + return signWithWeb3(signer, data); + } +} + +const signWithWeb3 = (signer: string, data: any) => { + return new Promise((resolve, reject) => { + (window as any).ethereum.sendAsync({ + method: "eth_signTypedData_v3", + params: [signer, JSON.stringify(data)], + from: signer, + }, (err, resp) => { + if (err) { + reject(err); + } else { + const sig = resp.result; + const address = recoverTypedSignature({ + data, + sig + }); + + resolve({ sig, address }); + } + }) + }); +} + +const signWithKeycard = (signer: string, data: any) => { + return new Promise((resolve, reject) => { + (window as any).ethereum.send("keycard_signTypedData", [signer, JSON.stringify(data)]).then(resp => { + const sig = resp.result; + const address = recoverTypedSignature({ + data, + sig + }); + resolve({ sig, address }); + }).catch(err => { + reject(err); + }) + }); +} diff --git a/app/js/actions/web3.ts b/app/js/actions/web3.ts index c3a00f4..644b757 100644 --- a/app/js/actions/web3.ts +++ b/app/js/actions/web3.ts @@ -11,10 +11,11 @@ export const VALID_NETWORK_ID = 3; // export const VALID_NETWORK_ID = 5; export const LOCAL_NETWORK_ID = 1337; -enum Web3Type { +export enum Web3Type { + None, Generic, Remote, - Status, + Status } export const WEB3_INITIALIZED = "WEB3_INITIALIZED"; diff --git a/app/js/components/App.tsx b/app/js/components/App.tsx index ee7c263..c3b595f 100644 --- a/app/js/components/App.tsx +++ b/app/js/components/App.tsx @@ -1,9 +1,24 @@ import React from 'react'; +import GiftBucketFactory from '../../../embarkArtifacts/contracts/GiftBucketFactory'; import { shallowEqual, useSelector, useDispatch, } from 'react-redux'; +import { Web3Type } from "../actions/web3"; + +const web3Type = (t: Web3Type) => { + switch (t) { + case Web3Type.None: + return "not a web3 browser"; + case Web3Type.Generic: + return "generic web3 browser"; + case Web3Type.Remote: + return "remote web3 node"; + case Web3Type.Status: + return "status web3 browser"; + } +} export default function(ownProps: any) { const props = useSelector(state => { @@ -11,6 +26,7 @@ export default function(ownProps: any) { initialized: state.web3.networkID, networkID: state.web3.networkID, error: state.web3.error, + type: state.web3.type, } }, shallowEqual); @@ -22,5 +38,13 @@ export default function(ownProps: any) { return "initializing..."; } - return ownProps.children; + return <> + Network ID: {props.networkID}
+ Factory: {GiftBucketFactory.address}
+ Web3 Type: {web3Type(props.type)} +
+
+ {ownProps.children} +
+ ; } diff --git a/app/js/components/Redeem.tsx b/app/js/components/Redeem.tsx index f9a2407..4e2f946 100644 --- a/app/js/components/Redeem.tsx +++ b/app/js/components/Redeem.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { RootState } from '../reducers'; import { useRouteMatch } from 'react-router-dom'; import { shallowEqual, @@ -8,19 +9,40 @@ import { import { redeemPath } from '../config'; import { loadGift, - BucketError, + BucketErrors, ERROR_LOADING_GIFT, ERROR_GIFT_NOT_FOUND, } from '../actions/bucket'; import { toBaseUnit } from "../utils"; +import { + redeem, + RedeemErrors, + ERROR_REDEEMING, + ERROR_WRONG_SIGNER, +} from '../actions/redeem'; -const errorMessage = (error: BucketError): string => { +const REDEEM_CODE = "hello world"; + +const buckerErrorMessage = (error: BucketErrors): string => { switch (error.type) { case ERROR_LOADING_GIFT: - return "couldn't load gift."; + return "couldn't load gift"; case ERROR_GIFT_NOT_FOUND: - return "gift not found"; + return "gift not found or already redeemed"; + + default: + return "something went wrong"; + } +} + +const redeemErrorMessage = (error: RedeemErrors): string => { + switch (error.type) { + case ERROR_WRONG_SIGNER: + return `wrong signer. expected signature from ${error.expected}, got signature from ${error.actual}`; + + case ERROR_REDEEMING: + return `redeem error: ${error.message}`; default: return "something went wrong"; @@ -37,11 +59,11 @@ export default function(ownProps: any) { const bucketAddress = match.params.bucketAddress; const recipientAddress = match.params.recipientAddress; - const props = useSelector(state => { + const props = useSelector((state: RootState) => { return { bucketAddress: state.bucket.address, loading: state.bucket.loading, - found: state.bucket.found, + expirationTime: state.bucket.expirationTime, error: state.bucket.error, recipient: state.bucket.recipient, amount: state.bucket.amount, @@ -50,6 +72,9 @@ export default function(ownProps: any) { tokenSymbol: state.bucket.tokenSymbol, tokenDecimals: state.bucket.tokenDecimals, receiver: state.web3.account, + redeeming: state.redeem.loading, + redeemError: state.redeem.error, + redeemTxHash: state.redeem.txHash, } }, shallowEqual); @@ -58,7 +83,7 @@ export default function(ownProps: any) { }, [bucketAddress, recipientAddress]); if (props.error) { - return `Error: ${errorMessage(props.error)}`; + return `Error: ${buckerErrorMessage(props.error)}`; } if (props.loading) { @@ -75,6 +100,7 @@ export default function(ownProps: any) { Bucket Address: {props.bucketAddress}
Recipient: {props.recipient}
Amount: {props.amount}
+ Expiration Time: {new Date(props.expirationTime * 1000).toLocaleDateString("default", {hour: "numeric", minute: "numeric"})}
Code Hash: {props.codeHash}
Token Address: {props.tokenAddress}
Token Symbol: {props.tokenSymbol}
@@ -84,5 +110,14 @@ export default function(ownProps: any) { Receiver: {props.receiver}



+ +
+ {props.redeemError && `Error: ${redeemErrorMessage(props.redeemError)}`} + + {props.redeemTxHash && `Done! Tx Hash: ${props.redeemTxHash}`} ; } diff --git a/app/js/reducers/bucket.ts b/app/js/reducers/bucket.ts index 3aa1872..05c3138 100644 --- a/app/js/reducers/bucket.ts +++ b/app/js/reducers/bucket.ts @@ -1,6 +1,6 @@ import { BucketActions, - BucketError, + BucketErrors, BUCKET_GIFT_LOADING, BUCKET_GIFT_NOT_FOUND, BUCKET_GIFT_LOADED, @@ -11,9 +11,11 @@ import { export interface BucketState { loading: boolean address: string | undefined + expirationTime: number | undefined tokenAddress: string | undefined + tokenSymbol: string | undefined tokenDecimals: number | undefined - error: BucketState | undefined + error: BucketErrors | undefined recipient: string | undefined amount: string | undefined codeHash: string | undefined @@ -22,7 +24,9 @@ export interface BucketState { const initialState: BucketState = { loading: false, address: undefined, + expirationTime: undefined, tokenAddress: undefined, + tokenSymbol: undefined, tokenDecimals: undefined, error: undefined, recipient: undefined, @@ -37,6 +41,7 @@ export const bucketReducer = (state: BucketState = initialState, action: BucketA ...initialState, loading: true, address: action.address, + recipient: action.recipient, } } @@ -52,6 +57,7 @@ export const bucketReducer = (state: BucketState = initialState, action: BucketA return { ...state, loading: false, + expirationTime: action.expirationTime, recipient: action.recipient, amount: action.amount, codeHash: action.codeHash, diff --git a/app/js/reducers/index.ts b/app/js/reducers/index.ts index cb186c8..b3bc388 100644 --- a/app/js/reducers/index.ts +++ b/app/js/reducers/index.ts @@ -8,10 +8,15 @@ import { BucketState, bucketReducer, } from './bucket'; +import { + RedeemState, + redeemReducer, +} from './redeem'; export interface RootState { web3: Web3State, bucket: BucketState, + redeem: RedeemState, } export default function(history) { @@ -19,5 +24,6 @@ export default function(history) { web3: web3Reducer, router: connectRouter(history), bucket: bucketReducer, + redeem: redeemReducer, }); } diff --git a/app/js/reducers/redeem.ts b/app/js/reducers/redeem.ts new file mode 100644 index 0000000..8106e38 --- /dev/null +++ b/app/js/reducers/redeem.ts @@ -0,0 +1,60 @@ +import { + RedeemActions, + RedeemErrors, + REDEEM_LOADING, + REDEEM_ERROR, + REDEEM_DONE, +} from "../actions/redeem"; +import { + BucketGiftLoadingAction, + BUCKET_GIFT_LOADING +} from "../actions/bucket"; + +export interface RedeemState { + loading: boolean + error: RedeemErrors | undefined + txHash: string | undefined + receiver: string | undefined +} + +const initialState: RedeemState = { + loading: false, + error: undefined, + txHash: undefined, + receiver: undefined, +} + +export const redeemReducer = (state: RedeemState = initialState, action: RedeemActions | BucketGiftLoadingAction): RedeemState => { + switch (action.type) { + case BUCKET_GIFT_LOADING: { + return initialState; + } + + case REDEEM_LOADING: { + return { + ...initialState, + loading: true, + } + } + + case REDEEM_ERROR: { + return { + ...initialState, + loading: false, + error: action.error, + } + } + + case REDEEM_DONE: { + return { + ...initialState, + loading: false, + txHash: action.txHash, + } + } + + default: + return state; + } +} + diff --git a/app/js/reducers/web3.ts b/app/js/reducers/web3.ts index 8dc73ab..53f3e95 100644 --- a/app/js/reducers/web3.ts +++ b/app/js/reducers/web3.ts @@ -4,6 +4,7 @@ import { WEB3_ERROR, WEB3_NETWORK_ID_LOADED, WEB3_ACCOUNT_LOADED, + Web3Type, } from '../actions/web3'; export interface Web3State { @@ -11,6 +12,7 @@ export interface Web3State { networkID: number | undefined error: string | undefined account: string | undefined + type: Web3Type } const initialState: Web3State = { @@ -18,6 +20,7 @@ const initialState: Web3State = { networkID: undefined, error: undefined, account: undefined, + type: Web3Type.None, }; export const web3Reducer = (state: Web3State = initialState, action: Web3Actions): Web3State => { @@ -26,6 +29,7 @@ export const web3Reducer = (state: Web3State = initialState, action: Web3Actions return { ...state, initialized: true, + type: action.web3Type, } } diff --git a/package.json b/package.json index c239026..44d87d0 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "connected-react-router": "^6.7.0", "esm": "^3.2.25", + "eth-sig-util": "^2.5.3", "history": "^4.10.1", "minimist": "^1.2.0", "react": "^16.12.0", diff --git a/test/contract_spec.js b/test/contract_spec.js index 4748bd4..47b0094 100644 --- a/test/contract_spec.js +++ b/test/contract_spec.js @@ -43,29 +43,29 @@ let sendMethod; async function signRedeem(contractAddress, signer, message) { const result = await web3.eth.net.getId(); let chainId = parseInt(result); - //FIXME: getChainID in the contract returns 1 so we hardcode it here to 1. + //FIXME: in tests, getChainID in the contract returns 1 so we hardcode it here to 1. chainId = 1; - let domain = [ + const domain = [ { name: "name", type: "string" }, { name: "version", type: "string" }, { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" } ]; - let redeem = [ + const redeem = [ { name: "receiver", type: "address" }, { name: "code", type: "bytes32" }, ]; - let domainData = { + const domainData = { name: "KeycardGift", version: "1", chainId: chainId, verifyingContract: contractAddress }; - let data = { + const data = { types: { EIP712Domain: domain, Redeem: redeem, diff --git a/yarn.lock b/yarn.lock index 2b0f2bf..506a5a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4881,6 +4881,18 @@ eth-sig-util@^2.1.1: tweetnacl "^1.0.0" tweetnacl-util "^0.15.0" +eth-sig-util@^2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/eth-sig-util/-/eth-sig-util-2.5.3.tgz#6938308b38226e0b3085435474900b03036abcbe" + integrity sha512-KpXbCKmmBUNUTGh9MRKmNkIPietfhzBqqYqysDavLseIiMUGl95k6UcPEkALAZlj41e9E6yioYXc1PC333RKqw== + dependencies: + buffer "^5.2.1" + elliptic "^6.4.0" + ethereumjs-abi "0.6.5" + ethereumjs-util "^5.1.1" + tweetnacl "^1.0.0" + tweetnacl-util "^0.15.0" + ethashjs@~0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ethashjs/-/ethashjs-0.0.7.tgz#30bfe4196726690a0c59d3b8272e70d4d0c34bae"