Apophis allows integrating new types for de/serialization with standard encodings (currently ProtoBuf and Amino) by following a specific pattern. Generally, the default encodings expect your messages to be implemented as classes. The ProtoBuf and Amino encoding subsystems define the following types, as well as these methods for registration of new default types:
@apophis-sdk/core/encoding/protobuf/any.ts
export type ProtobufType<T1 extends string = string, T2 = any> = {
  get protobufTypeUrl(): T1;
  toProtobuf(value: T2): Uint8Array;
  fromProtobuf(value: Uint8Array): T2;
};

export declare function registerDefaultProtobuf(...types: ProtobufType[]): void;
@apophis-sdk/cosmos/encoding/amino.ts
export type AminoType<T1 extends string = string, T2 = any> = {
  get aminoTypeUrl(): T1;
  new(data: T2): { get data(); T2 }
};

export declare function registerDefaultAmino(...types: AminoType[]): void;
With v0.3.1, a new solution for protobufs is introduced and will eventually supersede the existing in v0.4.0:
import { IMessage } from '@kiruse/hiproto';

export type ProtobufSchemaType<T1 extends string = string, T2 = any> = {
  get protobufTypeUrl(): T1;
  get protobufSchema(): IMessage<any, T2>;
  new (data: T2): any;
};
This new method leverages a new in-house protobuf abstraction hiproto which uses a zod-like API to define protobuf schemas. Correspondingly, the Cosmos Bank Send message is implemented as follows:
import { registerDefaultProtobufs } from '@apophis-sdk/core/encoding/protobuf/any.js';
import type { Coin } from 'cosmjs-types/cosmos/base/v1beta1/coin';
import { MsgSend } from 'cosmjs-types/cosmos/bank/v1beta1/tx';
import { registerDefaultAminos } from '../encoding/amino';

export namespace Bank {
  export type SendData = {
    fromAddress: string;
    toAddress: string;
    amount: Coin[];
  }

  export class Send {
    static readonly protobufTypeUrl = '/cosmos.bank.v1beta1.MsgSend';
    static readonly aminoTypeUrl = 'cosmos-sdk/MsgSend';

    constructor(public data: SendData) {}

    static toProtobuf(value: Send): Uint8Array {
      return MsgSend.encode(MsgSend.fromPartial(value.data)).finish();
    }

    static fromProtobuf(value: Uint8Array): Send {
      const { fromAddress, toAddress, amount } = MsgSend.decode(value);
      return new Send({ fromAddress, toAddress, amount });
    }
  };

  registerDefaultProtobufs(Send);
  registerDefaultAminos(Send);
}
As the naming suggests, these are merely “default” types. Using the Middleware Subsystem, nearly every single aspect of the Apophis SDK can be fine-tuned to the requirements of a specific blockchain, with these default types used as fallbacks. The beauty of this approach is that the same message class can be used for a variety of blockchains, and can even be somewhat standardized across entirely different blockchains. Likewise, it allows deserializing transactions into semantic objects, rather than just raw bytes or plain data objects.

Amino

Amino is a legacy JSON sub-specification for blockchains that use the Cosmos SDK. It is superceded by ProtoBuf, but is still widely used, and the only format Ledger hardware wallets support in Cosmos. Amino is defined in the @apophis-sdk/cosmos package, but listed here for completeness.

ProtoBuf

ProtoBuf is a widely adopted data transmission format developed and primarily maintained by Google. Unlike Amino, it is a size-optimized binary format, and requires more complex de/serialization. Formerly, you had to provide the correct de/serialization for your protobuf types yourself. With v0.3.1, we are now migrating to hiproto. Following is an example of how I’ve implemented the Coin schema:
import hpb from '@kiruse/hiproto';

const pbCoin = hpb.message({
  denom: hpb.string(1),
  amount: hpb.string(2).transform<bigint>({
    encode: (value) => value.toString(),
    decode: (value) => BigInt(value),
    default: 0n,
  }),
});
You can then reference this schema in your own messages:
./protobuf/schema.ts
import { pbCoin } from '@apophis-sdk/cosmos/encoding/protobuf/core';

const pbMsgSend = hpb.message({
  fromAddress: hpb.string(1),
  toAddress: hpb.string(2),
  amount: hpb.repeated.array(3, pbCoin),
});
And de/serialize it as follows:
import { Cosmos } from '@apophis-sdk/cosmos';
import { pbMsgSend } from './protobuf/schema.js';

const encoded = pbMsgSend.encode({
  fromAddress: 'cosmos1...',
  toAddress: 'cosmos1...',
  amount: [Cosmos.coin(1_000_000n, 'untrn')],
}).toShrunk().toUint8Array();

