import { Connection } from "@celo/connect"; import { Bip39 } from "@celo/utils/lib/account"; import { toChecksumAddress } from "@celo/utils/lib/address"; import { LocalWallet } from "@celo/wallet-local"; import { Interface } from "@ethersproject/abi"; import axios, { AxiosResponse } from "axios"; import BigNumber from "bignumber.js"; import PromiEvent from "promievent"; import invariant from "tiny-invariant"; import warning from "tiny-warning"; import Web3 from "web3"; import { Sign } from "web3-core"; import { TransactionConfig, TransactionReceipt } from "web3-eth"; import { Mixed } from "web3-utils"; import { PaymentRequest, PaymentRequestPayload, } from "../PaymentRequest/PaymentRequest"; import { TokenAmount } from "../Tokens"; import { Token } from "../Tokens/Token"; import { fetchTransactions } from "../Transactions/fetchTransactions"; import { TokenTransactionBase } from "../Transactions/types"; import { Address, ChainId, CHAINS_TO_RPC, CustomRPC, Interval, OverrideRPC, PageInfo, TimeFrame, TransactionOptions, WALLET_SERVICE_URL, } from "../constants"; import { ERC20_INTERFACE } from "../constants/contracts"; import { convertTimeframeToUnixSeconds } from "../utils"; export interface WalletConfig { eoa?: Address; smartWallet?: Address; mnemonic?: string; getMnemonic?: () => Promise; privateKey?: string; getPrivateKey?: () => Promise; bip39?: Bip39; } export interface NodeWalletTransaction { target: string; data: string; description: string; gasEst: number; } export interface ContractTransaction { target: string; contractInterface: Interface; method: string; params?: any[]; } export interface WalletOptions { defaultGasToken?: Address; customRpc?: CustomRPC; } /** * Representation of a wallet * * Can sign and send transactions. */ export abstract class Wallet { public address: string | undefined; public homeChain: ChainId; public rpc?: CustomRPC; public web3: Web3; public getMnemonic: (() => Promise) | undefined; private apiKey: string | undefined; public feeCurrency?: Address; public connection?: Connection; public wid?: number; /** * * @param apiKey string, API key required to authenticate payloads * @param homeChain homechain for the wallet. Default is Celo for now * @param address address of the wallet. EOA or smart wallet */ constructor( apiKey?: string, homeChain = ChainId.Celo, address?: string, options?: WalletOptions ) { this.address = address ? toChecksumAddress(address) : undefined; this.homeChain = homeChain; this.apiKey = apiKey; if (options?.customRpc) { OverrideRPC(homeChain, options.customRpc); } this.rpc = CHAINS_TO_RPC[homeChain]; if (this.rpc?.http) { this.web3 = new Web3(new Web3.providers.HttpProvider(this.rpc.http)); // } else if (this.rpc?.wss) { // this.web3 = new Web3(new Web3.providers.WebsocketProvider(this.rpc.wss)); } else { invariant(false, "No RPC provided within RPC object"); } this.feeCurrency = options?.defaultGasToken; if (homeChain === ChainId.Alfajores || homeChain === ChainId.Celo) { this.connection = new Connection(this.web3, new LocalWallet()); } } /** * * @param getTokens Callback to fetch a token given its address * @param opts Page options and currency code options * @returns List of transactions for the wallet, additionally with local value transacted */ public getHistoricalTransactions( getTokens: (address: string) => Token, opts?: { page?: number; perPage?: number; localCurrencyCode?: string; } ): Promise { warning(this.address, "Address required to fetch transactions"); if (!this.address) return new Promise((resolve) => resolve([] as TokenTransactionBase[])); return fetchTransactions(this.address, this.homeChain, getTokens, opts); } /** * Registers the wallet with the Node Finance Wallet Service. * * Will return error if wallet is already registered */ public async register() { invariant(this.address, "Address required to register"); invariant(this.apiKey, "Api key required to register wallet"); try { const resp = await axios.post<{ tid: number; wid: number }>( `${WALLET_SERVICE_URL}wallets`, { address: this.address, }, { headers: { "x-api-key": this.apiKey, }, } ); this.wid = resp.data.wid; } catch (_) { return this; } } /** * * Fetches details on the wallet from the Node Finace wallet service. Includes metadata set by developer. * * @returns Wallet object */ public async fetchFromWalletService() { try { const resp = await axios.get<{ tid: number; wid: number }>( `${WALLET_SERVICE_URL}wallets/${this.address}` ); this.wid = resp.data.wid; } catch (e) { await this.register(); } return this; } /** * * Sends a token from this wallet to any other address. * * @param token Token to send * @param amount Amount to send * @param recipient Recipient of transfer * @returns Receipt of transaction of sending the token */ public async transferToken( token: Token | Address, amount: BigNumber, recipient: Address ): Promise { if (typeof token === "string") { return await this.signAndSendContractTransactions([ { target: token, contractInterface: ERC20_INTERFACE, method: "transfer", params: [recipient, amount.toFixed(0)], }, ]); } else { return token.send(amount, this, recipient); } } /** * * Set the default gas currency. Only applicable on Celo and Alfajores, otherwise will fail * * @param tokenAddress Token address to use to pay gas fees. */ public setFeeCurrency(tokenAddress: Address) { invariant( this.homeChain === ChainId.Alfajores || this.homeChain === ChainId.Celo, "Can only set fee currency on Celo" ); this.feeCurrency = tokenAddress; } /** * * @param data Data and type definitions to sign * @returns Signature payload */ signTypedData(data: Mixed[]) { const encoded = this.web3.utils.encodePacked(...data); invariant(encoded, "Encoded paramters are null!"); return this.signMessage(encoded); } /** * * Handles repaying a portion, or all of, a payment request. * * @param request PaymentRequest object to fulfill * @param amount The total token amount to pay towards this payment request. Does not have to equal the remaining amount. * @returns Request after crediting payment, and receipt of payment */ fulfillPaymentRequest< T extends Record = Record >( request: PaymentRequest, amount: TokenAmount ): PromiEvent<{ request: PaymentRequest; receipt: { amountCredited: TokenAmount; isFullyRepaid: boolean; hash: string; rid: number; time: number; }; }> { const promiEvent = new PromiEvent<{ request: PaymentRequest; receipt: { amountCredited: TokenAmount; isFullyRepaid: boolean; hash: string; rid: number; time: number; }; }>((resolve, reject) => { this.transferToken(amount.token, amount.raw, request.info.payee) .then((receipt) => { promiEvent.emit("transfer", receipt); request .fulfill(receipt.transactionHash) .then((request) => { promiEvent.emit("confirmed", request); resolve(request); }) .catch(reject); }) .catch(reject); }); return promiEvent; } /** * * Authenticates and creates a new payment request * * @param from Address to request payment from * @param amount Token and amount to request * @param deadline Optional date that payment must be fulfilled * @param metadata Additional metadata to store regarding the request * @returns PaymentRequest object corresponding to the newly-created request */ public async requestPayment>( from: string, amount: TokenAmount, deadline?: Date, metadata?: T ): Promise> { return PaymentRequest.create(this.address ?? "", from, amount, this, { deadline, metadata, }); } /** * * Fetches payment requests for a wallet. Can fetch pending, completed, or all. Default pagination is page 0, * with 10 per page. * * @param getToken Callback to fetch a token given its address * @param opts Options for the query, including request type, page, and count per page * @returns List of payment requests for the wallet */ public async fetchPaymentRequests>( getToken: (address: string) => Token, opts?: { type?: "pending" | "completed" | "all"; page?: number; count?: number; } ): Promise<{ requests: PaymentRequest[]; pageInfo: PageInfo; }> { const resp = await axios.get< { type?: "pending" | "completed" | "all"; page?: number; count?: number; }, AxiosResponse<{ pagination: PageInfo; data: PaymentRequestPayload[]; }> >(`${WALLET_SERVICE_URL}wallets/${this.address}/payments/requests`, { params: opts, }); return { requests: resp.data.data.map((r) => PaymentRequest.deserialize(r, getToken) ), pageInfo: resp.data.pagination, }; } /** * * @param data Message to sign * @returns A Sign object, which contains all fields needed to recompute original signer, as well as the signed message */ abstract signMessage(data: string): Sign; /** * * @param attempt tracker to allow for re-attempts on server errors. * @returns true if success, false if unsuccessful */ async syncBackendPortfolioValue(): Promise { console.warn(`syncBackendPortfolio is deprecated.`); return true; } /** * * Fetches the wallet's historical portfolio for a given time period of resolution. Portfolio value is given in the declared * currency code. * * @param interval time interval between ticks * @param timeframe timeframe on which to view historical portfolio value * @param currency base currency code for the query * @returns an array of portfolio values at specific times, where the time in between ticks corresponds to interval */ async fetchBackendPortfolioValue( interval: Interval, timeframe: TimeFrame, currency = "usd" ): Promise<{ total: number; time: number }[] | undefined> { return this.fetchPortfolioRaw({ resolution: interval, startTime: Math.floor(Date.now() / 1000) - convertTimeframeToUnixSeconds(timeframe), chain: this.homeChain, currency: currency, }); } async fetchPortfolioRaw(opts: { resolution?: Interval; startTime?: number; endTime?: number; chain?: number; currency?: string; }) { invariant(this.address, "Address required tp fetch historical portfolio"); const resp = await axios.get<{ total: number; time: number }[]>( `${WALLET_SERVICE_URL}wallets/${ this.wid ?? this.address }/portfolio/historical`, { params: { chain: this.homeChain, ...opts, }, } ); return resp.data; } /** * * @param descriptions descriptions of transactions to sign and process * @returns transaction receipt from executed txn */ async signAndSendContractTransactions( descriptions: ContractTransaction[], opts?: TransactionOptions ): Promise { const address = this.address; const txns = descriptions.map( ({ contractInterface, method, params, target }) => { const fragment = contractInterface.getFunction(method); const data = contractInterface.encodeFunctionData(fragment, params); return { from: address, to: target, data, }; } ); const response = await this.signAndSendTransaction(txns, opts); return response; } /** * * @param transactions transaction descriptions in a more readable format * @returns transaction receipt */ async signAndSendNodeTransactions( transactions: NodeWalletTransaction[], opts?: TransactionOptions ): Promise { const txns = transactions.map((el) => ({ from: this.address, to: el.target, gas: el.gasEst, data: el.data, })); const resp = await this.signAndSendTransaction(txns, opts); return resp; } /** * * @param config Configuration object specifying details about the wallet * @returns Boolean flag indicating successful wallet load */ abstract _loadWallet(config: WalletConfig): Promise; /** * * @param transaction web3js transaction objects to sign and process * @returns transaction receipt */ abstract signAndSendTransaction( transaction: TransactionConfig[], opts?: TransactionOptions ): Promise; }