Add read support for ERC20 tokens

This commit is contained in:
Simon Warta 2020-02-09 19:27:38 +01:00
parent 6e83bd6459
commit df32bf6d95
11 changed files with 197 additions and 81 deletions

View File

@ -43,6 +43,7 @@
"@iov/crypto": "^2.0.0-alpha.7",
"@iov/encoding": "^2.0.0-alpha.7",
"@iov/stream": "^2.0.0-alpha.7",
"@iov/utils": "^2.0.0-alpha.7",
"fast-deep-equal": "^3.1.1",
"readonly-date": "^1.0.0",
"xstream": "^11.11.0"

View File

@ -14,6 +14,7 @@ import {
import { Secp256k1 } from "@iov/crypto";
import { Encoding } from "@iov/encoding";
import { HdPaths, Secp256k1HdWallet, UserProfile } from "@iov/keycontrol";
import { assert } from "@iov/utils";
import { CosmWasmCodec, cosmWasmCodec } from "./cosmwasmcodec";
import { CosmWasmConnection, TokenConfiguration } from "./cosmwasmconnection";
@ -33,21 +34,30 @@ describe("CosmWasmConnection", () => {
const httpUrl = "http://localhost:1317";
const defaultChainId = "cosmos:testing" as ChainId;
const defaultEmptyAddress = "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r" as Address;
const defaultAddress = "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6" as Address;
const defaultPubkey = {
algo: Algorithm.Secp256k1,
data: fromBase64("A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ") as PubkeyBytes,
};
const faucetMnemonic =
"economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone";
const faucetPath = HdPaths.cosmos(0);
const defaultRecipient = "cosmos1t70qnpr0az8tf7py83m4ue5y89w58lkjmx0yq2" as Address;
const faucetAccount = {
pubkey: {
algo: Algorithm.Secp256k1,
data: fromBase64("A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ") as PubkeyBytes,
},
address: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6" as Address,
};
const unusedAccount = {
pubkey: {
algo: Algorithm.Secp256k1,
data: fromBase64("ArkCaFUJ/IH+vKBmNRCdUVl3mCAhbopk9jjW4Ko4OfRQ") as PubkeyBytes,
},
address: "cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u" as Address,
};
const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix;
// this is for wasmd blockchain
const defaultTokens: TokenConfiguration = {
bank: [
const defaultConfig: TokenConfiguration = {
bankTokens: [
{
fractionalDigits: 6,
name: "Fee Token",
@ -61,12 +71,26 @@ describe("CosmWasmConnection", () => {
denom: "ustake",
},
],
erc20Tokens: [
{
contractAddress: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5",
fractionalDigits: 5,
ticker: "ASH",
name: "Ash Token",
},
{
contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd",
fractionalDigits: 0,
ticker: "BASH",
name: "Bash Token",
},
],
};
describe("establish", () => {
it("can connect to Cosmos via http", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
expect(connection).toBeTruthy();
connection.disconnect();
});
@ -75,7 +99,7 @@ describe("CosmWasmConnection", () => {
describe("chainId", () => {
it("displays the chain ID", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const chainId = connection.chainId();
expect(chainId).toEqual(defaultChainId);
connection.disconnect();
@ -85,7 +109,7 @@ describe("CosmWasmConnection", () => {
describe("height", () => {
it("displays the current height", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const height = await connection.height();
expect(height).toBeGreaterThan(0);
connection.disconnect();
@ -95,7 +119,7 @@ describe("CosmWasmConnection", () => {
describe("getToken", () => {
it("displays a given token", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const token = await connection.getToken("COSM" as TokenTicker);
expect(token).toEqual({
fractionalDigits: 6,
@ -107,7 +131,7 @@ describe("CosmWasmConnection", () => {
it("resolves to undefined if the token is not supported", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const token = await connection.getToken("whatever" as TokenTicker);
expect(token).toBeUndefined();
connection.disconnect();
@ -117,10 +141,19 @@ describe("CosmWasmConnection", () => {
describe("getAllTokens", () => {
it("resolves to a list of all supported tokens", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const tokens = await connection.getAllTokens();
// TODO: make this more flexible
expect(tokens).toEqual([
{
fractionalDigits: 5,
tokenName: "Ash Token",
tokenTicker: "ASH" as TokenTicker,
},
{
fractionalDigits: 0,
tokenName: "Bash Token",
tokenTicker: "BASH" as TokenTicker,
},
{
fractionalDigits: 6,
tokenName: "Fee Token",
@ -139,7 +172,7 @@ describe("CosmWasmConnection", () => {
describe("identifier", () => {
it("calculates tx hash from PostableBytes", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const postable = cosmWasmCodec.bytesToPost(signedTxJson);
const id = await connection.identifier(postable);
expect(id).toMatch(/^[0-9A-F]{64}$/);
@ -150,7 +183,7 @@ describe("CosmWasmConnection", () => {
describe("getAccount", () => {
it("gets an empty account by address", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const account = await connection.getAccount({ address: defaultEmptyAddress });
expect(account).toBeUndefined();
connection.disconnect();
@ -158,35 +191,50 @@ describe("CosmWasmConnection", () => {
it("gets an account by address", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const account = await connection.getAccount({ address: defaultAddress });
if (account === undefined) {
throw new Error("Expected account not to be undefined");
}
expect(account.address).toEqual(defaultAddress);
// Undefined until we sign a transaction (on multiple runs against one server this will be set), allow both
if (account.pubkey !== undefined) {
expect(account.pubkey).toEqual(defaultPubkey);
}
// Starts with two tokens
expect(account.balance.length).toEqual(2);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const account = await connection.getAccount({ address: unusedAccount.address });
assert(account, "Account must be defined");
expect(account.address).toEqual(unusedAccount.address);
expect(account.pubkey).toBeUndefined();
expect(account.balance).toEqual([
{
tokenTicker: "ASH" as TokenTicker,
quantity: "12812345",
fractionalDigits: 5,
},
{
tokenTicker: "BASH" as TokenTicker,
quantity: "42",
fractionalDigits: 0,
},
{
tokenTicker: "COSM" as TokenTicker,
quantity: "1000000000",
fractionalDigits: 6,
},
{
tokenTicker: "STAKE" as TokenTicker,
quantity: "1000000000",
fractionalDigits: 6,
},
]);
connection.disconnect();
});
it("gets an account by pubkey", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const account = await connection.getAccount({ pubkey: defaultPubkey });
if (account === undefined) {
throw new Error("Expected account not to be undefined");
}
expect(account.address).toEqual(defaultAddress);
// Undefined until we sign a transaction (on multiple runs against one server this will be set), allow both
if (account.pubkey !== undefined) {
expect(account.pubkey).toEqual(defaultPubkey);
}
// Starts with two tokens
expect(account.balance.length).toEqual(2);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const byAddress = await connection.getAccount({ address: unusedAccount.address });
const byPubkey = await connection.getAccount({ pubkey: unusedAccount.pubkey });
expect(byPubkey).toEqual(byAddress); // above we verified that by address works as expected
connection.disconnect();
});
it("has a pubkey when getting account with transactions", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const account = await connection.getAccount({ address: faucetAccount.address });
expect(account?.pubkey).toEqual(faucetAccount.pubkey);
connection.disconnect();
});
});
@ -194,7 +242,7 @@ describe("CosmWasmConnection", () => {
describe("integration tests", () => {
it("can post and get a transaction", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const profile = new UserProfile();
const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucetMnemonic));
const faucet = await profile.createIdentity(wallet.id, defaultChainId, faucetPath);
@ -214,7 +262,7 @@ describe("CosmWasmConnection", () => {
});
const nonce = await connection.getNonce({ address: faucetAddress });
// TODO: we need to use custom codecs everywhere
const codec = new CosmWasmCodec(defaultPrefix, defaultTokens.bank);
const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens);
const signed = await profile.signTransaction(faucet, unsigned, codec, nonce);
const postableBytes = codec.bytesToPost(signed);
const response = await connection.postTx(postableBytes);
@ -259,7 +307,7 @@ describe("CosmWasmConnection", () => {
it("can post and search for a transaction", async () => {
pendingWithoutCosmos();
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultTokens);
const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig);
const profile = new UserProfile();
const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucetMnemonic));
const faucet = await profile.createIdentity(wallet.id, defaultChainId, faucetPath);
@ -279,7 +327,7 @@ describe("CosmWasmConnection", () => {
});
const nonce = await connection.getNonce({ address: faucetAddress });
// TODO: we need to use custom codecs everywhere
const codec = new CosmWasmCodec(defaultPrefix, defaultTokens.bank);
const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens);
const signed = await profile.signTransaction(faucet, unsigned, codec, nonce);
const postableBytes = codec.bytesToPost(signed);
const response = await connection.postTx(postableBytes);

View File

@ -4,6 +4,7 @@ import {
Account,
AccountQuery,
AddressQuery,
Amount,
BlockchainConnection,
BlockHeader,
BlockId,
@ -37,7 +38,7 @@ import { Stream } from "xstream";
import { decodeCosmosPubkey, pubkeyToAddress } from "./address";
import { Caip5 } from "./caip5";
import { decodeAmount, parseTxsResponse } from "./decode";
import { accountToNonce, BankToken } from "./types";
import { accountToNonce, BankToken, Erc20Token } from "./types";
const { toHex } = Encoding;
@ -71,7 +72,9 @@ function buildQueryString({
export interface TokenConfiguration {
/** Supported tokens of the Cosmos SDK bank module */
readonly bank: ReadonlyArray<BankToken & { readonly name: string }>;
readonly bankTokens: ReadonlyArray<BankToken & { readonly name: string }>;
/** Smart contract based tokens (ERC20 compatible). Unset means empty array. */
readonly erc20Tokens?: ReadonlyArray<Erc20Token & { readonly name: string }>;
}
export class CosmWasmConnection implements BlockchainConnection {
@ -95,9 +98,10 @@ export class CosmWasmConnection implements BlockchainConnection {
private readonly chainData: ChainData;
private readonly addressPrefix: CosmosAddressBech32Prefix;
private readonly bankTokens: readonly BankToken[];
private readonly erc20Tokens: readonly Erc20Token[];
// these are derived from arguments (cached for use in multiple functions)
private readonly primaryToken: Token;
private readonly feeToken: BankToken | undefined;
private readonly supportedTokens: readonly Token[];
private constructor(
@ -109,14 +113,17 @@ export class CosmWasmConnection implements BlockchainConnection {
this.restClient = restClient;
this.chainData = chainData;
this.addressPrefix = addressPrefix;
this.bankTokens = tokens.bank;
this.supportedTokens = tokens.bank.map(info => ({
tokenTicker: info.ticker as TokenTicker,
tokenName: info.name,
fractionalDigits: info.fractionalDigits,
}));
this.primaryToken = this.supportedTokens[0];
this.bankTokens = tokens.bankTokens;
this.feeToken = this.bankTokens.find(() => true);
const erc20Tokens = tokens.erc20Tokens || [];
this.erc20Tokens = erc20Tokens;
this.supportedTokens = [...tokens.bankTokens, ...erc20Tokens]
.map(info => ({
tokenTicker: info.ticker as TokenTicker,
tokenName: info.name,
fractionalDigits: info.fractionalDigits,
}))
.sort((a, b) => a.tokenTicker.localeCompare(b.tokenTicker));
}
public disconnect(): void {
@ -154,14 +161,35 @@ export class CosmWasmConnection implements BlockchainConnection {
if (!account.address) {
return undefined;
}
const supportedCoins = account.coins.filter(({ denom }) =>
const supportedBankCoins = account.coins.filter(({ denom }) =>
this.bankTokens.find(token => token.denom === denom),
);
const erc20Amounts = await Promise.all(
this.erc20Tokens.map(
async (erc20): Promise<Amount> => {
const queryMsg = { balance: { address: address } };
const response = JSON.parse(
await this.restClient.queryContractSmart(erc20.contractAddress, queryMsg),
);
return {
fractionalDigits: erc20.fractionalDigits,
quantity: response.balance,
tokenTicker: erc20.ticker as TokenTicker,
};
},
),
);
const balance = [
...supportedBankCoins.map(coin => decodeAmount(this.bankTokens, coin)),
...erc20Amounts,
].sort((a, b) => a.tokenTicker.localeCompare(b.tokenTicker));
const pubkey = !account.public_key ? undefined : decodeCosmosPubkey(account.public_key);
return {
address: address,
balance: supportedCoins.map(coin => decodeAmount(this.bankTokens, coin)),
balance: balance,
pubkey: pubkey,
};
}
@ -283,11 +311,12 @@ export class CosmWasmConnection implements BlockchainConnection {
if (!isSendTransaction(tx)) {
throw new Error("Received transaction of unsupported kind.");
}
if (!this.feeToken) throw new Error("This connection has no fee token configured.");
return {
tokens: {
fractionalDigits: this.primaryToken.fractionalDigits,
fractionalDigits: this.feeToken.fractionalDigits,
quantity: "5000",
tokenTicker: this.primaryToken.tokenTicker,
tokenTicker: this.feeToken.ticker as TokenTicker,
},
gasLimit: "200000",
};

View File

@ -13,7 +13,7 @@ export function createCosmWasmConnector(
tokens: TokenConfiguration,
expectedChainId?: ChainId,
): ChainConnector<CosmWasmConnection> {
const codec = new CosmWasmCodec(addressPrefix, tokens.bank);
const codec = new CosmWasmCodec(addressPrefix, tokens.bankTokens);
return {
establishConnection: async () => CosmWasmConnection.establish(url, addressPrefix, tokens),
codec: codec,

View File

@ -18,6 +18,21 @@ export interface BankToken {
export type BankTokens = ReadonlyArray<BankToken>;
export interface Erc20Token {
readonly contractAddress: string;
readonly ticker: string;
/**
* The number of fractional digits the token supports.
*
* A quantity is expressed as atomic units. 10^fractionalDigits of those
* atomic units make up 1 token.
*
* E.g. in Ethereum 10^18 wei are 1 ETH and from the quantity 123000000000000000000
* the last 18 digits are the fractional part and the rest the wole part.
*/
readonly fractionalDigits: number;
}
// tslint:disable-next-line:no-bitwise
const maxAcct = 1 << 23;
// tslint:disable-next-line:no-bitwise

View File

@ -11,11 +11,11 @@ import {
TxCodec,
UnsignedTransaction,
} from "@iov/bcp";
import { TokenInfos } from "./types";
import { BankTokens } from "./types";
export declare class CosmWasmCodec implements TxCodec {
private readonly addressPrefix;
private readonly tokens;
constructor(addressPrefix: CosmosAddressBech32Prefix, tokens: TokenInfos);
constructor(addressPrefix: CosmosAddressBech32Prefix, tokens: BankTokens);
bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob;
bytesToPost(signed: SignedTransaction): PostableBytes;
identifier(_signed: SignedTransaction): TransactionId;

View File

@ -21,11 +21,17 @@ import {
UnsignedTransaction,
} from "@iov/bcp";
import { Stream } from "xstream";
import { TokenInfo } from "./types";
import { BankToken, Erc20Token } from "./types";
export interface TokenConfiguration {
/** Supported tokens of the Cosmos SDK bank module */
readonly bank: ReadonlyArray<
TokenInfo & {
readonly bankTokens: ReadonlyArray<
BankToken & {
readonly name: string;
}
>;
/** Smart contract based tokens (ERC20 compatible). Unset means empty array. */
readonly erc20Tokens?: ReadonlyArray<
Erc20Token & {
readonly name: string;
}
>;
@ -41,7 +47,8 @@ export declare class CosmWasmConnection implements BlockchainConnection {
private readonly chainData;
private readonly addressPrefix;
private readonly bankTokens;
private readonly primaryToken;
private readonly erc20Tokens;
private readonly feeToken;
private readonly supportedTokens;
private constructor();
disconnect(): void;

View File

@ -12,24 +12,24 @@ import {
UnsignedTransaction,
} from "@iov/bcp";
import { Decimal } from "@iov/encoding";
import { TokenInfos } from "./types";
import { BankTokens } from "./types";
export declare function decodePubkey(pubkey: types.PubKey): PubkeyBundle;
export declare function decodeSignature(signature: string): SignatureBytes;
export declare function decodeFullSignature(signature: types.StdSignature, nonce: number): FullSignature;
export declare function coinToDecimal(tokens: TokenInfos, coin: types.Coin): readonly [Decimal, string];
export declare function decodeAmount(tokens: TokenInfos, coin: types.Coin): Amount;
export declare function parseMsg(msg: types.Msg, chainId: ChainId, tokens: TokenInfos): UnsignedTransaction;
export declare function parseFee(fee: types.StdFee, tokens: TokenInfos): Fee;
export declare function coinToDecimal(tokens: BankTokens, coin: types.Coin): readonly [Decimal, string];
export declare function decodeAmount(tokens: BankTokens, coin: types.Coin): Amount;
export declare function parseMsg(msg: types.Msg, chainId: ChainId, tokens: BankTokens): UnsignedTransaction;
export declare function parseFee(fee: types.StdFee, tokens: BankTokens): Fee;
export declare function parseTx(
txValue: types.StdTx,
chainId: ChainId,
nonce: Nonce,
tokens: TokenInfos,
tokens: BankTokens,
): SignedTransaction;
export declare function parseTxsResponse(
chainId: ChainId,
currentHeight: number,
nonce: Nonce,
response: TxsResponse,
tokens: TokenInfos,
tokens: BankTokens,
): ConfirmedAndSignedTransaction<UnsignedTransaction>;

View File

@ -1,11 +1,11 @@
import { types } from "@cosmwasm/sdk";
import { Amount, Fee, FullSignature, PubkeyBundle, SignedTransaction, UnsignedTransaction } from "@iov/bcp";
import { Decimal } from "@iov/encoding";
import { TokenInfos } from "./types";
import { BankTokens } from "./types";
export declare function encodePubkey(pubkey: PubkeyBundle): types.PubKey;
export declare function decimalToCoin(lookup: TokenInfos, value: Decimal, ticker: string): types.Coin;
export declare function encodeAmount(amount: Amount, tokens: TokenInfos): types.Coin;
export declare function encodeFee(fee: Fee, tokens: TokenInfos): types.StdFee;
export declare function decimalToCoin(lookup: BankTokens, value: Decimal, ticker: string): types.Coin;
export declare function encodeAmount(amount: Amount, tokens: BankTokens): types.Coin;
export declare function encodeFee(fee: Fee, tokens: BankTokens): types.StdFee;
export declare function encodeFullSignature(fullSignature: FullSignature): types.StdSignature;
export declare function buildUnsignedTx(tx: UnsignedTransaction, tokens: TokenInfos): types.AminoTx;
export declare function buildSignedTx(tx: SignedTransaction, tokens: TokenInfos): types.AminoTx;
export declare function buildUnsignedTx(tx: UnsignedTransaction, tokens: BankTokens): types.AminoTx;
export declare function buildSignedTx(tx: SignedTransaction, tokens: BankTokens): types.AminoTx;

View File

@ -1,6 +1,6 @@
import { types } from "@cosmwasm/sdk";
import { Nonce } from "@iov/bcp";
export interface TokenInfo {
export interface BankToken {
readonly denom: string;
readonly ticker: string;
/**
@ -14,7 +14,21 @@ export interface TokenInfo {
*/
readonly fractionalDigits: number;
}
export declare type TokenInfos = ReadonlyArray<TokenInfo>;
export declare type BankTokens = ReadonlyArray<BankToken>;
export interface Erc20Token {
readonly contractAddress: string;
readonly ticker: string;
/**
* The number of fractional digits the token supports.
*
* A quantity is expressed as atomic units. 10^fractionalDigits of those
* atomic units make up 1 token.
*
* E.g. in Ethereum 10^18 wei are 1 ETH and from the quantity 123000000000000000000
* the last 18 digits are the fractional part and the rest the wole part.
*/
readonly fractionalDigits: number;
}
export declare function accountToNonce({ account_number: account, sequence }: types.NonceInfo): Nonce;
export declare function nonceToAccountNumber(nonce: Nonce): number;
export declare function nonceToSequence(nonce: Nonce): number;

View File

@ -83,7 +83,9 @@ You should get output matching the following:
1. Faucet<br>
economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone<br>
A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ<br>
cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6
2. Unused: for testing account state; this account never changes balances or nonces<br>
oyster design unusual machine spread century engine gravity focus cave carry slot<br>
ArkCaFUJ/IH+vKBmNRCdUVl3mCAhbopk9jjW4Ko4OfRQ<br>
cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u