diff --git a/packages/bcp/package.json b/packages/bcp/package.json index 4721a2d6..193fb5d9 100644 --- a/packages/bcp/package.json +++ b/packages/bcp/package.json @@ -44,6 +44,8 @@ "@iov/encoding": "^2.0.0-alpha.7", "@iov/stream": "^2.0.0-alpha.7", "@iov/utils": "^2.0.0-alpha.7", + "@types/bn.js": "^4.11.6", + "bn.js": "^5.1.1", "fast-deep-equal": "^3.1.1", "readonly-date": "^1.0.0", "xstream": "^11.11.0" diff --git a/packages/bcp/src/cosmwasmcodec.spec.ts b/packages/bcp/src/cosmwasmcodec.spec.ts index e82d3870..2269887e 100644 --- a/packages/bcp/src/cosmwasmcodec.spec.ts +++ b/packages/bcp/src/cosmwasmcodec.spec.ts @@ -1,64 +1,128 @@ -import { PostableBytes, PrehashType } from "@iov/bcp"; +import { CosmosAddressBech32Prefix } from "@cosmwasm/sdk"; +import { Address, PostableBytes, PrehashType, SendTransaction, TokenTicker } from "@iov/bcp"; import { Encoding } from "@iov/encoding"; -import { cosmWasmCodec } from "./cosmwasmcodec"; +import { CosmWasmCodec } from "./cosmwasmcodec"; import { chainId, nonce, sendTxJson, signedTxBin, signedTxEncodedJson, signedTxJson } from "./testdata.spec"; +import { BankToken, Erc20Token } from "./types"; const { toUtf8 } = Encoding; -describe("cosmWasmCodec", () => { +const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix; + +const defaultBankTokens: readonly BankToken[] = [ + { + fractionalDigits: 6, + ticker: "ATOM", + denom: "uatom", + }, +]; + +const defaultErc20Tokens: readonly Erc20Token[] = [ + { + contractAddress: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + fractionalDigits: 5, + ticker: "ASH", + }, + { + contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + fractionalDigits: 0, + ticker: "BASH", + }, + { + contractAddress: "cosmos18r5szma8hm93pvx6lwpjwyxruw27e0k5uw835c", + fractionalDigits: 18, + ticker: "CASH", + }, +]; + +describe("CosmWasmCodec", () => { + const codec = new CosmWasmCodec(defaultPrefix, defaultBankTokens, defaultErc20Tokens); + describe("isValidAddress", () => { it("accepts valid addresses", () => { - expect(cosmWasmCodec.isValidAddress("cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6")).toEqual(true); - expect(cosmWasmCodec.isValidAddress("cosmosvalcons10q82zkzzmaku5lazhsvxv7hsg4ntpuhdwadmss")).toEqual( - true, - ); - expect(cosmWasmCodec.isValidAddress("cosmosvaloper17mggn4znyeyg25wd7498qxl7r2jhgue8u4qjcq")).toEqual( - true, - ); + expect(codec.isValidAddress("cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6")).toEqual(true); + expect(codec.isValidAddress("cosmosvalcons10q82zkzzmaku5lazhsvxv7hsg4ntpuhdwadmss")).toEqual(true); + expect(codec.isValidAddress("cosmosvaloper17mggn4znyeyg25wd7498qxl7r2jhgue8u4qjcq")).toEqual(true); }); it("rejects invalid addresses", () => { // Bad size - expect(cosmWasmCodec.isValidAddress("cosmos10q82zkzzmaku5lazhsvxv7hsg4ntpuhh8289f")).toEqual(false); + expect(codec.isValidAddress("cosmos10q82zkzzmaku5lazhsvxv7hsg4ntpuhh8289f")).toEqual(false); // Bad checksum - expect(cosmWasmCodec.isValidAddress("cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs7")).toEqual(false); + expect(codec.isValidAddress("cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs7")).toEqual(false); // Bad prefix - expect(cosmWasmCodec.isValidAddress("cosmot10q82zkzzmaku5lazhsvxv7hsg4ntpuhd8j5266")).toEqual(false); + expect(codec.isValidAddress("cosmot10q82zkzzmaku5lazhsvxv7hsg4ntpuhd8j5266")).toEqual(false); }); }); - it("properly generates bytes to sign", () => { - const expected = { - bytes: toUtf8( - '{"account_number":"0","chain_id":"cosmoshub-3","fee":{"amount":[{"amount":"2500","denom":"uatom"}],"gas":"100000"},"memo":"","msgs":[{"type":"cosmos-sdk/MsgSend","value":{"amount":[{"amount":"35997500","denom":"uatom"}],"from_address":"cosmos1txqfn5jmcts0x0q7krdxj8tgf98tj0965vqlmq","to_address":"cosmos1nynns8ex9fq6sjjfj8k79ymkdz4sqth06xexae"}}],"sequence":"99"}', - ), - prehashType: PrehashType.Sha256, - }; - const bytesToSign = cosmWasmCodec.bytesToSign(sendTxJson, nonce); + describe("bytesToSign", () => { + it("works for SendTransaction via bank module", () => { + const expected = { + bytes: toUtf8( + '{"account_number":"0","chain_id":"cosmoshub-3","fee":{"amount":[{"amount":"2500","denom":"uatom"}],"gas":"100000"},"memo":"","msgs":[{"type":"cosmos-sdk/MsgSend","value":{"amount":[{"amount":"35997500","denom":"uatom"}],"from_address":"cosmos1txqfn5jmcts0x0q7krdxj8tgf98tj0965vqlmq","to_address":"cosmos1nynns8ex9fq6sjjfj8k79ymkdz4sqth06xexae"}}],"sequence":"99"}', + ), + prehashType: PrehashType.Sha256, + }; + expect(codec.bytesToSign(sendTxJson, nonce)).toEqual(expected); + }); - expect(bytesToSign).toEqual(expected); + it("works for ERC20 send", () => { + const bashSendTx: SendTransaction = { + kind: "bcp/send", + chainId: chainId, + sender: "cosmos1txqfn5jmcts0x0q7krdxj8tgf98tj0965vqlmq" as Address, + recipient: "cosmos1dddd" as Address, + memo: "My first BASH payment", + amount: { + fractionalDigits: 0, + quantity: "345", + tokenTicker: "BASH" as TokenTicker, + }, + fee: { + tokens: { + fractionalDigits: 6, + quantity: "2500", + tokenTicker: "ATOM" as TokenTicker, + }, + gasLimit: "100000", + }, + }; + + const expected = { + bytes: toUtf8( + '{"account_number":"0","chain_id":"cosmoshub-3","fee":{"amount":[{"amount":"2500","denom":"uatom"}],"gas":"100000"},"memo":"My first BASH payment","msgs":[{"type":"wasm/execute","value":{"contract":"cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd","msg":{"transfer":{"amount":"345","recipient":"cosmos1dddd"}},"sender":"cosmos1txqfn5jmcts0x0q7krdxj8tgf98tj0965vqlmq","sent_funds":[]}}],"sequence":"99"}', + ), + prehashType: PrehashType.Sha256, + }; + + expect(codec.bytesToSign(bashSendTx, nonce)).toEqual(expected); + }); }); - it("properly encodes transactions", () => { - const encoded = cosmWasmCodec.bytesToPost(signedTxJson); - expect(encoded).toEqual(signedTxEncodedJson); + describe("bytesToPost", () => { + it("works for SendTransaction via bank module", () => { + const encoded = codec.bytesToPost(signedTxJson); + expect(encoded).toEqual(signedTxEncodedJson); + }); }); - it("throws when trying to decode a transaction without a nonce", () => { - expect(() => cosmWasmCodec.parseBytes(signedTxBin as PostableBytes, chainId)).toThrowError( - /nonce is required/i, - ); - }); + describe("parseBytes", () => { + it("throws when trying to decode a transaction without a nonce", () => { + expect(() => codec.parseBytes(signedTxBin as PostableBytes, chainId)).toThrowError( + /nonce is required/i, + ); + }); - it("properly decodes transactions", () => { - const decoded = cosmWasmCodec.parseBytes(signedTxEncodedJson as PostableBytes, chainId, nonce); - expect(decoded).toEqual(signedTxJson); - }); + it("properly decodes transactions", () => { + const decoded = codec.parseBytes(signedTxEncodedJson as PostableBytes, chainId, nonce); + expect(decoded).toEqual(signedTxJson); + }); - it("round trip works", () => { - const encoded = cosmWasmCodec.bytesToPost(signedTxJson); - const decoded = cosmWasmCodec.parseBytes(encoded, chainId, nonce); - expect(decoded).toEqual(signedTxJson); + it("round trip works", () => { + const encoded = codec.bytesToPost(signedTxJson); + const decoded = codec.parseBytes(encoded, chainId, nonce); + expect(decoded).toEqual(signedTxJson); + }); }); }); diff --git a/packages/bcp/src/cosmwasmcodec.ts b/packages/bcp/src/cosmwasmcodec.ts index 2df8f0ef..1b2f1e76 100644 --- a/packages/bcp/src/cosmwasmcodec.ts +++ b/packages/bcp/src/cosmwasmcodec.ts @@ -26,19 +26,25 @@ import { pubkeyToAddress } from "./address"; import { Caip5 } from "./caip5"; import { parseTx } from "./decode"; import { buildSignedTx, buildUnsignedTx } from "./encode"; -import { BankTokens, nonceToAccountNumber, nonceToSequence } from "./types"; +import { BankTokens, Erc20Token, nonceToAccountNumber, nonceToSequence } from "./types"; export class CosmWasmCodec implements TxCodec { private readonly addressPrefix: CosmosAddressBech32Prefix; - private readonly tokens: BankTokens; + private readonly bankTokens: BankTokens; + private readonly erc20Tokens: readonly Erc20Token[]; - public constructor(addressPrefix: CosmosAddressBech32Prefix, tokens: BankTokens) { + public constructor( + addressPrefix: CosmosAddressBech32Prefix, + bankTokens: BankTokens, + erc20Tokens: readonly Erc20Token[] = [], + ) { this.addressPrefix = addressPrefix; - this.tokens = tokens; + this.bankTokens = bankTokens; + this.erc20Tokens = erc20Tokens; } public bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob { - const built = buildUnsignedTx(unsigned, this.tokens); + const built = buildUnsignedTx(unsigned, this.bankTokens, this.erc20Tokens); const nonceInfo: types.NonceInfo = { account_number: nonceToAccountNumber(nonce), @@ -61,7 +67,7 @@ export class CosmWasmCodec implements TxCodec { // PostableBytes are JSON-encoded StdTx public bytesToPost(signed: SignedTransaction): PostableBytes { // TODO: change this as well (return StdTx, not AminoTx)? - const built = buildSignedTx(signed, this.tokens); + const built = buildSignedTx(signed, this.bankTokens, this.erc20Tokens); return marshalTx(built.value) as PostableBytes; } @@ -79,7 +85,7 @@ export class CosmWasmCodec implements TxCodec { throw new Error("Nonce is required"); } const parsed = unmarshalTx(bytes); - return parseTx(parsed, chainId, nonce, this.tokens); + return parseTx(parsed, chainId, nonce, this.bankTokens); } public identityToAddress(identity: Identity): Address { @@ -90,16 +96,3 @@ export class CosmWasmCodec implements TxCodec { return isValidAddress(address); } } - -const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix; - -const defaultTokens: BankTokens = [ - { - fractionalDigits: 6, - ticker: "ATOM", - denom: "uatom", - }, -]; - -/** Unconfigured codec is useful for testing only */ -export const cosmWasmCodec = new CosmWasmCodec(defaultPrefix, defaultTokens); diff --git a/packages/bcp/src/cosmwasmconnection.spec.ts b/packages/bcp/src/cosmwasmconnection.spec.ts index 6c14f0d1..6e28d6dc 100644 --- a/packages/bcp/src/cosmwasmconnection.spec.ts +++ b/packages/bcp/src/cosmwasmconnection.spec.ts @@ -11,12 +11,12 @@ import { TokenTicker, TransactionState, } from "@iov/bcp"; -import { Secp256k1 } from "@iov/crypto"; -import { Encoding } from "@iov/encoding"; +import { Random, Secp256k1 } from "@iov/crypto"; +import { Bech32, Encoding } from "@iov/encoding"; import { HdPaths, Secp256k1HdWallet, UserProfile } from "@iov/keycontrol"; import { assert } from "@iov/utils"; -import { CosmWasmCodec, cosmWasmCodec } from "./cosmwasmcodec"; +import { CosmWasmCodec } from "./cosmwasmcodec"; import { CosmWasmConnection, TokenConfiguration } from "./cosmwasmconnection"; import { signedTxJson, txId } from "./testdata.spec"; import { nonceToSequence } from "./types"; @@ -29,6 +29,12 @@ function pendingWithoutCosmos(): void { } } +const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix; + +function makeRandomAddress(): Address { + return Bech32.encode(defaultPrefix, Random.getBytes(20)) as Address; +} + describe("CosmWasmConnection", () => { const cosm = "COSM" as TokenTicker; const httpUrl = "http://localhost:1317"; @@ -53,8 +59,6 @@ describe("CosmWasmConnection", () => { address: "cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u" as Address, }; - const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix; - // this is for wasmd blockchain const defaultConfig: TokenConfiguration = { bankTokens: [ @@ -84,6 +88,23 @@ describe("CosmWasmConnection", () => { ticker: "BASH", name: "Bash Token", }, + { + contractAddress: "cosmos18r5szma8hm93pvx6lwpjwyxruw27e0k5uw835c", + fractionalDigits: 18, + ticker: "CASH", + name: "Cash Token", + }, + ], + }; + + const atomConfig: TokenConfiguration = { + bankTokens: [ + { + fractionalDigits: 6, + name: "Atom", + ticker: "ATOM", + denom: "uatom", + }, ], }; @@ -154,6 +175,11 @@ describe("CosmWasmConnection", () => { tokenName: "Bash Token", tokenTicker: "BASH" as TokenTicker, }, + { + fractionalDigits: 18, + tokenName: "Cash Token", + tokenTicker: "CASH" as TokenTicker, + }, { fractionalDigits: 6, tokenName: "Fee Token", @@ -172,8 +198,9 @@ describe("CosmWasmConnection", () => { describe("identifier", () => { it("calculates tx hash from PostableBytes", async () => { pendingWithoutCosmos(); - const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); - const postable = cosmWasmCodec.bytesToPost(signedTxJson); + const codec = new CosmWasmCodec(defaultPrefix, atomConfig.bankTokens); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, atomConfig); + const postable = codec.bytesToPost(signedTxJson); const id = await connection.identifier(postable); expect(id).toMatch(/^[0-9A-F]{64}$/); expect(id).toEqual(txId); @@ -242,11 +269,12 @@ describe("CosmWasmConnection", () => { describe("integration tests", () => { it("can post and get a transaction", async () => { pendingWithoutCosmos(); + const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens); 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); - const faucetAddress = cosmWasmCodec.identityToAddress(faucet); + const faucetAddress = codec.identityToAddress(faucet); const unsigned = await connection.withDefaultFee({ kind: "bcp/send", @@ -261,8 +289,6 @@ describe("CosmWasmConnection", () => { }, }); const nonce = await connection.getNonce({ address: faucetAddress }); - // TODO: we need to use custom codecs everywhere - 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); @@ -307,11 +333,12 @@ describe("CosmWasmConnection", () => { it("can post and search for a transaction", async () => { pendingWithoutCosmos(); + const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens); 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); - const faucetAddress = cosmWasmCodec.identityToAddress(faucet); + const faucetAddress = codec.identityToAddress(faucet); const unsigned = await connection.withDefaultFee({ kind: "bcp/send", @@ -326,8 +353,6 @@ describe("CosmWasmConnection", () => { }, }); const nonce = await connection.getNonce({ address: faucetAddress }); - // TODO: we need to use custom codecs everywhere - 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); @@ -433,5 +458,47 @@ describe("CosmWasmConnection", () => { connection.disconnect(); }); + + it("can send ERC20 tokens", async () => { + pendingWithoutCosmos(); + const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens, defaultConfig.erc20Tokens); + 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); + const faucetAddress = codec.identityToAddress(faucet); + const recipient = makeRandomAddress(); + + const unsigned = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + sender: faucetAddress, + recipient: recipient, + memo: "My first payment", + amount: { + quantity: "75", + fractionalDigits: 0, + tokenTicker: "BASH" as TokenTicker, + }, + }); + const nonce = await connection.getNonce({ address: faucetAddress }); + const signed = await profile.signTransaction(faucet, unsigned, codec, nonce); + const postableBytes = codec.bytesToPost(signed); + const response = await connection.postTx(postableBytes); + const blockInfo = await response.blockInfo.waitFor(info => !isBlockInfoPending(info)); + expect(blockInfo.state).toEqual(TransactionState.Succeeded); + + const recipientAccount = await connection.getAccount({ address: recipient }); + assert(recipientAccount, "Recipient account must have BASH tokens"); + expect(recipientAccount.balance).toEqual([ + { + tokenTicker: "BASH" as TokenTicker, + quantity: "75", + fractionalDigits: 0, + }, + ]); + + connection.disconnect(); + }); }); }); diff --git a/packages/bcp/src/cosmwasmconnection.ts b/packages/bcp/src/cosmwasmconnection.ts index d8e8dbb5..5640c72f 100644 --- a/packages/bcp/src/cosmwasmconnection.ts +++ b/packages/bcp/src/cosmwasmconnection.ts @@ -31,6 +31,7 @@ import { import { Sha256 } from "@iov/crypto"; import { Encoding, Uint53 } from "@iov/encoding"; import { DefaultValueProducer, ValueAndUpdates } from "@iov/stream"; +import BN from "bn.js"; import equal from "fast-deep-equal"; import { ReadonlyDate } from "readonly-date"; import { Stream } from "xstream"; @@ -157,12 +158,10 @@ export class CosmWasmConnection implements BlockchainConnection { public async getAccount(query: AccountQuery): Promise { const address = isPubkeyQuery(query) ? pubkeyToAddress(query.pubkey, this.addressPrefix) : query.address; const { result } = await this.restClient.authAccounts(address); - const account = result.value; - if (!account.address) { - return undefined; - } + const bankAccount = result.value; + const hasBankAccount = !!bankAccount.address; - const supportedBankCoins = account.coins.filter(({ denom }) => + const supportedBankCoins = bankAccount.coins.filter(({ denom }) => this.bankTokens.find(token => token.denom === denom), ); const erc20Amounts = await Promise.all( @@ -172,26 +171,31 @@ export class CosmWasmConnection implements BlockchainConnection { const response = JSON.parse( await this.restClient.queryContractSmart(erc20.contractAddress, queryMsg), ); + const normalizedBalance = new BN(response.balance).toString(); return { fractionalDigits: erc20.fractionalDigits, - quantity: response.balance, + quantity: normalizedBalance, tokenTicker: erc20.ticker as TokenTicker, }; }, ), ); + const nonZeroErc20Amounts = erc20Amounts.filter(amount => amount.quantity !== "0"); - 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: balance, - pubkey: pubkey, - }; + if (!hasBankAccount && nonZeroErc20Amounts.length === 0) { + return undefined; + } else { + const balance = [ + ...supportedBankCoins.map(coin => decodeAmount(this.bankTokens, coin)), + ...nonZeroErc20Amounts, + ].sort((a, b) => a.tokenTicker.localeCompare(b.tokenTicker)); + const pubkey = !bankAccount.public_key ? undefined : decodeCosmosPubkey(bankAccount.public_key); + return { + address: address, + balance: balance, + pubkey: pubkey, + }; + } } public watchAccount(_account: AccountQuery): Stream { @@ -291,8 +295,10 @@ export class CosmWasmConnection implements BlockchainConnection { ): Promise | FailedTransaction)[]> { const queryString = buildQueryString(query); const chainId = this.chainId(); - const { txs: responses } = await this.restClient.txs(queryString); - return Promise.all(responses.map(response => this.parseAndPopulateTxResponse(response, chainId))); + // TODO: we need pagination support + const response = await this.restClient.txs(queryString + "&limit=50"); + const { txs } = response; + return Promise.all(txs.map(tx => this.parseAndPopulateTxResponse(tx, chainId))); } public listenTx( diff --git a/packages/bcp/src/cosmwasmconnector.ts b/packages/bcp/src/cosmwasmconnector.ts index cdf1c4b2..648568e9 100644 --- a/packages/bcp/src/cosmwasmconnector.ts +++ b/packages/bcp/src/cosmwasmconnector.ts @@ -10,12 +10,12 @@ import { CosmWasmConnection, TokenConfiguration } from "./cosmwasmconnection"; export function createCosmWasmConnector( url: string, addressPrefix: CosmosAddressBech32Prefix, - tokens: TokenConfiguration, + tokenConfig: TokenConfiguration, expectedChainId?: ChainId, ): ChainConnector { - const codec = new CosmWasmCodec(addressPrefix, tokens.bankTokens); + const codec = new CosmWasmCodec(addressPrefix, tokenConfig.bankTokens, tokenConfig.erc20Tokens); return { - establishConnection: async () => CosmWasmConnection.establish(url, addressPrefix, tokens), + establishConnection: async () => CosmWasmConnection.establish(url, addressPrefix, tokenConfig), codec: codec, expectedChainId: expectedChainId, }; diff --git a/packages/bcp/src/encode.spec.ts b/packages/bcp/src/encode.spec.ts index 436430d5..26aac966 100644 --- a/packages/bcp/src/encode.spec.ts +++ b/packages/bcp/src/encode.spec.ts @@ -16,12 +16,13 @@ import { Encoding } from "@iov/encoding"; import { buildSignedTx, buildUnsignedTx, - encodeAmount, encodeFee, encodeFullSignature, encodePubkey, + toBankCoin, + toErc20Amount, } from "./encode"; -import { BankTokens } from "./types"; +import { BankTokens, Erc20Token } from "./types"; const { fromBase64 } = Encoding; @@ -48,6 +49,23 @@ describe("encode", () => { denom: "uatom", }, ]; + const defaultErc20Tokens: Erc20Token[] = [ + { + contractAddress: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + fractionalDigits: 5, + ticker: "ASH", + }, + { + contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + fractionalDigits: 0, + ticker: "BASH", + }, + { + contractAddress: "cosmos18r5szma8hm93pvx6lwpjwyxruw27e0k5uw835c", + fractionalDigits: 18, + ticker: "CASH", + }, + ]; describe("encodePubKey", () => { it("encodes a Secp256k1 pubkey", () => { @@ -58,9 +76,40 @@ describe("encode", () => { }); }); - describe("encodeAmount", () => { + describe("toErc20Amount", () => { + const [ash, bash] = defaultErc20Tokens; + it("encodes an amount", () => { - expect(encodeAmount(defaultAmount, defaultTokens)).toEqual({ + const amount: Amount = { + quantity: "789", + fractionalDigits: 0, + tokenTicker: "BASH" as TokenTicker, + }; + expect(toErc20Amount(amount, bash)).toEqual("789"); + }); + + it("throws on ticker mismatch", () => { + const amount: Amount = { + quantity: "789", + fractionalDigits: 0, + tokenTicker: "BASH" as TokenTicker, + }; + expect(() => toErc20Amount(amount, ash)).toThrowError(/ticker mismatch/i); + }); + + it("throws on ticker mismatch", () => { + const amount: Amount = { + quantity: "789", + fractionalDigits: 2, + tokenTicker: "BASH" as TokenTicker, + }; + expect(() => toErc20Amount(amount, bash)).toThrowError(/fractional digits mismatch/i); + }); + }); + + describe("toBankCoin", () => { + it("encodes an amount", () => { + expect(toBankCoin(defaultAmount, defaultTokens)).toEqual({ denom: "uatom", amount: "11657995", }); @@ -262,6 +311,56 @@ describe("encode", () => { }, }); }); + + it("works for ERC20 send", () => { + const bashSendTx: SendTransaction = { + kind: "bcp/send", + chainId: defaultChainId, + sender: "cosmos1txqfn5jmcts0x0q7krdxj8tgf98tj0965vqlmq" as Address, + recipient: "cosmos1dddd" as Address, + memo: defaultMemo, + amount: { + fractionalDigits: 0, + quantity: "345", + tokenTicker: "BASH" as TokenTicker, + }, + fee: { + tokens: { + fractionalDigits: 6, + quantity: "3333", + tokenTicker: "ATOM" as TokenTicker, + }, + gasLimit: "234000", + }, + }; + expect(buildUnsignedTx(bashSendTx, defaultTokens, defaultErc20Tokens)).toEqual({ + type: "cosmos-sdk/StdTx", + value: { + msg: [ + { + type: "wasm/execute", + value: { + sender: "cosmos1txqfn5jmcts0x0q7krdxj8tgf98tj0965vqlmq", + contract: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + msg: { + transfer: { + recipient: "cosmos1dddd", + amount: "345", + }, + }, + sent_funds: [], + }, + }, + ], + fee: { + amount: [{ denom: "uatom", amount: "3333" }], + gas: "234000", + }, + signatures: [], + memo: defaultMemo, + }, + }); + }); }); describe("buildSignedTx", () => { diff --git a/packages/bcp/src/encode.ts b/packages/bcp/src/encode.ts index a0a84d8e..1cf8a300 100644 --- a/packages/bcp/src/encode.ts +++ b/packages/bcp/src/encode.ts @@ -10,9 +10,9 @@ import { SignedTransaction, UnsignedTransaction, } from "@iov/bcp"; -import { Decimal, Encoding } from "@iov/encoding"; +import { Encoding } from "@iov/encoding"; -import { BankTokens } from "./types"; +import { BankTokens, Erc20Token } from "./types"; const { toBase64 } = Encoding; @@ -33,30 +33,28 @@ export function encodePubkey(pubkey: PubkeyBundle): types.PubKey { } } -export function decimalToCoin(lookup: BankTokens, value: Decimal, ticker: string): types.Coin { - const match = lookup.find(token => token.ticker === ticker); - if (!match) { - throw Error(`unknown ticker: ${ticker}`); +export function toErc20Amount(amount: Amount, erc20Token: Erc20Token): string { + if (amount.tokenTicker !== erc20Token.ticker) throw new Error("Ticker mismatch between amount and token"); + if (amount.fractionalDigits !== erc20Token.fractionalDigits) { + throw new Error("Fractional digits mismatch between amount and token"); } - if (match.fractionalDigits !== value.fractionalDigits) { + return amount.quantity; +} + +export function toBankCoin(amount: Amount, tokens: BankTokens): types.Coin { + const match = tokens.find(token => token.ticker === amount.tokenTicker); + if (!match) throw Error(`unknown ticker: ${amount.tokenTicker}`); + if (match.fractionalDigits !== amount.fractionalDigits) { throw new Error( "Mismatch in fractional digits between token and value. If you really want, implement a conversion here. However, this indicates a bug in the caller code.", ); } return { denom: match.denom, - amount: value.atomics, + amount: amount.quantity, }; } -export function encodeAmount(amount: Amount, tokens: BankTokens): types.Coin { - return decimalToCoin( - tokens, - Decimal.fromAtomics(amount.quantity, amount.fractionalDigits), - amount.tokenTicker, - ); -} - export function encodeFee(fee: Fee, tokens: BankTokens): types.StdFee { if (fee.tokens === undefined) { throw new Error("Cannot encode fee without tokens"); @@ -65,7 +63,7 @@ export function encodeFee(fee: Fee, tokens: BankTokens): types.StdFee { throw new Error("Cannot encode fee without gas limit"); } return { - amount: [encodeAmount(fee.tokens, tokens)], + amount: [toBankCoin(fee.tokens, tokens)], gas: fee.gasLimit, }; } @@ -79,37 +77,83 @@ export function encodeFullSignature(fullSignature: FullSignature): types.StdSign } } -export function buildUnsignedTx(tx: UnsignedTransaction, tokens: BankTokens): types.AminoTx { +export function buildUnsignedTx( + tx: UnsignedTransaction, + bankTokens: BankTokens, + erc20Tokens: readonly Erc20Token[] = [], +): types.AminoTx { if (!isSendTransaction(tx)) { throw new Error("Received transaction of unsupported kind"); } - return { - type: "cosmos-sdk/StdTx", - value: { - msg: [ - { - type: "cosmos-sdk/MsgSend", - value: { - from_address: tx.sender, - to_address: tx.recipient, - amount: [encodeAmount(tx.amount, tokens)], + + const matchingBankToken = bankTokens.find(t => t.ticker === tx.amount.tokenTicker); + const matchingErc20Token = erc20Tokens.find(t => t.ticker === tx.amount.tokenTicker); + + if (matchingBankToken) { + return { + type: "cosmos-sdk/StdTx", + value: { + msg: [ + { + type: "cosmos-sdk/MsgSend", + value: { + from_address: tx.sender, + to_address: tx.recipient, + amount: [toBankCoin(tx.amount, bankTokens)], + }, }, - }, - ], - memo: tx.memo || "", - signatures: [], - fee: tx.fee - ? encodeFee(tx.fee, tokens) - : { - amount: [], - gas: "", + ], + memo: tx.memo || "", + signatures: [], + fee: tx.fee + ? encodeFee(tx.fee, bankTokens) + : { + amount: [], + gas: "", + }, + }, + }; + } else if (matchingErc20Token) { + return { + type: "cosmos-sdk/StdTx", + value: { + msg: [ + { + type: "wasm/execute", + value: { + sender: tx.sender, + contract: matchingErc20Token.contractAddress, + msg: { + transfer: { + amount: toErc20Amount(tx.amount, matchingErc20Token), + recipient: tx.recipient, + }, + }, + sent_funds: [], + }, }, - }, - }; + ], + memo: tx.memo || "", + signatures: [], + fee: tx.fee + ? encodeFee(tx.fee, bankTokens) + : { + amount: [], + gas: "", + }, + }, + }; + } else { + throw new Error("Cannot encode this type of transaction"); + } } -export function buildSignedTx(tx: SignedTransaction, tokens: BankTokens): types.AminoTx { - const built = buildUnsignedTx(tx.transaction, tokens); +export function buildSignedTx( + tx: SignedTransaction, + bankTokens: BankTokens, + erc20Tokens: readonly Erc20Token[] = [], +): types.AminoTx { + const built = buildUnsignedTx(tx.transaction, bankTokens, erc20Tokens); return { ...built, value: { diff --git a/packages/bcp/types/cosmwasmcodec.d.ts b/packages/bcp/types/cosmwasmcodec.d.ts index 6161b65d..c7f01fb1 100644 --- a/packages/bcp/types/cosmwasmcodec.d.ts +++ b/packages/bcp/types/cosmwasmcodec.d.ts @@ -11,11 +11,16 @@ import { TxCodec, UnsignedTransaction, } from "@iov/bcp"; -import { BankTokens } from "./types"; +import { BankTokens, Erc20Token } from "./types"; export declare class CosmWasmCodec implements TxCodec { private readonly addressPrefix; - private readonly tokens; - constructor(addressPrefix: CosmosAddressBech32Prefix, tokens: BankTokens); + private readonly bankTokens; + private readonly erc20Tokens; + constructor( + addressPrefix: CosmosAddressBech32Prefix, + bankTokens: BankTokens, + erc20Tokens?: readonly Erc20Token[], + ); bytesToSign(unsigned: UnsignedTransaction, nonce: Nonce): SigningJob; bytesToPost(signed: SignedTransaction): PostableBytes; identifier(_signed: SignedTransaction): TransactionId; @@ -23,5 +28,3 @@ export declare class CosmWasmCodec implements TxCodec { identityToAddress(identity: Identity): Address; isValidAddress(address: string): boolean; } -/** Unconfigured codec is useful for testing only */ -export declare const cosmWasmCodec: CosmWasmCodec; diff --git a/packages/bcp/types/cosmwasmconnector.d.ts b/packages/bcp/types/cosmwasmconnector.d.ts index fbebceea..61ed984f 100644 --- a/packages/bcp/types/cosmwasmconnector.d.ts +++ b/packages/bcp/types/cosmwasmconnector.d.ts @@ -7,6 +7,6 @@ import { CosmWasmConnection, TokenConfiguration } from "./cosmwasmconnection"; export declare function createCosmWasmConnector( url: string, addressPrefix: CosmosAddressBech32Prefix, - tokens: TokenConfiguration, + tokenConfig: TokenConfiguration, expectedChainId?: ChainId, ): ChainConnector; diff --git a/packages/bcp/types/encode.d.ts b/packages/bcp/types/encode.d.ts index c55e4240..bf6d0410 100644 --- a/packages/bcp/types/encode.d.ts +++ b/packages/bcp/types/encode.d.ts @@ -1,11 +1,18 @@ import { types } from "@cosmwasm/sdk"; import { Amount, Fee, FullSignature, PubkeyBundle, SignedTransaction, UnsignedTransaction } from "@iov/bcp"; -import { Decimal } from "@iov/encoding"; -import { BankTokens } from "./types"; +import { BankTokens, Erc20Token } from "./types"; export declare function encodePubkey(pubkey: PubkeyBundle): types.PubKey; -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 toErc20Amount(amount: Amount, erc20Token: Erc20Token): string; +export declare function toBankCoin(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: BankTokens): types.AminoTx; -export declare function buildSignedTx(tx: SignedTransaction, tokens: BankTokens): types.AminoTx; +export declare function buildUnsignedTx( + tx: UnsignedTransaction, + bankTokens: BankTokens, + erc20Tokens?: readonly Erc20Token[], +): types.AminoTx; +export declare function buildSignedTx( + tx: SignedTransaction, + bankTokens: BankTokens, + erc20Tokens?: readonly Erc20Token[], +): types.AminoTx; diff --git a/packages/faucet/README.md b/packages/faucet/README.md index b2918908..3919206e 100644 --- a/packages/faucet/README.md +++ b/packages/faucet/README.md @@ -116,7 +116,7 @@ situation is different. ``` curl --header "Content-Type: application/json" \ --request POST \ - --data '{"ticker":"CASH","address":"tiov1k898u78hgs36uqw68dg7va5nfkgstu5z0fhz3f"}' \ + --data '{"ticker":"BASH","address":"cosmos1yre6ac7qfgyfgvh58ph0rgw627rhw766y430qq"}' \ http://localhost:8000/credit ``` diff --git a/packages/faucet/src/actions/start/start.ts b/packages/faucet/src/actions/start/start.ts index 408203f5..9f803cb7 100644 --- a/packages/faucet/src/actions/start/start.ts +++ b/packages/faucet/src/actions/start/start.ts @@ -19,7 +19,7 @@ export async function start(args: ReadonlyArray): Promise { const connector = createCosmWasmConnector( blockchainBaseUrl, constants.addressPrefix, - constants.tokenConfig, + constants.developmentTokenConfig, ); console.info(`Connecting to blockchain ${blockchainBaseUrl} ...`); const connection = await connector.establishConnection(); @@ -35,7 +35,7 @@ export async function start(args: ReadonlyArray): Promise { ); // Faucet - const faucet = new Faucet(constants.tokenConfig, connection, connector.codec, profile, true); + const faucet = new Faucet(constants.developmentTokenConfig, connection, connector.codec, profile, true); const chainTokens = await faucet.loadTokenTickers(); console.info("Chain tokens:", chainTokens); const accounts = await faucet.loadAccounts(); diff --git a/packages/faucet/src/constants.ts b/packages/faucet/src/constants.ts index bea2d710..3e37b1e8 100644 --- a/packages/faucet/src/constants.ts +++ b/packages/faucet/src/constants.ts @@ -6,7 +6,9 @@ export const port: number = Number.parseInt(process.env.FAUCET_PORT || "", 10) | export const mnemonic: string | undefined = process.env.FAUCET_MNEMONIC; export const addressPrefix = "cosmos"; -export const tokenConfig: TokenConfiguration = { + +/** For the local development chain */ +export const developmentTokenConfig: TokenConfiguration = { bankTokens: [ { fractionalDigits: 6, @@ -21,4 +23,12 @@ export const tokenConfig: TokenConfiguration = { denom: "ustake", }, ], + erc20Tokens: [ + { + contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + fractionalDigits: 0, + ticker: "BASH", + name: "Bash Token", + }, + ], }; diff --git a/packages/faucet/src/faucet.spec.ts b/packages/faucet/src/faucet.spec.ts index 932ba7a9..c823f142 100644 --- a/packages/faucet/src/faucet.spec.ts +++ b/packages/faucet/src/faucet.spec.ts @@ -32,23 +32,17 @@ const defaultConfig: TokenConfiguration = { }, ], erc20Tokens: [ - // { - // contractAddress: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", - // fractionalDigits: 5, - // ticker: "ASH", - // name: "Ash Token", - // }, - // { - // contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", - // fractionalDigits: 0, - // ticker: "BASH", - // name: "Bash Token", - // }, + { + contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + fractionalDigits: 0, + ticker: "BASH", + name: "Bash Token", + }, ], }; const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix; const defaultChainId = "cosmos:testing" as ChainId; -const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens); +const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens, defaultConfig.erc20Tokens); function makeRandomAddress(): Address { return Bech32.encode(defaultPrefix, Random.getBytes(20)) as Address; @@ -81,7 +75,7 @@ describe("Faucet", () => { }); describe("send", () => { - it("can send", async () => { + it("can send bank token", async () => { pendingWithoutCosmos(); const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const { profile, holder } = await makeProfile(); @@ -107,6 +101,33 @@ describe("Faucet", () => { ]); connection.disconnect(); }); + + it("can send ERC20 token", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const { profile, holder } = await makeProfile(); + const faucet = new Faucet(defaultConfig, connection, codec, profile); + const recipient = makeRandomAddress(); + await faucet.send({ + amount: { + quantity: "7", + fractionalDigits: 0, + tokenTicker: "BASH" as TokenTicker, + }, + sender: holder, + recipient: recipient, + }); + const account = await connection.getAccount({ address: recipient }); + assert(account); + expect(account.balance).toEqual([ + { + quantity: "7", + fractionalDigits: 0, + tokenTicker: "BASH" as TokenTicker, + }, + ]); + connection.disconnect(); + }); }); describe("refill", () => { @@ -119,6 +140,10 @@ describe("Faucet", () => { const distributorBalance = (await connection.getAccount({ pubkey: distributors[0].pubkey }))?.balance; assert(distributorBalance); expect(distributorBalance).toEqual([ + jasmine.objectContaining({ + tokenTicker: "BASH", + fractionalDigits: 0, + }), jasmine.objectContaining({ tokenTicker: "COSM", fractionalDigits: 6, @@ -128,8 +153,9 @@ describe("Faucet", () => { fractionalDigits: 6, }), ]); - expect(Number.parseInt(distributorBalance[0].quantity, 10)).toBeGreaterThanOrEqual(80_000000); + expect(Number.parseInt(distributorBalance[0].quantity, 10)).toBeGreaterThanOrEqual(80); expect(Number.parseInt(distributorBalance[1].quantity, 10)).toBeGreaterThanOrEqual(80_000000); + expect(Number.parseInt(distributorBalance[2].quantity, 10)).toBeGreaterThanOrEqual(80_000000); connection.disconnect(); }); }); @@ -181,7 +207,7 @@ describe("Faucet", () => { const { profile } = await makeProfile(); const faucet = new Faucet(defaultConfig, connection, codec, profile); const tickers = await faucet.loadTokenTickers(); - expect(tickers).toEqual(["COSM", "STAKE"]); + expect(tickers).toEqual(["BASH", "COSM", "STAKE"]); connection.disconnect(); }); }); diff --git a/packages/faucet/src/faucet.ts b/packages/faucet/src/faucet.ts index 66d406b2..6a2cd3fc 100644 --- a/packages/faucet/src/faucet.ts +++ b/packages/faucet/src/faucet.ts @@ -146,7 +146,7 @@ export class Faucet { } if (jobs.length > 0) { for (const job of jobs) { - logSendJob(job); + if (this.logging) logSendJob(job); await this.send(job); await sleep(50); } diff --git a/scripts/cosm/deploy_erc20.js b/scripts/cosm/deploy_erc20.js index 953f433b..78901bf1 100755 --- a/scripts/cosm/deploy_erc20.js +++ b/scripts/cosm/deploy_erc20.js @@ -118,7 +118,18 @@ async function main() { }, ], }; - for (const initMsg of [initMsgAsh, initMsgBash]) { + const initMsgCash = { + decimals: 18, + name: "Cash Token", + symbol: "CASH", + initial_balances: [ + { + address: faucetAddress, + amount: "189189189000000000000000000", // 189189189 CASH + }, + ], + }; + for (const initMsg of [initMsgAsh, initMsgBash, initMsgCash]) { const initResult = await instantiateContract(client, pen, codeId, initMsg); if (initResult.code) { throw new Error(`Instantiation failed with code: ${initResult.code}; log: '${initResult.raw_log}'`); diff --git a/yarn.lock b/yarn.lock index 634f4e2f..2f12d9fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -966,6 +966,13 @@ dependencies: "@types/babel-types" "*" +"@types/bn.js@^4.11.6": + version "4.11.6" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c" + integrity sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg== + dependencies: + "@types/node" "*" + "@types/body-parser@*": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897" @@ -1826,6 +1833,11 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.8, bn.js@^4.4.0: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== +bn.js@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.1.tgz#48efc4031a9c4041b9c99c6941d903463ab62eb5" + integrity sha512-IUTD/REb78Z2eodka1QZyyEk66pciRcP6Sroka0aI3tG/iwIdYLrBD62RsubR7vqdt3WyX8p4jxeatzmRSphtA== + body-parser@^1.16.1: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"