From 77bf3620c681a4ff77bc2e8988ef8cc08e9e03cc Mon Sep 17 00:00:00 2001 From: Evan Gray Date: Mon, 4 Oct 2021 11:52:11 +0000 Subject: [PATCH] bridge_ui, sdk/js: support native terra Change-Id: I1125030b0f09b29567c19ca9adec7866695e2262 --- .../TerraSourceTokenSelector.tsx | 64 ++++++++++++++++--- bridge_ui/src/hooks/useTerraNativeBalances.ts | 43 +++++++++++++ bridge_ui/src/hooks/useTerraTokenMap.ts | 1 + bridge_ui/src/utils/terra.ts | 22 +++++++ sdk/js/src/terra/address.ts | 19 ++++++ sdk/js/src/token_bridge/attest.ts | 14 +++- sdk/js/src/token_bridge/getOriginalAsset.ts | 9 ++- sdk/js/src/token_bridge/transfer.ts | 53 ++++++++------- sdk/js/src/utils/array.ts | 9 ++- 9 files changed, 196 insertions(+), 38 deletions(-) create mode 100644 bridge_ui/src/hooks/useTerraNativeBalances.ts diff --git a/bridge_ui/src/components/TokenSelectors/TerraSourceTokenSelector.tsx b/bridge_ui/src/components/TokenSelectors/TerraSourceTokenSelector.tsx index 55ce4bbb..df1c845b 100644 --- a/bridge_ui/src/components/TokenSelectors/TerraSourceTokenSelector.tsx +++ b/bridge_ui/src/components/TokenSelectors/TerraSourceTokenSelector.tsx @@ -1,3 +1,4 @@ +import { isNativeDenom } from "@certusone/wormhole-sdk"; import { CircularProgress, createStyles, @@ -14,12 +15,19 @@ import { import { formatUnits } from "ethers/lib/utils"; import React, { useCallback, useMemo, useState } from "react"; import { createParsedTokenAccount } from "../../hooks/useGetSourceParsedTokenAccounts"; +import useTerraNativeBalances from "../../hooks/useTerraNativeBalances"; import useTerraTokenMap, { TerraTokenMetadata, } from "../../hooks/useTerraTokenMap"; import { ParsedTokenAccount } from "../../store/transferSlice"; import { TERRA_HOST } from "../../utils/consts"; import { shortenAddress } from "../../utils/solana"; +import { + formatNativeDenom, + formatTerraNativeBalance, + getNativeTerraIcon, + NATIVE_TERRA_DECIMALS, +} from "../../utils/terra"; import OffsetButton from "./OffsetButton"; import RefreshButtonWrapper from "./RefreshButtonWrapper"; @@ -126,11 +134,25 @@ export default function TerraSourceTokenSelector( const isLoading = tokenMap?.isFetching || false; + const { balances } = useTerraNativeBalances(terraWallet?.walletAddress); + const terraTokenArray = useMemo(() => { + const balancesItems = balances + ? Object.keys(balances).map( + (denom) => + ({ + protocol: "native", + symbol: formatNativeDenom(denom), + token: denom, + icon: getNativeTerraIcon(formatNativeDenom(denom)), + balance: balances[denom], + } as TerraTokenMetadata) + ) + : []; const values = tokenMap.data?.mainnet; - const items = Object.values(values || {}); - return items || []; - }, [tokenMap]); + const tokenMapItems = Object.values(values || {}) || []; + return [...balancesItems, ...tokenMapItems]; + }, [balances, tokenMap]); const valueToOption = (fromProps: ParsedTokenAccount | undefined | null) => { if (!fromProps) return null; @@ -154,15 +176,32 @@ export default function TerraSourceTokenSelector( return; } setAdvancedModeError(""); - lookupTerraAddress(address, terraWallet).then( - (result) => { - onChange(result); - }, - (error) => { + if (isNativeDenom(address)) { + if (balances && balances[address]) { + onChange( + createParsedTokenAccount( + terraWallet.walletAddress, + address, + balances[address], + NATIVE_TERRA_DECIMALS, + Number(formatUnits(balances[address], NATIVE_TERRA_DECIMALS)), + formatUnits(balances[address], NATIVE_TERRA_DECIMALS), + formatNativeDenom(address) + ) + ); + } else { setAdvancedModeError("Unable to retrieve that address."); } - ); - setAdvancedModeError(""); + } else { + lookupTerraAddress(address, terraWallet).then( + (result) => { + onChange(result); + }, + (error) => { + setAdvancedModeError("Unable to retrieve that address."); + } + ); + } }; const filterConfig = createFilterOptions({ @@ -191,6 +230,11 @@ export default function TerraSourceTokenSelector(
{option.token} + {option.balance ? ( + + {formatTerraNativeBalance(option.balance)} + + ) : null}
); diff --git a/bridge_ui/src/hooks/useTerraNativeBalances.ts b/bridge_ui/src/hooks/useTerraNativeBalances.ts new file mode 100644 index 00000000..70e1d878 --- /dev/null +++ b/bridge_ui/src/hooks/useTerraNativeBalances.ts @@ -0,0 +1,43 @@ +import { LCDClient } from "@terra-money/terra.js"; +import { useEffect, useMemo, useState } from "react"; +import { TERRA_HOST } from "../utils/consts"; + +export interface TerraNativeBalances { + [index: string]: string; +} + +export default function useTerraNativeBalances(walletAddress?: string) { + const [isLoading, setIsLoading] = useState(true); + const [balances, setBalances] = useState({}); + useEffect(() => { + if (walletAddress) { + setIsLoading(true); + setBalances(undefined); + const lcd = new LCDClient(TERRA_HOST); + lcd.bank + .balance(walletAddress) + .then((coins) => { + // coins doesn't support reduce + const balancePairs = coins.map(({ amount, denom }) => [ + denom, + amount, + ]); + const balance = balancePairs.reduce((obj, current) => { + obj[current[0].toString()] = current[1].toString(); + return obj; + }, {} as TerraNativeBalances); + setIsLoading(false); + setBalances(balance); + }) + .catch((e) => { + setIsLoading(false); + setBalances(undefined); + }); + } else { + setIsLoading(false); + setBalances(undefined); + } + }, [walletAddress]); + const value = useMemo(() => ({ isLoading, balances }), [isLoading, balances]); + return value; +} diff --git a/bridge_ui/src/hooks/useTerraTokenMap.ts b/bridge_ui/src/hooks/useTerraTokenMap.ts index fdf872d5..61233028 100644 --- a/bridge_ui/src/hooks/useTerraTokenMap.ts +++ b/bridge_ui/src/hooks/useTerraTokenMap.ts @@ -16,6 +16,7 @@ export type TerraTokenMetadata = { symbol: string; token: string; icon: string; + balance?: string; // populated by native tokens, could move to a type that extends this }; export type TerraTokenMap = { diff --git a/bridge_ui/src/utils/terra.ts b/bridge_ui/src/utils/terra.ts index effa1454..138d275f 100644 --- a/bridge_ui/src/utils/terra.ts +++ b/bridge_ui/src/utils/terra.ts @@ -1,7 +1,29 @@ +import { isNativeTerra } from "@certusone/wormhole-sdk"; +import { formatUnits } from "@ethersproject/units"; import { LCDClient } from "@terra-money/terra.js"; import { TxResult } from "@terra-money/wallet-provider"; +// import { TerraTokenMetadata } from "../hooks/useTerraTokenMap"; import { TERRA_HOST } from "./consts"; +export const NATIVE_TERRA_DECIMALS = 6; + +export const getNativeTerraIcon = (symbol = "") => + `https://assets.terra.money/icon/60/${symbol}.png`; + +// inspired by https://github.com/terra-money/station/blob/dca7de43958ce075c6e46605622203b9859b0e14/src/lib/utils/format.ts#L38 +export const formatNativeDenom = (denom = ""): string => { + const unit = denom.slice(1).toUpperCase(); + const isValidTerra = isNativeTerra(denom); + return denom === "uluna" + ? "Luna" + : isValidTerra + ? unit.slice(0, 2) + "T" + : ""; +}; + +export const formatTerraNativeBalance = (balance = ""): string => + formatUnits(balance, 6); + export async function waitForTerraExecution(transaction: TxResult) { const lcd = new LCDClient(TERRA_HOST); let info; diff --git a/sdk/js/src/terra/address.ts b/sdk/js/src/terra/address.ts index 03a0452c..e7730f7f 100644 --- a/sdk/js/src/terra/address.ts +++ b/sdk/js/src/terra/address.ts @@ -1,3 +1,4 @@ +import { zeroPad } from "@ethersproject/bytes"; import { bech32 } from "bech32"; export function canonicalAddress(humanAddress: string) { @@ -6,3 +7,21 @@ export function canonicalAddress(humanAddress: string) { export function humanAddress(canonicalAddress: Uint8Array) { return bech32.encode("terra", bech32.toWords(canonicalAddress)); } + +// from https://github.com/terra-money/station/blob/dca7de43958ce075c6e46605622203b9859b0e14/src/lib/utils/is.ts#L12 +export const isNativeTerra = (string = "") => + string.startsWith("u") && string.length === 4; + +// from https://github.com/terra-money/station/blob/dca7de43958ce075c6e46605622203b9859b0e14/src/lib/utils/is.ts#L20 +export const isNativeDenom = (string = "") => + isNativeTerra(string) || string === "uluna"; + +export function buildNativeId(denom: string): Uint8Array { + const bytes = []; + for (let i = 0; i < denom.length; i++) { + bytes.push(denom.charCodeAt(i)); + } + const padded = zeroPad(new Uint8Array(bytes), 32); + padded[0] = 1; + return padded; +} diff --git a/sdk/js/src/token_bridge/attest.ts b/sdk/js/src/token_bridge/attest.ts index b46cb65b..08c7227f 100644 --- a/sdk/js/src/token_bridge/attest.ts +++ b/sdk/js/src/token_bridge/attest.ts @@ -5,6 +5,7 @@ import { getBridgeFeeIx, ixFromRust } from "../solana"; import { createNonce } from "../utils/createNonce"; import { ConnectedWallet as TerraConnectedWallet } from "@terra-money/wallet-provider"; import { MsgExecuteContract } from "@terra-money/terra.js"; +import { isNativeDenom } from ".."; export async function attestFromEth( tokenBridgeAddress: string, @@ -20,9 +21,10 @@ export async function attestFromEth( export async function attestFromTerra( tokenBridgeAddress: string, wallet: TerraConnectedWallet, - asset: string, + asset: string ) { const nonce = Math.round(Math.random() * 100000); + const isNativeAsset = isNativeDenom(asset); return await wallet.post({ msgs: [ new MsgExecuteContract( @@ -30,7 +32,15 @@ export async function attestFromTerra( tokenBridgeAddress, { create_asset_meta: { - asset_address: asset, + asset_info: isNativeAsset + ? { + native_token: { denom: asset }, + } + : { + token: { + contract_addr: asset, + }, + }, nonce: nonce, }, }, diff --git a/sdk/js/src/token_bridge/getOriginalAsset.ts b/sdk/js/src/token_bridge/getOriginalAsset.ts index bde4d019..318fdc3a 100644 --- a/sdk/js/src/token_bridge/getOriginalAsset.ts +++ b/sdk/js/src/token_bridge/getOriginalAsset.ts @@ -10,7 +10,7 @@ import { } from "../utils"; import { getIsWrappedAssetEth } from "./getIsWrappedAsset"; import { LCDClient } from "@terra-money/terra.js"; -import { canonicalAddress } from "../terra"; +import { buildNativeId, canonicalAddress, isNativeDenom } from "../terra"; export interface WormholeWrappedInfo { isWrapped: boolean; @@ -59,6 +59,13 @@ export async function getOriginalAssetTerra( client: LCDClient, wrappedAddress: string ): Promise { + if (isNativeDenom(wrappedAddress)) { + return { + isWrapped: false, + chainId: CHAIN_ID_TERRA, + assetAddress: buildNativeId(wrappedAddress), + }; + } try { const result: { asset_address: string; diff --git a/sdk/js/src/token_bridge/transfer.ts b/sdk/js/src/token_bridge/transfer.ts index fab5640a..ba7b0cae 100644 --- a/sdk/js/src/token_bridge/transfer.ts +++ b/sdk/js/src/token_bridge/transfer.ts @@ -7,7 +7,8 @@ import { Transaction, } from "@solana/web3.js"; import { MsgExecuteContract } from "@terra-money/terra.js"; -import { ethers } from "ethers"; +import { BigNumber, ethers } from "ethers"; +import { isNativeDenom } from ".."; import { Bridge__factory, TokenImplementation__factory, @@ -90,24 +91,10 @@ export async function transferFromTerra( recipientAddress: Uint8Array ) { const nonce = Math.round(Math.random() * 100000); - const isNativeAsset = ["uluna"].includes(tokenAddress); - return [ - new MsgExecuteContract( - walletAddress, - tokenAddress, - { - increase_allowance: { - spender: tokenBridgeAddress, - amount: amount, - expires: { - never: {}, - }, - }, - }, - { uluna: 10000 } - ), - isNativeAsset - ? new MsgExecuteContract( + const isNativeAsset = isNativeDenom(tokenAddress); + return isNativeAsset + ? [ + new MsgExecuteContract( walletAddress, tokenBridgeAddress, { @@ -126,9 +113,29 @@ export async function transferFromTerra( nonce: nonce, }, }, - { uluna: 10000, [tokenAddress]: amount } - ) - : new MsgExecuteContract( + { + uluna: BigNumber.from("10000") + .add(BigNumber.from(amount)) + .toString(), + } + ), + ] + : [ + new MsgExecuteContract( + walletAddress, + tokenAddress, + { + increase_allowance: { + spender: tokenBridgeAddress, + amount: amount, + expires: { + never: {}, + }, + }, + }, + { uluna: 10000 } + ), + new MsgExecuteContract( walletAddress, tokenBridgeAddress, { @@ -149,7 +156,7 @@ export async function transferFromTerra( }, { uluna: 10000 } ), - ]; + ]; } export async function transferNativeSol( diff --git a/sdk/js/src/utils/array.ts b/sdk/js/src/utils/array.ts index c5bbf596..3fb3132c 100644 --- a/sdk/js/src/utils/array.ts +++ b/sdk/js/src/utils/array.ts @@ -6,8 +6,11 @@ import { } from "./consts"; import { humanAddress } from "../terra"; import { PublicKey } from "@solana/web3.js"; -import { hexValue, hexZeroPad } from "ethers/lib/utils"; +import { hexValue, hexZeroPad, stripZeros } from "ethers/lib/utils"; +export const isHexNativeTerra = (h: string) => h.startsWith("01"); +export const nativeTerraHexToDenom = (h: string) => + Buffer.from(stripZeros(hexToUint8Array(h.substr(2)))).toString("ascii"); export const uint8ArrayToHex = (a: Uint8Array) => Buffer.from(a).toString("hex"); export const hexToUint8Array = (h: string) => @@ -21,7 +24,9 @@ export const hexToNativeString = (h: string | undefined, c: ChainId) => { : c === CHAIN_ID_ETH ? hexZeroPad(hexValue(hexToUint8Array(h)), 20) : c === CHAIN_ID_TERRA - ? humanAddress(hexToUint8Array(h.substr(24))) // terra expects 20 bytes, not 32 + ? isHexNativeTerra(h) + ? nativeTerraHexToDenom(h) + : humanAddress(hexToUint8Array(h.substr(24))) // terra expects 20 bytes, not 32 : h; } catch (e) {} return undefined;