const decoded = pbMsgSend.decode(encoded);
console.log(`${decoded.fromAddress} -> ${decoded.toAddress}: ${decoded.amount.length} coins`);
You can then register your message class with the registerDefaultProtobufSchema function:
./messages/bank.ts
import { registerDefaultAminos, registerDefaultProtobufSchema } from '@apophis-sdk/core/encoding/protobuf/any.js';
import hpb from '@kiruse/hiproto';
import { pbMsgSend } from './protobuf/schema.js';

export namespace Bank {
  export type SendData = hpb.infer<typeof pbMsgSend>;

  export class Send {
    static readonly aminoTypeUrl = 'cosmos-sdk/MsgSend';
    static readonly protobufTypeUrl = '/cosmos.bank.v1beta1.MsgSend';
    static readonly protobufSchema = pbMsgSend;
    constructor(public data: SendData) {}
  }

  registerDefaultAminos(Send);
  registerDefaultProtobufSchema(Send);
}
Note that this is currently in a transitional state. For now, Apophis’ Cosmos module still uses the cosmjs-types package for its standard messages, but these will be gradually migrated to the new hiproto-based approach. The existing methodology is now marked as deprecated and will be removed in v0.4.0.

Custom Amino Marshaller

You can further apply a custom marshaller for Amino types. This is useful where data is no longer reliably reconstructible from serialized form. For example the StoreCode message of the CosmWasm module:
import { fromBase64 } from '@apophis-sdk/core/utils.js';
import { registerDefaultAminos } from '@apophis-sdk/cosmos/encoding/amino.js';
import hpb from '@kiruse/hiproto';
import { extendDefaultMarshaller, RecaseMarshalUnit } from '@kiruse/marshal';

const marshaller = extendDefaultMarshaller([
  RecaseMarshalUnit(toSnakeCase, toCamelCase),
]);

const pbStoreCode = hpb.message({
  sender: hpb.string(1).required(),
  wasmByteCode: hpb.bytes(2).required(),
});

type StoreCodeData = hpb.infer<typeof pbStoreCode>;

class StoreCode {
  static readonly aminoTypeUrl = 'wasm/MsgStoreCode';
  static readonly aminoMarshaller = addMarshallerFinalizer<StoreCodeData>(marshaller, value => ({
    ...value,
    wasmByteCode: fromBase64(value.wasmByteCode),
  }));
  static readonly protobufTypeUrl = '/cosmwasm.wasm.v1.MsgStoreCode';
  static readonly protobufSchema = pbStoreCode;
  constructor(public data: hpb.infer<typeof pbStoreCode>) {}
}

registerDefaultAminos(StoreCode);
registerDefaultProtobufSchema(StoreCode);

function toSnakeCase(str: string): string { /* ... */ }
function toCamelCase(str: string): string { /* ... */ }

Required Fields

By default, Protobuf message fields are all optional. Even so, clients may require certain fields be present or otherwise transactions are rejected. Cross-compatibility with Amino also may require certain fields to be present, even if empty, such as funds on a Contracts.Execute message. Unfortunately, for legacy Amino support, you must pay attention to required and optional fields yourself. Otherwise, the full node may reconstruct the signature payload differently from the data you have provided to the signer, causing the transaction to be rejected immediately. The Contracts.Execute schema may look like this:
import { pbCoin } from '@apophis-sdk/cosmos/encoding/protobuf/core.js';
import hpb from '@kiruse/hiproto';

const pbExecute = hpb.message({
  sender: hpb.string(1).required(),
  contract: hpb.string(2).required(),
  msg: hpb.bytes(3).required(),
  funds: hpb.repeated.array(5, pbCoin).required(),
});
Given this schema, you shouldn’t need any further changes to the default pattern.

Transforms

There are a few general-purpose transforms for protobuf fields available in @apophis-sdk/cosmos/encoding/protobuf/core.js:
  • bigintTransform: Transforms a string to a bigint and vice versa.
  • aminoTransform: Applies Amino.normalize to an arbitrary value, including sorting the object’s keys. In general, whenever you use hpb.json, you should also apply this transform.

Other encodings

Other blockchains such as Solana and Ethereum use different encodings. When the time comes to develop integrations for these ecosystems, I will add corresponding encoding subsystems to this SDK. If you are looking to integrate your own encoding, please follow the above pattern, and develop a corresponding middleware. Your middleware may use the ["core", "init"] hook to register existing messages (if applicable, e.g. Bank.Send) for your new encoding so SDK consumers may seamlessly integrate your blockchain.