Merge pull request #71 from confio/erc20-faucet

Add ERC20 send support for faucet
This commit is contained in:
Simon Warta 2020-02-10 21:44:35 +01:00 committed by GitHub
commit b5e600f5b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 516 additions and 172 deletions

View File

@ -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"

View File

@ -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);
});
});
});

View File

@ -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);

View File

@ -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<SendTransaction>({
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<SendTransaction>({
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<SendTransaction>({
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();
});
});
});

View File

@ -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<Account | undefined> {
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<Account | undefined> {
@ -291,8 +295,10 @@ export class CosmWasmConnection implements BlockchainConnection {
): Promise<readonly (ConfirmedTransaction<UnsignedTransaction> | 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(

View File

@ -10,12 +10,12 @@ import { CosmWasmConnection, TokenConfiguration } from "./cosmwasmconnection";
export function createCosmWasmConnector(
url: string,
addressPrefix: CosmosAddressBech32Prefix,
tokens: TokenConfiguration,
tokenConfig: TokenConfiguration,
expectedChainId?: ChainId,
): ChainConnector<CosmWasmConnection> {
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,
};

View File

@ -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", () => {

View File

@ -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: {

View File

@ -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;

View File

@ -7,6 +7,6 @@ import { CosmWasmConnection, TokenConfiguration } from "./cosmwasmconnection";
export declare function createCosmWasmConnector(
url: string,
addressPrefix: CosmosAddressBech32Prefix,
tokens: TokenConfiguration,
tokenConfig: TokenConfiguration,
expectedChainId?: ChainId,
): ChainConnector<CosmWasmConnection>;

View File

@ -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;

View File

@ -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
```

View File

@ -19,7 +19,7 @@ export async function start(args: ReadonlyArray<string>): Promise<void> {
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<string>): Promise<void> {
);
// 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();

View File

@ -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",
},
],
};

View File

@ -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();
});
});

View File

@ -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);
}

View File

@ -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}'`);

View File

@ -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"