diff --git a/packages/bcp/src/cosmwasmcodec.ts b/packages/bcp/src/cosmwasmcodec.ts index d2964397..584ea5e7 100644 --- a/packages/bcp/src/cosmwasmcodec.ts +++ b/packages/bcp/src/cosmwasmcodec.ts @@ -81,7 +81,7 @@ export class CosmWasmCodec implements TxCodec { throw new Error("Nonce is required"); } const parsed = unmarshalTx(bytes); - return parseSignedTx(parsed, chainId, nonce, this.bankTokens); + return parseSignedTx(parsed, chainId, nonce, this.bankTokens, this.erc20Tokens); } public identityToAddress(identity: Identity): Address { diff --git a/packages/bcp/src/cosmwasmconnection.spec.ts b/packages/bcp/src/cosmwasmconnection.spec.ts index 2301b7fb..a5505e0e 100644 --- a/packages/bcp/src/cosmwasmconnection.spec.ts +++ b/packages/bcp/src/cosmwasmconnection.spec.ts @@ -49,6 +49,7 @@ const faucet = { describe("CosmWasmConnection", () => { const cosm = "COSM" as TokenTicker; + const bash = "BASH" as TokenTicker; const httpUrl = "http://localhost:1317"; const defaultChainId = "cosmos:testing" as ChainId; const defaultEmptyAddress = "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r" as Address; @@ -267,7 +268,7 @@ describe("CosmWasmConnection", () => { }); describe("getTx", () => { - it("can get a recently posted transaction", async () => { + it("can get a recently posted bank send transaction", async () => { pendingWithoutWasmd(); const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); const profile = new UserProfile(); @@ -318,6 +319,56 @@ describe("CosmWasmConnection", () => { connection.disconnect(); }); + it("can get a recently posted ERC20 send transaction", async () => { + pendingWithoutWasmd(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const profile = new UserProfile(); + const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(faucet.mnemonic)); + const senderIdentity = await profile.createIdentity(wallet.id, defaultChainId, faucet.path); + const senderAddress = connection.codec.identityToAddress(senderIdentity); + + const unsigned = await connection.withDefaultFee({ + kind: "bcp/send", + chainId: defaultChainId, + sender: senderAddress, + recipient: defaultRecipient, + memo: "An ERC20 payment", + amount: { + quantity: "345", + fractionalDigits: 0, + tokenTicker: bash, + }, + }); + const nonce = await connection.getNonce({ address: senderAddress }); + const signed = await profile.signTransaction(senderIdentity, unsigned, connection.codec, nonce); + const postableBytes = connection.codec.bytesToPost(signed); + const response = await connection.postTx(postableBytes); + const { transactionId } = response; + await response.blockInfo.waitFor(info => isBlockInfoSucceeded(info)); + + const getResponse = await connection.getTx(transactionId); + expect(getResponse.transactionId).toEqual(transactionId); + assert(isConfirmedTransaction(getResponse), "Expected transaction to succeed"); + assert(getResponse.log, "Log must be available"); + const [firstLog] = JSON.parse(getResponse.log); + expect(firstLog.events.length).toEqual(1); + + const { transaction, signatures } = getResponse; + assert(isSendTransaction(transaction), "Expected send transaction"); + expect(transaction).toEqual(unsigned); + expect(signatures.length).toEqual(1); + expect(signatures[0]).toEqual({ + nonce: signed.signatures[0].nonce, + pubkey: { + algo: signed.signatures[0].pubkey.algo, + data: Secp256k1.compressPubkey(signed.signatures[0].pubkey.data), + }, + signature: Secp256k1.trimRecoveryByte(signed.signatures[0].signature), + }); + + connection.disconnect(); + }); + it("can get an old transaction", async () => { pendingWithoutWasmd(); const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); diff --git a/packages/bcp/src/cosmwasmconnection.ts b/packages/bcp/src/cosmwasmconnection.ts index 07c6e391..6a659c6a 100644 --- a/packages/bcp/src/cosmwasmconnection.ts +++ b/packages/bcp/src/cosmwasmconnection.ts @@ -348,7 +348,13 @@ export class CosmWasmConnection implements BlockchainConnection { private parseAndPopulateTxResponseUnsigned( response: TxsResponse, ): ConfirmedTransaction | FailedTransaction { - return parseTxsResponseUnsigned(this.chainId, parseInt(response.height, 10), response, this.bankTokens); + return parseTxsResponseUnsigned( + this.chainId, + parseInt(response.height, 10), + response, + this.bankTokens, + this.erc20Tokens, + ); } private async parseAndPopulateTxResponseSigned( @@ -387,6 +393,7 @@ export class CosmWasmConnection implements BlockchainConnection { nonce, response, this.bankTokens, + this.erc20Tokens, ); } } diff --git a/packages/bcp/src/decode.spec.ts b/packages/bcp/src/decode.spec.ts index dde064c6..cdfa1129 100644 --- a/packages/bcp/src/decode.spec.ts +++ b/packages/bcp/src/decode.spec.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/camelcase */ import { types } from "@cosmwasm/sdk"; -import { Address, Algorithm, SendTransaction, TokenTicker } from "@iov/bcp"; +import { Address, Algorithm, isSendTransaction, SendTransaction, TokenTicker } from "@iov/bcp"; import { Encoding } from "@iov/encoding"; +import { assert } from "@iov/utils"; import { decodeAmount, @@ -17,7 +18,7 @@ import { } from "./decode"; import * as testdata from "./testdata.spec"; import cosmoshub from "./testdata/cosmoshub.json"; -import { BankTokens } from "./types"; +import { BankTokens, Erc20Token } from "./types"; const { fromBase64, fromHex } = Encoding; @@ -63,6 +64,23 @@ describe("decode", () => { denom: "uatom", }, ]; + const defaultErc20Tokens: Erc20Token[] = [ + { + contractAddress: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + fractionalDigits: 5, + ticker: "ASH", + }, + { + contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + fractionalDigits: 0, + ticker: "BASH", + }, + { + contractAddress: "cosmos18r5szma8hm93pvx6lwpjwyxruw27e0k5uw835c", + fractionalDigits: 18, + ticker: "CASH", + }, + ]; describe("decodePubkey", () => { it("works for secp256k1", () => { @@ -126,7 +144,7 @@ describe("decode", () => { }); describe("parseMsg", () => { - it("works", () => { + it("works for bank send transaction", () => { const msg: types.Msg = { type: "cosmos-sdk/MsgSend", value: { @@ -140,7 +158,40 @@ describe("decode", () => { ], }, }; - expect(parseMsg(msg, defaultMemo, testdata.chainId, defaultTokens)).toEqual(defaultSendTransaction); + expect(parseMsg(msg, defaultMemo, testdata.chainId, defaultTokens, defaultErc20Tokens)).toEqual( + defaultSendTransaction, + ); + }); + + it("works for ERC20 send transaction", () => { + const msg: types.MsgExecuteContract = { + type: "wasm/execute", + value: { + sender: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r", + contract: defaultErc20Tokens[0].contractAddress, + msg: { + transfer: { + amount: "887878484", + recipient: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e", + }, + }, + sent_funds: [], + }, + }; + const transaction = parseMsg(msg, defaultMemo, testdata.chainId, defaultTokens, defaultErc20Tokens); + assert(isSendTransaction(transaction)); + expect(transaction).toEqual({ + kind: "bcp/send", + chainId: testdata.chainId, + sender: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r" as Address, + recipient: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e" as Address, + amount: { + quantity: "887878484", + tokenTicker: "ASH" as TokenTicker, + fractionalDigits: 5, + }, + memo: defaultMemo, + }); }); }); @@ -160,18 +211,70 @@ describe("decode", () => { }); describe("parseUnsignedTx", () => { - it("works", () => { - expect(parseUnsignedTx(cosmoshub.tx.value, testdata.chainId, defaultTokens)).toEqual( - testdata.sendTxJson, - ); + it("works for bank send transaction", () => { + expect( + parseUnsignedTx(cosmoshub.tx.value, testdata.chainId, defaultTokens, defaultErc20Tokens), + ).toEqual(testdata.sendTxJson); + }); + + it("works for ERC20 send transaction", () => { + const msg: types.MsgExecuteContract = { + type: "wasm/execute", + value: { + sender: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r", + contract: defaultErc20Tokens[0].contractAddress, + msg: { + transfer: { + amount: "887878484", + recipient: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e", + }, + }, + sent_funds: [], + }, + }; + const tx: types.StdTx = { + msg: [msg], + memo: defaultMemo, + fee: { + amount: [ + { + denom: "uatom", + amount: "5000", + }, + ], + gas: "200000", + }, + signatures: [], + }; + const unsigned = parseUnsignedTx(tx, testdata.chainId, defaultTokens, defaultErc20Tokens); + assert(isSendTransaction(unsigned)); + expect(unsigned).toEqual({ + kind: "bcp/send", + chainId: testdata.chainId, + sender: "cosmos1h806c7khnvmjlywdrkdgk2vrayy2mmvf9rxk2r" as Address, + recipient: "cosmos1z7g5w84ynmjyg0kqpahdjqpj7yq34v3suckp0e" as Address, + amount: { + quantity: "887878484", + tokenTicker: "ASH" as TokenTicker, + fractionalDigits: 5, + }, + memo: defaultMemo, + fee: defaultFee, + }); }); }); describe("parseSignedTx", () => { it("works", () => { - expect(parseSignedTx(cosmoshub.tx.value, testdata.chainId, testdata.nonce, defaultTokens)).toEqual( - testdata.signedTxJson, - ); + expect( + parseSignedTx( + cosmoshub.tx.value, + testdata.chainId, + testdata.nonce, + defaultTokens, + defaultErc20Tokens, + ), + ).toEqual(testdata.signedTxJson); }); }); @@ -192,9 +295,15 @@ describe("decode", () => { transactionId: testdata.txId, log: '[{"msg_index":0,"success":true,"log":""}]', }; - expect(parseTxsResponseUnsigned(testdata.chainId, currentHeight, txsResponse, defaultTokens)).toEqual( - expected, - ); + expect( + parseTxsResponseUnsigned( + testdata.chainId, + currentHeight, + txsResponse, + defaultTokens, + defaultErc20Tokens, + ), + ).toEqual(expected); }); }); @@ -216,7 +325,14 @@ describe("decode", () => { log: '[{"msg_index":0,"success":true,"log":""}]', }; expect( - parseTxsResponseSigned(testdata.chainId, currentHeight, testdata.nonce, txsResponse, defaultTokens), + parseTxsResponseSigned( + testdata.chainId, + currentHeight, + testdata.nonce, + txsResponse, + defaultTokens, + defaultErc20Tokens, + ), ).toEqual(expected); }); }); diff --git a/packages/bcp/src/decode.ts b/packages/bcp/src/decode.ts index b57eb8bd..3279c9d9 100644 --- a/packages/bcp/src/decode.ts +++ b/packages/bcp/src/decode.ts @@ -19,8 +19,9 @@ import { UnsignedTransaction, } from "@iov/bcp"; import { Decimal, Encoding } from "@iov/encoding"; +import BN from "bn.js"; -import { BankTokens } from "./types"; +import { BankTokens, Erc20Token } from "./types"; const { fromBase64 } = Encoding; @@ -76,6 +77,7 @@ export function parseMsg( memo: string | undefined, chainId: ChainId, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): UnsignedTransaction { if (types.isMsgSend(msg)) { if (msg.value.amount.length !== 1) { @@ -90,6 +92,34 @@ export function parseMsg( memo: memo, }; return send; + } else if (types.isMsgExecuteContract(msg)) { + const matchingTokenContract = erc20Tokens.find(t => t.contractAddress === msg.value.contract); + if (!matchingTokenContract) { + return { + chainId: chainId, + kind: "bcp/unknown", + }; + } + + const recipient: string | undefined = (msg.value.msg as any).transfer?.recipient; + if (!recipient) throw new Error("Could not read recipient"); + + const amount: string | undefined = (msg.value.msg as any).transfer?.amount; + if (!amount) throw new Error("Could not read recipient"); + + const send: SendTransaction = { + kind: "bcp/send", + chainId: chainId, + sender: msg.value.sender as Address, + recipient: recipient as Address, + amount: { + quantity: new BN(amount).toString(), + fractionalDigits: matchingTokenContract.fractionalDigits, + tokenTicker: matchingTokenContract.ticker as TokenTicker, + }, + memo: memo, + }; + return send; } else { // Unknown transaction type const unknown = { @@ -114,6 +144,7 @@ export function parseUnsignedTx( txValue: types.StdTx, chainId: ChainId, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): UnsignedTransaction { if (!types.isStdTx(txValue)) { throw new Error("Only StdTx is supported"); @@ -122,7 +153,7 @@ export function parseUnsignedTx( throw new Error("Only single-message transactions currently supported"); } - const msg = parseMsg(txValue.msg[0], txValue.memo, chainId, tokens); + const msg = parseMsg(txValue.msg[0], txValue.memo, chainId, tokens, erc20Tokens); const fee = parseFee(txValue.fee, tokens); return { @@ -137,10 +168,11 @@ export function parseSignedTx( chainId: ChainId, nonce: Nonce, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): SignedTransaction { const [primarySignature] = txValue.signatures.map(signature => decodeFullSignature(signature, nonce)); return { - transaction: parseUnsignedTx(txValue, chainId, tokens), + transaction: parseUnsignedTx(txValue, chainId, tokens, erc20Tokens), signatures: [primarySignature], }; } @@ -150,10 +182,11 @@ export function parseTxsResponseUnsigned( currentHeight: number, response: TxsResponse, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): ConfirmedTransaction { const height = parseInt(response.height, 10); return { - transaction: parseUnsignedTx(response.tx.value, chainId, tokens), + transaction: parseUnsignedTx(response.tx.value, chainId, tokens, erc20Tokens), height: height, confirmations: currentHeight - height + 1, transactionId: response.txhash as TransactionId, @@ -167,10 +200,11 @@ export function parseTxsResponseSigned( nonce: Nonce, response: TxsResponse, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): ConfirmedAndSignedTransaction { const height = parseInt(response.height, 10); return { - ...parseSignedTx(response.tx.value, chainId, nonce, tokens), + ...parseSignedTx(response.tx.value, chainId, nonce, tokens, erc20Tokens), height: height, confirmations: currentHeight - height + 1, transactionId: response.txhash as TransactionId, diff --git a/packages/bcp/types/decode.d.ts b/packages/bcp/types/decode.d.ts index c895d2a0..b2fac2c7 100644 --- a/packages/bcp/types/decode.d.ts +++ b/packages/bcp/types/decode.d.ts @@ -13,7 +13,7 @@ import { UnsignedTransaction, } from "@iov/bcp"; import { Decimal } from "@iov/encoding"; -import { BankTokens } from "./types"; +import { BankTokens, Erc20Token } 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; @@ -24,24 +24,28 @@ export declare function parseMsg( memo: string | undefined, chainId: ChainId, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): UnsignedTransaction; export declare function parseFee(fee: types.StdFee, tokens: BankTokens): Fee; export declare function parseUnsignedTx( txValue: types.StdTx, chainId: ChainId, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): UnsignedTransaction; export declare function parseSignedTx( txValue: types.StdTx, chainId: ChainId, nonce: Nonce, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): SignedTransaction; export declare function parseTxsResponseUnsigned( chainId: ChainId, currentHeight: number, response: TxsResponse, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): ConfirmedTransaction; export declare function parseTxsResponseSigned( chainId: ChainId, @@ -49,4 +53,5 @@ export declare function parseTxsResponseSigned( nonce: Nonce, response: TxsResponse, tokens: BankTokens, + erc20Tokens: readonly Erc20Token[], ): ConfirmedAndSignedTransaction;