diff --git a/CHANGELOG.md b/CHANGELOG.md index 997e74f8..545259e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to ## [Unreleased] +### Fixed + +- @cosmjs/amino: Fix escaping of "&", "<" and ">" characters in Amino JSON + encoding to match the Go implementation ([#1373], [#1388]). + +[#1373]: https://github.com/cosmos/cosmjs/pull/1373 +[#1388]: https://github.com/cosmos/cosmjs/pull/1388 + ## [0.30.0] - 2023-03-09 ### Changed diff --git a/packages/amino/src/signdoc.spec.ts b/packages/amino/src/signdoc.spec.ts index 121b3587..e4c1261c 100644 --- a/packages/amino/src/signdoc.spec.ts +++ b/packages/amino/src/signdoc.spec.ts @@ -2,7 +2,7 @@ import { Random } from "@cosmjs/crypto"; import { toBech32 } from "@cosmjs/encoding"; -import { AminoMsg, makeSignDoc, sortedJsonStringify } from "./signdoc"; +import { AminoMsg, escapeCharacters, makeSignDoc, sortedJsonStringify } from "./signdoc"; function makeRandomAddress(): string { return toBech32("cosmos", Random.getBytes(20)); @@ -132,4 +132,43 @@ describe("encoding", () => { }); }); }); + + describe("escapeCharacters", () => { + it("works", () => { + // Unchanged originals + expect(escapeCharacters(`""`)).toEqual(`""`); + expect(escapeCharacters(`{}`)).toEqual(`{}`); + expect(escapeCharacters(`[]`)).toEqual(`[]`); + expect(escapeCharacters(`[123,null,"foo",[{}]]`)).toEqual(`[123,null,"foo",[{}]]`); + expect(escapeCharacters(`{"num":123}`)).toEqual(`{"num":123}`); + expect(escapeCharacters(`{"memo":"123"}`)).toEqual(`{"memo":"123"}`); + expect(escapeCharacters(`{"memo":"\\u0026"}`)).toEqual(`{"memo":"\\u0026"}`); + + // Escapes one + expect(escapeCharacters(`{"m":"with amp: &"}`)).toEqual(`{"m":"with amp: \\u0026"}`); + expect(escapeCharacters(`{"m":"with lt: <"}`)).toEqual(`{"m":"with lt: \\u003c"}`); + expect(escapeCharacters(`{"m":"with gt: >"}`)).toEqual(`{"m":"with gt: \\u003e"}`); + // Escapes multiple + expect(escapeCharacters(`{"m":"with amp: &&"}`)).toEqual(`{"m":"with amp: \\u0026\\u0026"}`); + expect(escapeCharacters(`{"m":"with lt: <<"}`)).toEqual(`{"m":"with lt: \\u003c\\u003c"}`); + expect(escapeCharacters(`{"m":"with gt: >>"}`)).toEqual(`{"m":"with gt: \\u003e\\u003e"}`); + expect(escapeCharacters(`{"m":"with all: &<>"}`)).toEqual(`{"m":"with all: \\u0026\\u003c\\u003e"}`); + }); + + it("escaped encoding can be decoded to the same document", () => { + const docs = [ + { memo: "ampersand:&,lt:<,gt:>", value: 123.421 }, + "", + 123, + ["foo", "ampersand:&,lt:<,gt:>"], + ]; + + for (const doc of docs) { + const normalEncoding = JSON.stringify(doc); + const escapedEncoding = escapeCharacters(normalEncoding); + expect(JSON.parse(escapedEncoding)).toEqual(JSON.parse(normalEncoding)); + expect(JSON.parse(escapedEncoding)).toEqual(doc); + } + }); + }); }); diff --git a/packages/amino/src/signdoc.ts b/packages/amino/src/signdoc.ts index 2348353e..3a6f56c1 100644 --- a/packages/amino/src/signdoc.ts +++ b/packages/amino/src/signdoc.ts @@ -72,6 +72,28 @@ export function makeSignDoc( }; } -export function serializeSignDoc(signDoc: StdSignDoc): Uint8Array { - return toUtf8(sortedJsonStringify(signDoc)); +/** + * Takes a valid JSON document and performs the following escapings in string values: + * + * `&` -> `\u0026` + * `<` -> `\u003c` + * `>` -> `\u003e` + * + * Since those characters do not occur in other places of the JSON document, only + * string values are affected. + * + * If the input is invalid JSON, the behaviour is undefined. + */ +export function escapeCharacters(input: string): string { + // When we migrate to target es2021 or above, we can use replaceAll instead of global patterns. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll + const amp = /&/g; + const lt = //g; + return input.replace(amp, "\\u0026").replace(lt, "\\u003c").replace(gt, "\\u003e"); +} + +export function serializeSignDoc(signDoc: StdSignDoc): Uint8Array { + const serialized = escapeCharacters(sortedJsonStringify(signDoc)); + return toUtf8(serialized); } diff --git a/packages/stargate/src/signingstargateclient.spec.ts b/packages/stargate/src/signingstargateclient.spec.ts index e0a58e2b..3f15b5c9 100644 --- a/packages/stargate/src/signingstargateclient.spec.ts +++ b/packages/stargate/src/signingstargateclient.spec.ts @@ -455,6 +455,33 @@ describe("SigningStargateClient", () => { }); describe("legacy Amino mode", () => { + it("works with special characters in memo", async () => { + pendingWithoutSimapp(); + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const client = await SigningStargateClient.connectWithSigner( + simapp.tendermintUrl, + wallet, + defaultSigningClientOptions, + ); + + const msgSend: MsgSend = { + fromAddress: faucet.address0, + toAddress: makeRandomAddress(), + amount: coins(1234, "ucosm"), + }; + const msgAny: MsgSendEncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msgSend, + }; + const fee = { + amount: coins(2000, "ucosm"), + gas: "200000", + }; + const memo = "ampersand:&,lt:<,gt:>"; + const result = await client.signAndBroadcast(faucet.address0, [msgAny], fee, memo); + assertIsDeliverTxSuccess(result); + }); + it("works with bank MsgSend", async () => { pendingWithoutSimapp(); const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic);