Skip to content
gnolangPublic

About

Amino binary and JSON encoding for TypeScript, byte-compatible with go-amino, with tm2 and gno.land types

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@gnolang/amino-ts

Amino encoding and decoding for TypeScript, byte-for-byte compatible with the Go implementation used by Tendermint2 and gno.land.

  • Binary (amino.Marshal), JSON (amino.MarshalJSON), Any, and the length-prefixed (Sized) forms, for encoding and decoding.
  • Schema-driven. Declare your types once with the t builders, which mirror Go declarations and struct tags. Value types are inferred.
  • Go-exact. Tested differentially against go-amino: thousands of fuzzed values, corrupted inputs, and real gno.land transactions (including their sign bytes) are produced by Go and must be reproduced exactly.
  • No runtime dependencies and no Node APIs, so it runs in browsers, Node, Deno and Bun. Ships ESM and CJS.

Install

npm install @gnolang/amino-ts

Usage

import { Codec, t, type Infer } from "@gnolang/amino-ts";

// type Coin struct { Denom string `json:"denom"`; Amount int64 `json:"amount"` }
const Coin = t.struct("Coin", {
  denom: t.string,
  amount: t.int64,
});

// type MsgSend struct { ... }
const MsgSend = t.struct("MsgSend", {
  fromAddress: t.field(t.string, { json: "from_address" }),
  toAddress: t.field(t.string, { json: "to_address" }),
  amount: t.slice(Coin),
});

type MsgSend = Infer<typeof MsgSend>;
// { fromAddress: string; toAddress: string; amount: { denom: string; amount: bigint }[] | null }

const cdc = new Codec().register("/bank.MsgSend", MsgSend);

const msg: MsgSend = {
  fromAddress: "g1...",
  toAddress: "g1...",
  amount: [{ denom: "ugnot", amount: 1000n }],
};

const bytes = cdc.marshal(MsgSend, msg);           // Uint8Array
const back = cdc.unmarshal(MsgSend, bytes);        // MsgSend
const json = cdc.marshalJSON(MsgSend, msg);        // '{"from_address":"g1...",...}'

// Interfaces (protobuf Any)
const any = cdc.marshalAny({ typeUrl: "/bank.MsgSend", value: msg });
cdc.unmarshalAny(any); // { typeUrl: "/bank.MsgSend", value: {...} }
cdc.marshalJSONAny({ typeUrl: "/bank.MsgSend", value: msg });
// '{"@type":"/bank.MsgSend","from_address":"g1...",...}'

gno.land and tm2 types

@gnolang/amino-ts/gno ships schemas for every amino type of tm2 and gno.land: transactions and messages, accounts, blocks, headers, commits, votes, validators, evidence, ABCI requests and responses, events, Merkle proofs and genesis state. They are generated from go-amino's own type information (pnpm schemas), so field order, tags and type URLs match the Go code.

import { Infer } from "@gnolang/amino-ts";
import {
  gnoCodec, std, vm, bft, abci,
  addressFromBech32, parseCoins, getSignaturePayload, type SignDoc,
} from "@gnolang/amino-ts/gno";

const cdc = gnoCodec(); // every type registered under its Go type URL

const call: Infer<typeof vm.MsgCall> = {
  caller: addressFromBech32("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"),
  send: parseCoins("1000ugnot"),
  maxDeposit: [],
  pkgPath: "gno.land/r/demo/counter",
  func: "Incr",
  args: null,
};

const tx: Infer<typeof std.Tx> = {
  msgs: [{ typeUrl: "/vm.m_call", value: call }],
  fee: { gasWanted: 1_000_000n, gasFee: { denom: "ugnot", amount: 1000n } },
  signatures: null,
  memo: "",
};

// Sign bytes, exactly as std.GetSignaturePayload builds them.
const doc: SignDoc = { chainID: "gnoland1", accountNumber: 7n, sequence: 0n, fee: tx.fee, msgs: tx.msgs, memo: tx.memo };
const signBytes = getSignaturePayload(cdc, doc);
// (getSignaturePayloadLegacy gives the older rendering; nodes accept both.)

const txBytes = cdc.marshal(std.Tx, tx);

// Blocks and results from RPC:
const header = cdc.unmarshal(bft.Header, headerBytes);
const result = cdc.unmarshal(abci.ResponseDeliverTx, resultBytes);

Namespaces follow the Go packages: std, bank, vm, auth, params, gnoland, chain (gno event types), bft (tm2/pkg/bft/types), abci, sdk, merkle, bitarray, crypto, secp256k1, ed25519 and multisig. Field names are the Go names in lowerCamelCase (ChainID becomes chainID). The JSON keys are unchanged.

The types Go encodes through MarshalAmino have JS values that are easy to work with:

Go JS value Helpers
crypto.Address Uint8Array (20 bytes) addressToBech32, addressFromBech32
std.Coin { denom, amount: bigint } parseCoin, formatCoin
std.Coins Coin[] parseCoins, formatCoins (same validation and sorting as Go)
params.Param { key, type, value } parseParam, formatParam
gnoland.Balance { address, amount, vesting } parseBalance, formatBalance

gnoCodec() also records which interfaces each type implements, so placing a non-message in std.Tx.msgs fails, as it does in Go. Extra types can be added to a codec, e.g. cdc.register("/mymod.MsgFoo", MsgFoo, [std.Msg]).

Sign bytes for your own types

tm2 signs sortJSON(aminoJSON(signDoc)). sortJSON matches std.MustSortJSON: keys sorted, whitespace stripped, and strings and numbers re-encoded as Go does.

Schema reference

