diff --git a/packages/encoding/package.json b/packages/encoding/package.json index 92b41678..9106c71e 100644 --- a/packages/encoding/package.json +++ b/packages/encoding/package.json @@ -27,6 +27,7 @@ "lint": "eslint . --fix" }, "dependencies": { + "@interchainjs/math": "1.9.11", "base64-js": "^1.3.0", "bech32": "^1.1.4", "readonly-date": "^1.0.0" diff --git a/packages/encoding/src/index.ts b/packages/encoding/src/index.ts index a2dc4771..bd66880f 100644 --- a/packages/encoding/src/index.ts +++ b/packages/encoding/src/index.ts @@ -4,3 +4,4 @@ export { fromBech32, normalizeBech32, toBech32 } from "./bech32"; export { fromHex, toHex } from "./hex"; export { fromRfc3339, toRfc3339 } from "./rfc3339"; export { fromUtf8, toUtf8 } from "./utf8"; +export { toAccAddress, longify, decodeCosmosSdkDecFromProto } from "./utils"; \ No newline at end of file diff --git a/packages/encoding/src/utils.spec.ts b/packages/encoding/src/utils.spec.ts new file mode 100644 index 00000000..5db66c1f --- /dev/null +++ b/packages/encoding/src/utils.spec.ts @@ -0,0 +1,21 @@ +import { fromHex } from "./hex"; + +import { decodeCosmosSdkDecFromProto } from "./utils"; + +describe("utils", () => { + describe("decodeCosmosSdkDecFromProto", () => { + it("works for string inputs", () => { + expect(decodeCosmosSdkDecFromProto("0").toString()).toEqual("0"); + expect(decodeCosmosSdkDecFromProto("1").toString()).toEqual("0.000000000000000001"); + expect(decodeCosmosSdkDecFromProto("3000000").toString()).toEqual("0.000000000003"); + expect(decodeCosmosSdkDecFromProto("123456789123456789").toString()).toEqual("0.123456789123456789"); + expect(decodeCosmosSdkDecFromProto("1234567891234567890").toString()).toEqual("1.23456789123456789"); + }); + + it("works for byte inputs", () => { + expect(decodeCosmosSdkDecFromProto(fromHex("313330303033343138373830313631333938")).toString()).toEqual( + "0.130003418780161398", + ); + }); + }); +}); diff --git a/packages/encoding/src/utils.ts b/packages/encoding/src/utils.ts new file mode 100644 index 00000000..694c5cd9 --- /dev/null +++ b/packages/encoding/src/utils.ts @@ -0,0 +1,32 @@ +import { fromAscii } from "./ascii"; +import { fromBech32 } from "./bech32"; +import { Decimal, Uint64 } from "@interchainjs/math"; + +/** + * Takes a bech32 encoded address and returns the data part. The prefix is ignored and discarded. + * This is called AccAddress in Cosmos SDK, which is basically an alias for raw binary data. + * The result is typically 20 bytes long but not restricted to that. + */ +export function toAccAddress(address: string): Uint8Array { + return fromBech32(address).data; +} + +/** + * Takes a uint64 value as string, number, BigInt or Uint64 and returns a BigInt + * of it. + */ +export function longify(value: string | number | Uint64): bigint { + const checkedValue = Uint64.fromString(value.toString()); + return BigInt(checkedValue.toString()); +} + +/** + * Takes a string or binary encoded `github.com/cosmos/cosmos-sdk/types.Dec` from the + * protobuf API and converts it into a `Decimal` with 18 fractional digits. + * + * See https://github.com/cosmos/cosmos-sdk/issues/10863 for more context why this is needed. + */ +export function decodeCosmosSdkDecFromProto(input: string | Uint8Array): Decimal { + const asString = typeof input === "string" ? input : fromAscii(input); + return Decimal.fromAtomics(asString, 18); +} diff --git a/packages/utils/package.json b/packages/utils/package.json index 203771a9..78e9cbc8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -25,6 +25,8 @@ "lint": "eslint . --fix" }, "dependencies": { + "@chain-registry/v2": "1.71.71", + "@chain-registry/v2-types": "0.53.72", "@interchainjs/types": "1.9.11", "bech32": "^2.0.0", "decimal.js": "^10.4.3" diff --git a/packages/utils/src/events.ts b/packages/utils/src/events.ts new file mode 100644 index 00000000..3ca2de52 --- /dev/null +++ b/packages/utils/src/events.ts @@ -0,0 +1,27 @@ +/** + * An event attribute. + * + * This is the same attribute type as tendermint34.Attribute and tendermint35.EventAttribute + * but `key` and `value` are unified to strings. The conversion + * from bytes to string in the Tendermint 0.34 case should be done by performing + * [lossy] UTF-8 decoding. + * + * [lossy]: https://doc.rust-lang.org/stable/std/string/struct.String.html#method.from_utf8_lossy + */ +export interface Attribute { + readonly key: string; + readonly value: string; +} + +/** + * The same event type as tendermint34.Event and tendermint35.Event + * but attribute keys and values are unified to strings. The conversion + * from bytes to string in the Tendermint 0.34 case should be done by performing + * [lossy] UTF-8 decoding. + * + * [lossy]: https://doc.rust-lang.org/stable/std/string/struct.String.html#method.from_utf8_lossy + */ +export interface Event { + readonly type: string; + readonly attributes: readonly Attribute[]; +} \ No newline at end of file diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d6037275..b083becd 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -8,3 +8,5 @@ export * from "./arrays"; export * from "./typechecks"; export * from "./chain"; export * from "./rpc"; +export * from "./logs"; +export * from "./events"; \ No newline at end of file diff --git a/packages/utils/src/logs.ts b/packages/utils/src/logs.ts new file mode 100644 index 00000000..8f84d446 --- /dev/null +++ b/packages/utils/src/logs.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { isObjectLike } from "./typechecks"; + +import { Attribute, Event } from "./events"; + +export interface Log { + readonly msg_index: number; + readonly log: string; + readonly events: readonly Event[]; +} + +export function parseAttribute(input: unknown): Attribute { + if (!isObjectLike(input)) throw new Error("Attribute must be a non-null object"); + const { key, value } = input as any; + if (typeof key !== "string" || !key) throw new Error("Attribute's key must be a non-empty string"); + if (typeof value !== "string" && typeof value !== "undefined") { + throw new Error("Attribute's value must be a string or unset"); + } + + return { + key: key, + value: value || "", + }; +} + +export function parseEvent(input: unknown): Event { + if (!isObjectLike(input)) throw new Error("Event must be a non-null object"); + const { type, attributes } = input as any; + if (typeof type !== "string" || type === "") { + throw new Error(`Event type must be a non-empty string`); + } + if (!Array.isArray(attributes)) throw new Error("Event's attributes must be an array"); + return { + type: type, + attributes: attributes.map(parseAttribute), + }; +} + +export function parseLog(input: unknown): Log { + if (!isObjectLike(input)) throw new Error("Log must be a non-null object"); + const { msg_index, log, events } = input as any; + if (typeof msg_index !== "number") throw new Error("Log's msg_index must be a number"); + if (typeof log !== "string") throw new Error("Log's log must be a string"); + if (!Array.isArray(events)) throw new Error("Log's events must be an array"); + return { + msg_index: msg_index, + log: log, + events: events.map(parseEvent), + }; +} + +export function parseLogs(input: unknown): readonly Log[] { + if (!Array.isArray(input)) throw new Error("Logs must be an array"); + return input.map(parseLog); +} + +export function parseRawLog(input: string | undefined): readonly Log[] { + // Cosmos SDK >= 0.50 gives us an empty string here. This should be handled like undefined. + if (!input) return []; + + const logsToParse = JSON.parse(input).map(({ events }: { events: readonly unknown[] }, i: number) => ({ + msg_index: i, + events, + log: "", + })); + return parseLogs(logsToParse); +} + +/** + * Searches in logs for the first event of the given event type and in that event + * for the first first attribute with the given attribute key. + * + * Throws if the attribute was not found. + */ +export function findAttribute(logs: readonly Log[], eventType: string, attrKey: string): Attribute { + const firstLogs = logs.find(() => true); + const out = firstLogs?.events + .find((event) => event.type === eventType) + ?.attributes.find((attr) => attr.key === attrKey); + if (!out) { + throw new Error( + `Could not find attribute '${attrKey}' in first event of type '${eventType}' in first log.`, + ); + } + return out; +} diff --git a/yarn.lock b/yarn.lock index 9221a291..238edf21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1116,6 +1116,18 @@ resolved "https://registry.yarnpkg.com/@chain-registry/v2-types/-/v2-types-0.53.68.tgz#83173c3e79c7c89a382c4017e15db71970038847" integrity sha512-MCK9RKJ67VYWCJ0HtnDYJIottx4paoo6wKMbH3s6xWFzLVwufJWi0iKMoppVOIXWk+yr+h9W3puaLJPnYNez9A== +"@chain-registry/v2-types@0.53.72", "@chain-registry/v2-types@^0.53.40": + version "0.53.72" + resolved "https://registry.yarnpkg.com/@chain-registry/v2-types/-/v2-types-0.53.72.tgz#3621cc1e94cacb430c657c2af63d4825950b880f" + integrity sha512-HIbDFK0R1aZTbXdTN7FOZI+z3lHt4ZQWRYDctUk3IwvGxOC04gYCa45SfsHx4YpqALXa0hu9Acj5fUSp4mnZ5w== + +"@chain-registry/v2@1.71.71": + version "1.71.71" + resolved "https://registry.yarnpkg.com/@chain-registry/v2/-/v2-1.71.71.tgz#648eab79a487a2680c77b85fab96d5c5d5fb3eea" + integrity sha512-JdzJHRduw58io8ZOKy7mnAt9oFJqWSa5Bn5srWXG5326ztUCe1MLd9yZMMCos57mc4UFrOJm8Gr9V7lKtEZvjQ== + dependencies: + "@chain-registry/v2-types" "^0.53.40" + "@chain-registry/v2@^1.65.6": version "1.71.121" resolved "https://registry.yarnpkg.com/@chain-registry/v2/-/v2-1.71.121.tgz#38baa88c2d6eb59e8996a0bc2b754b2f492468d9"