Skip to content

Commit

Permalink
chore: type txData in chain adapters (shapeshift#89)
Browse files Browse the repository at this point in the history
* chore: type txData in chain adapters

* chore: ergonomic types for chain adapters and type unit tests

* chore: simplify typescript black magic

* asset-service still needs ChainTypes.Litecoin

* move type assertion tests to separate file

* handle chain types that aren't in ChainFieldMap

Co-authored-by: Reid Rankin <[email protected]>
  • Loading branch information
0xdef1cafe and mrnerdhair authored Oct 7, 2021
1 parent 2356618 commit 17375d8
Show file tree
Hide file tree
Showing 14 changed files with 195 additions and 85 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"semantic-release-monorepo": "^7.0.5",
"ts-jest": "^27.0.5",
"ts-node": "^10.2.1",
"type-assertions": "^1.1.0",
"typescript": "^4.2.4"
},
"dependencies": {}
}
}
2 changes: 1 addition & 1 deletion packages/chain-adapters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"type-check": "tsc --project ./tsconfig.json --noEmit"
},
"dependencies": {
"@shapeshiftoss/hdwallet-core": "^1.15.5-alpha.0",
"@shapeshiftoss/hdwallet-core": "^1.16.3",
"@shapeshiftoss/hdwallet-native": "^1.15.5-alpha.1",
"@shapeshiftoss/types": "^1.0.0",
"axios": "^0.21.1",
Expand Down
4 changes: 3 additions & 1 deletion packages/chain-adapters/src/ChainAdapterManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ describe('ChainAdapterManager', () => {

it('should throw an error if no adapter is found', () => {
// @ts-ignore
expect(() => getCAM({ ripple: 'x' })).toThrow('No chain adapter for ripple')
expect(() => getCAM({ ripple: 'x' })).toThrow(
'ChainAdapterManager: cannot instantiate ripple chain adapter'
)
})
})

Expand Down
49 changes: 26 additions & 23 deletions packages/chain-adapters/src/ChainAdapterManager.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,27 @@
import { ChainAdapter } from './api'
import { ChainTypes } from '@shapeshiftoss/types'
import { ChainAdapter, isChainAdapterOfType } from './api'
import { EthereumChainAdapter } from './ethereum'
import { UnchainedProvider } from './providers'

export type UnchainedUrls = Record<ChainTypes.Ethereum, string>

const chainAdapterMap = {
[ChainTypes.Ethereum]: EthereumChainAdapter
} as const
export type UnchainedUrls = Partial<Record<ChainTypes, string>>

export class ChainAdapterManager {
private supported: Map<ChainTypes, () => ChainAdapter> = new Map()
private instances: Map<string, ChainAdapter> = new Map()
private supported: Map<ChainTypes, () => ChainAdapter<ChainTypes>> = new Map()
private instances: Map<ChainTypes, ChainAdapter<ChainTypes>> = new Map()

constructor(unchainedUrls: UnchainedUrls) {
if (!unchainedUrls) {
throw new Error('Blockchain urls required')
}
// TODO(0xdef1cafe): loosen this from ChainTypes.Ethereum to ChainTypes once we implement more than ethereum
;(Object.keys(unchainedUrls) as Array<ChainTypes.Ethereum>).forEach(
(key: ChainTypes.Ethereum) => {
const Adapter = chainAdapterMap[key]
if (!Adapter) throw new Error(`No chain adapter for ${key}`)
this.addChain(
key,
() => new Adapter({ provider: new UnchainedProvider(unchainedUrls[key]) })
)
;(Object.entries(unchainedUrls) as Array<[keyof UnchainedUrls, string]>).forEach(
([type, baseURL]) => {
switch (type) {
case ChainTypes.Ethereum: {
const provider = new UnchainedProvider({ baseURL, type })
return this.addChain(type, () => new EthereumChainAdapter({ provider }))
}
}
throw new Error(`ChainAdapterManager: cannot instantiate ${type} chain adapter`)
}
)
}
Expand All @@ -40,7 +36,7 @@ export class ChainAdapterManager {
* @param {ChainTypes} network - Coin/network symbol from Asset query
* @param {Function} factory - A function that returns a ChainAdapter instance
*/
addChain(chain: ChainTypes, factory: () => ChainAdapter): void {
addChain<T extends ChainTypes>(chain: T, factory: () => ChainAdapter<T>): void {
if (typeof chain !== 'string' || typeof factory !== 'function') {
throw new Error('Parameter validation error')
}
Expand All @@ -51,25 +47,32 @@ export class ChainAdapterManager {
return Array.from(this.supported.keys())
}

getSupportedAdapters(): Array<() => ChainAdapter> {
getSupportedAdapters(): Array<() => ChainAdapter<ChainTypes>> {
return Array.from(this.supported.values())
}

/*** Get a ChainAdapter instance for a network */
byChain(chain: ChainTypes): ChainAdapter {
byChain<T extends ChainTypes>(chain: T): ChainAdapter<T> {
let adapter = this.instances.get(chain)
if (!adapter) {
const factory = this.supported.get(chain)
if (factory) {
this.instances.set(chain, factory())
adapter = this.instances.get(chain)
adapter = factory()
if (!adapter || !isChainAdapterOfType(chain, adapter)) {
throw new Error(
`Adapter type [${
adapter ? adapter.getType() : typeof adapter
}] does not match requested type [${chain}]`
)
}
this.instances.set(chain, adapter)
}
}

if (!adapter) {
throw new Error(`Network [${chain}] is not supported`)
}

return adapter
return adapter as ChainAdapter<T>
}
}
32 changes: 20 additions & 12 deletions packages/chain-adapters/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Params } from './types/Params.type'
import { ETHSignTx } from '@shapeshiftoss/hdwallet-core'
import {
BalanceResponse,
BuildSendTxInput,
ChainTxType,
ChainTypes,
FeeDataEstimate,
GetAddressInput,
Expand All @@ -11,30 +10,39 @@ import {
TxHistoryResponse,
ValidAddressResult
} from '@shapeshiftoss/types'
import { BlockchainProvider } from './types/BlockchainProvider.type'
import { Params } from './types/Params.type'

export interface ChainAdapter {
export const isChainAdapterOfType = <U extends ChainTypes>(
chainType: U,
x: ChainAdapter<ChainTypes>
): x is ChainAdapter<U> => {
return x.getType() === chainType
}

export interface ChainAdapterFactory<T extends ChainTypes> {
new ({ provider }: { provider: BlockchainProvider<T> }): ChainAdapter<T>
}

export interface ChainAdapter<T extends ChainTypes> {
/**
* Get type of adapter
*/
getType(): ChainTypes
getType(): T

/**
* Get the balance of an address
*/
getBalance(address: string): Promise<BalanceResponse | undefined>

/**
* Get Transaction History for an address
*/
getTxHistory(address: string, params?: Params): Promise<TxHistoryResponse>
getBalance(address: string): Promise<BalanceResponse>
getTxHistory(address: string, params?: Params): Promise<TxHistoryResponse<T>>

buildSendTransaction(
input: BuildSendTxInput
): Promise<{ txToSign: ETHSignTx; estimatedFees: FeeDataEstimate }>
): Promise<{ txToSign: ChainTxType<T>; estimatedFees: FeeDataEstimate }>

getAddress(input: GetAddressInput): Promise<string>

signTransaction(signTxInput: SignTxInput): Promise<string>
signTransaction(signTxInput: SignTxInput<ChainTxType<T>>): Promise<string>

getFeeData(input: Partial<GetFeeDataInput>): Promise<FeeDataEstimate>

Expand Down
29 changes: 16 additions & 13 deletions packages/chain-adapters/src/ethereum/EthereumChainAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import erc20Abi from './erc20Abi.json'
import { ChainAdapter } from '..'

export type EthereumChainAdapterDependencies = {
provider: BlockchainProvider
provider: BlockchainProvider<ChainTypes.Ethereum>
}

type ZrxFeeResult = {
Expand Down Expand Up @@ -56,18 +56,18 @@ async function getErc20Data(to: string, value: string, contractAddress?: string)
return callData || ''
}

export class EthereumChainAdapter implements ChainAdapter {
private readonly provider: BlockchainProvider
export class EthereumChainAdapter implements ChainAdapter<ChainTypes.Ethereum> {
private readonly provider: BlockchainProvider<ChainTypes.Ethereum>

constructor(deps: EthereumChainAdapterDependencies) {
this.provider = deps.provider
}

getType = (): ChainTypes => {
getType(): ChainTypes.Ethereum {
return ChainTypes.Ethereum
}

getBalance = async (address: string): Promise<BalanceResponse | undefined> => {
async getBalance(address: string): Promise<BalanceResponse> {
try {
const balanceData = await this.provider.getBalance(address)
return balanceData
Expand All @@ -76,17 +76,20 @@ export class EthereumChainAdapter implements ChainAdapter {
}
}

getTxHistory = async (address: string, params?: Params): Promise<TxHistoryResponse> => {
async getTxHistory(
address: string,
params?: Params
): Promise<TxHistoryResponse<ChainTypes.Ethereum>> {
try {
return this.provider.getTxHistory(address, params)
} catch (err) {
return ErrorHandler(err)
}
}

buildSendTransaction = async (
async buildSendTransaction(
tx: BuildSendTxInput
): Promise<{ txToSign: ETHSignTx; estimatedFees: FeeDataEstimate }> => {
): Promise<{ txToSign: ETHSignTx; estimatedFees: FeeDataEstimate }> {
try {
const { to, erc20ContractAddress, path, wallet, fee, limit } = tx
const value = erc20ContractAddress ? '0' : tx?.value
Expand Down Expand Up @@ -129,7 +132,7 @@ export class EthereumChainAdapter implements ChainAdapter {
}
}

signTransaction = async (signTxInput: SignTxInput): Promise<string> => {
async signTransaction(signTxInput: SignTxInput<ETHSignTx>): Promise<string> {
try {
const { txToSign, wallet } = signTxInput
const signedTx = await (wallet as ETHWallet).ethSignTx(txToSign)
Expand All @@ -142,16 +145,16 @@ export class EthereumChainAdapter implements ChainAdapter {
}
}

broadcastTransaction = async (hex: string) => {
async broadcastTransaction(hex: string) {
return this.provider.broadcastTx(hex)
}

getFeeData = async ({
async getFeeData({
to,
from,
contractAddress,
value
}: GetFeeDataInput): Promise<FeeDataEstimate> => {
}: GetFeeDataInput): Promise<FeeDataEstimate> {
const { data: responseData } = await axios.get<ZrxGasApiResponse>('https://gas.api.0x.org/')
const fees = responseData.result.find((result) => result.source === 'MEDIAN')

Expand Down Expand Up @@ -187,7 +190,7 @@ export class EthereumChainAdapter implements ChainAdapter {
}
}

getAddress = async (input: GetAddressInput): Promise<string> => {
async getAddress(input: GetAddressInput): Promise<string> {
const { wallet, path } = input
const addressNList = bip32ToAddressNList(path)
const ethAddress = await (wallet as ETHWallet).ethGetAddress({
Expand Down
32 changes: 28 additions & 4 deletions packages/chain-adapters/src/providers/UnchainedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import axios, { AxiosInstance } from 'axios'
import { Params } from '../types/Params.type'
import { BlockchainProvider } from '../types/BlockchainProvider.type'
import {
ChainTypes,
TxHistoryResponse,
BalanceResponse,
BroadcastTxResponse,
Expand All @@ -18,19 +19,42 @@ const axiosClient = (baseURL: string) =>
})
})

export class UnchainedProvider implements BlockchainProvider {
export function isUnchainedProviderOfType<U extends ChainTypes>(
chainType: U,
x: UnchainedProvider<ChainTypes>
): x is UnchainedProvider<U> {
return x.getType() === chainType
}

export interface UnchainedProviderFactory<T extends ChainTypes> {
new (baseURL: string): UnchainedProvider<T>
}

type UnchainedProviderDeps<T> = {
baseURL: string
type: T
}

export class UnchainedProvider<T extends ChainTypes> implements BlockchainProvider<T> {
axios: AxiosInstance
constructor(baseURL: string) {
type: T

constructor({ baseURL, type }: UnchainedProviderDeps<T>) {
this.axios = axiosClient(baseURL)
this.type = type
}

getType(): T {
return this.type
}

async getBalance(address: string): Promise<BalanceResponse | undefined> {
async getBalance(address: string): Promise<BalanceResponse> {
const { data } = await this.axios.get<BalanceResponse>(`/balance/${address}`)
return data
}

async getTxHistory(address: string, params?: Params) {
const { data } = await this.axios.get<TxHistoryResponse>(`/txs/${address}`, {
const { data } = await this.axios.get<TxHistoryResponse<T>>(`/account/${address}/txs`, {
params: params
})
return data
Expand Down
13 changes: 9 additions & 4 deletions packages/chain-adapters/src/types/BlockchainProvider.type.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { Params } from './Params.type'
import { TxHistoryResponse, FeeEstimateInput, BalanceResponse } from '@shapeshiftoss/types'
import {
ChainTypes,
TxHistoryResponse,
FeeEstimateInput,
BalanceResponse
} from '@shapeshiftoss/types'

export interface BlockchainProvider {
getBalance: (address: string) => Promise<BalanceResponse | undefined>
getTxHistory: (address: string, params?: Params) => Promise<TxHistoryResponse>
export interface BlockchainProvider<T extends ChainTypes> {
getBalance: (address: string) => Promise<BalanceResponse>
getTxHistory: (address: string, params?: Params) => Promise<TxHistoryResponse<T>>
getNonce: (address: string) => Promise<number>
broadcastTx: (hex: string) => Promise<string>
getFeePrice: () => Promise<string>
Expand Down
2 changes: 1 addition & 1 deletion packages/swapper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"dependencies": {
"@shapeshiftoss/chain-adapters": "^1.0.0",
"@shapeshiftoss/hdwallet-core": "^1.16.2-alpha.0",
"@shapeshiftoss/hdwallet-core": "^1.16.3",
"@shapeshiftoss/types": "^1.0.0",
"axios": "^0.21.4",
"web3": "^1.5.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function approvalNeeded(
throw new SwapError('ZrxSwapper:approvalNeeded only Ethereum chain type is supported')
}

const adapter: ChainAdapter = adapterManager.byChain(sellAsset.chain)
const adapter: ChainAdapter<ChainTypes> = adapterManager.byChain(sellAsset.chain)
const receiveAddress = await adapter.getAddress({ wallet, path: DEFAULT_ETH_PATH })

/**
Expand Down
10 changes: 8 additions & 2 deletions packages/swapper/src/swappers/zrx/buildQuoteTx/buildQuoteTx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AxiosResponse } from 'axios'
import * as rax from 'retry-axios'
import { ChainAdapter } from '@shapeshiftoss/chain-adapters'
import { SwapError } from '../../..'
import { Quote, QuoteResponse, BuildQuoteTxInput } from '@shapeshiftoss/types'
import { ChainTypes, Quote, QuoteResponse, BuildQuoteTxInput } from '@shapeshiftoss/types'
import { ZrxSwapperDeps } from '../ZrxSwapper'
import { applyAxiosRetry } from '../utils/applyAxiosRetry'
import { erc20AllowanceAbi } from '../utils/abi/erc20Allowance-abi'
Expand Down Expand Up @@ -58,7 +58,13 @@ export async function buildQuoteTx(
)
}

const adapter: ChainAdapter = adapterManager.byChain(buyAsset.chain)
if (buyAsset.chain !== ChainTypes.Ethereum) {
throw new SwapError(
`ZrxSwapper:buildQuoteTx buyAsset must be on chain [${ChainTypes.Ethereum}]`
)
}

const adapter: ChainAdapter<ChainTypes.Ethereum> = adapterManager.byChain(buyAsset.chain)
const receiveAddress = await adapter.getAddress({ wallet, path: DEFAULT_ETH_PATH })

if (new BigNumber(slippage || 0).gt(MAX_SLIPPAGE)) {
Expand Down
Loading

0 comments on commit 17375d8

Please sign in to comment.