Go Schema JS value
bool t.bool boolean
int8 int16 int32 t.int8 t.int16 t.int32 number
uint8/byte uint16 uint32 t.uint8/t.byte t.uint16 t.uint32 number
int64 int uint64 uint t.int64 t.int t.uint64 t.uint bigint (encoders also take safe-integer numbers and decimal strings)
float32 float64 t.float32 t.float64 (need unsafe) number
string t.string string
[]byte t.bytes Uint8Array | null
[N]byte t.byteArray(N) Uint8Array
[]T t.slice(T) T[] | null
[N]T t.array(T, N) T[]
*T t.pointer(T) T | null
struct {...} t.struct(name, { field: T, ... }) object
interface t.interface(name?) { typeUrl, value } | null
time.Time t.time { seconds: bigint, nanos: number } (a Date is accepted when encoding)
time.Duration t.duration bigint nanoseconds
MarshalAmino/UnmarshalAmino t.repr(reprType, toRepr, fromRepr, options?) any
_ struct{} amino:"reserved" t.reserved() (none)
recursive types t.lazy(() => T)

null stands for Go's nil. Go distinguishes a nil slice (null in JSON) from an empty one ([]), and so does this library. Decoding binary always yields null for empty slices and bytes, as Go does.

Struct fields and tags

Object key order is field order, and field order sets the field numbers, as in Go. Keys that look like integers are rejected, because JS would reorder them. Wrap a type in t.field(type, options) to add tags:

Go tag option
json:"name" json: "name"
json:",omitempty" omitEmpty: true
binary:"fixed32" / binary:"fixed64" fixed32: true / fixed64: true
binary:"varint" varint: true
amino:"unsafe" unsafe: true
amino:"write_empty" writeEmpty: true
amino:"nil_elements" nilElements: true

A field tagged json:"-" is not encoded at all, so leave it out of the schema. A field missing from a JS object is encoded as its zero value.

Repr types (MarshalAmino)

A Go type with MarshalAmino() (R, error) is encoded as R:

// crypto.Address: [20]byte, encoded as a bech32 string
const Address = t.repr(t.string, addr => bech32Encode("g", addr), s => bech32Decode(s), {
  goKind: "array",
  isZero: a => a.every(b => b === 0),
});

Amino also looks at the kind of the Go type itself, so t.repr asks for it:

  • goKind: "struct" (the default), "array", or "scalar" (Go numbers, strings, bools and slices). A "scalar" field holding its zero value is skipped in binary without being converted, and non-struct kinds use the {"@type","value"} form of interface JSON.
  • isZero(v): whether v is the Go zero value, for omitempty (and for skipping "scalar" fields).
  • zero(): the Go zero value, if fromRepr(zero of R) does not give it.

If a repr type is used behind a pointer, give it a zero value that is not null, or a nil pointer and a pointer to the zero value become indistinguishable.

Registration

new Codec() comes with go-amino's built-in registrations: /google.protobuf.{Timestamp,Duration,Int64Value,UInt64Value,Int32Value,UInt32Value,BoolValue,StringValue,BytesValue,Empty} and /amino.{UInt8,Int8,UInt16,Int16}. Register your own concrete types with register(typeUrl, type), or with registerPackage(p3pkg, { Name: type }), which builds the /<p3pkg>.<Name> URLs the way amino.NewPackage does. As in Go, type URLs are matched on the part after the last /.

Compatibility notes

  • Strictness matches Go. Decoders reject what go-amino rejects: out-of-order or duplicate fields, unknown fields and JSON keys, trailing bytes, unquoted 64-bit JSON integers, and Any nesting deeper than 64. They also accept what Go accepts, such as non-canonical varints and trailing data after a top-level JSON object.
  • Two Go decoders. Go decodes types with generated code (every tm2 and gno.land type) differently from types it handles by reflection, on two malformed inputs. The default matches the generated decoders, which is what nodes run. Pass new Codec({ goDecoder: "reflect" }) to match reflection.
  • Invalid UTF-8. Go strings can hold arbitrary bytes, and JS strings cannot. By default, invalid sequences decode to U+FFFD, like TextDecoder, so re-encoding such a value does not reproduce the original bytes. Pass new Codec({ strictUtf8: true }) to reject such input instead.
  • Go zero time. A zero time.Time in Go is year 1, and amino treats the Unix epoch as its "empty" time. Decoders return year-1 timestamps wherever Go would (empty Any values, fields of absent nested structs), so that re-encoding stays identical.
  • go-amino cannot encode a struct field whose repr type is a list of structs (it panics), so this library refuses to encode one too.

Development

pnpm install
pnpm test          # vitest
pnpm lint
pnpm build         # tsc + tsdown + attw
pnpm schemas       # regenerate src/gno/types.gen.ts from go-amino (needs Go and ../gno)
pnpm fixtures      # regenerate testdata/*.json from go-amino

gen/ is a Go module that uses ../gno through a replace directive:

  • gen/schemagen generates src/gno/types.gen.ts for the packages listed in gen/gnotypes. Go types with MarshalAmino map to the hand-written schemas in src/gno/reprs.ts.
  • gen itself fuzzes values with go-amino, records every encode and decode path, and writes the fixtures.
    • testdata/fixtures.json covers every amino feature through the types in gen/types.go, which tests/schemas.ts mirrors. When you add a type there, add it to both.
    • testdata/gno-fixtures.json covers every type in /gno.
    • It also records real gno.land transactions and their sign bytes.

License

Apache-2.0

About

Amino binary and JSON encoding for TypeScript, byte-compatible with go-amino, with tm2 and gno.land types

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages