Skip to content

Instantly share code, notes, and snippets.

@jasonpaulos
Last active November 23, 2021 19:40
Show Gist options
  • Save jasonpaulos/a810abe7e86d43840d14445718565a9a to your computer and use it in GitHub Desktop.
Save jasonpaulos/a810abe7e86d43840d14445718565a9a to your computer and use it in GitHub Desktop.
Design doc for SDK support of the Algorand ABI

SDK ABI Interaction Design Doc

The Algorand ABI defines standards for smart contract interoperability. There are two main components to this:

  1. Off-chain programs invoking on-chain contracts
  2. On-chain contracts invoking other on-chain contract

The primary concern of our SDKs is 1, and this document aims to define how that happens.

Features

I've separated the above goal into the following two "features":

  1. Support for ABI JSON descriptions
  2. Construction of method invocation transactions

Support for ABI JSON descriptions

Our ABI defines 3 types of serializable objects that are used to describe ABI-compatible contracts. These objects are essential for off-chain programs to interact with on-chain contracts.

In general, smart contract authors will create or generate these description objects, and our SDKs must be able to parse and use them to construct transactions that invoke ABI methods.

From the spec:

A method description provides further information about a method beyond its signature. This description is encoded in JSON and consists of a method's name, description, arguments (their types, names, and descriptions), and return type (and possible description for the return type). From this structure, the method's signature and selector can be calculated. The Algorand SDKs will provide convenience functions to calculate signatures and selectors from such JSON files.

The JSON schema for a method description is,

interface Method {
  name: string;
  desc?: string;
  args: Array<{ type: string; name?: string; desc?: string }>;
  returns: { type: string; desc?: string };
}

Each SDK must provide:

  1. A way to parse this description from JSON into an in-memory object
  2. For easier ad-hoc usage, a way to create the same in-memory object from only a method signature string, e.g. "add(uint64,uint64)uint128"
  3. A way to examine every every field in the description from the in-memory object
  4. A way to calculate the signature and selector of a method from the in-memory object
  5. A way to calculate the number of transactions needed to invoke a method from the in-memory object

From the spec:

An interface is a logically grouped set of methods.

The JSON schema for an interface description is,

interface Interface {
  name: string;
  methods: Method[];
}

Where Method is the previously defined JSON method description.

Each SDK must provide:

  1. A way to parse this description from JSON into an in-memory object
  2. A way to examine the name of the interface from the in-memory object
  3. A way to obtain an in-memory method description object (defined in the prior section) for each method in the interface

From the spec:

An contract is the complete set of methods that an app implements. It is similar to an interface, but may include further details about the concrete implementation.

One piece of additional information a contract provides that an interface does not is the application ID.

The JSON schema for a contract description is,

interface Contract {
  name: string;
  appId: number;
  methods: Method[];
}

Where Method is the previously defined JSON method description.

Each SDK must provide:

  1. A way to parse this description from JSON into an in-memory object
  2. A way to examine the name and app ID of the contract from the in-memory object
  3. A way to obtain an in-memory method description object (defined in a previous section) for each method in the contract

Construction of method invocation transactions

The SDKs also need to provide a way to create ABI method calls, send them to the network, and decode the return value. In order to accomplish this, the new class AtomicTransactionComposer will be introduced. In addition to enabling ABI method calls, this class can also be used to simplify group transaction creation and execution independent of the ABI.

For simplicity, the following sections use a single language, TypeScript/JavaScript, to define new SDK types that should be added. However, the intent is for every SDK to have these new types, and while some modifications will need to be made, the functionality of the following types should not differ between each SDK.

Background types

These type definitions are needed first before we define AtomicTransactionComposer.

/**
 * A recursive type which represents JavaScript values that can be encoded as arguments in the ABI.
 *
 * Note: ABI type information is also needed in order to encode this value.
 */
type ABIValue = boolean | number | bigint | string | Uint8Array | ABIValue[];

/**
 * This type represents a function which can sign transactions from an atomic transaction group.
 * @param txnGroup - The atomic group containing transactions to be signed
 * @param indexesToSign - An array of indexes in the atomic transaction group that should be signed
 * @returns A promise which resolves an array of encoded signed transactions. The length of the
 *   array will be the same as the length of indexesToSign, and each index i in the array
 *   corresponds to the signed transaction from txnGroup[indexesToSign[i]]
 */
type TransactionSigner = (
  txnGroup: Transaction[],
  indexesToSign: number[]
) => Promise<Uint8Array[]>;

/**
 * Create a TransactionSigner that can sign transactions for the provided basic Account.
 */
function makeBasicAccountTransactionSigner(account: Account): TransactionSigner;

/**
 * Create a TransactionSigner that can sign transactions for the provided LogicSigAccount.
 */
function makeLogicSigAccountTransactionSigner(
  account: LogicSigAccount
): TransactionSigner;

/**
 * Create a TransactionSigner that can sign transactions for the provided Multisig account.
 * @param msig - The Multisig account metadata
 * @param sks - An array of private keys belonging to the msig which should sign the transactions.
 */
function makeMultiSigAccountTransactionSigner(
  msig: MultisigMetadata,
  sks: Uint8Array[]
): TransactionSigner;

/** Represents an unsigned transactions and a signer that can authorize that transaction. */
interface TransactionWithSigner {
  /** An unsigned transaction */
  txn: Transaction;
  /** A transaction signer that can authorize txn */
  signer: TransactionSigner;
}

/**
 * This type represents allowable arguments to ABI methods.
 *
 * In addition to the standard ABI values represented by ABIValue, transactions may also be provided
 * as "arguments" to a method. In this case, the transaction is placed in the atomic group immediately
 * before the app call which invokes the smart contract method, and nothing is added to the app call
 * arguments to specify the transaction being passed in -- it is up to the smart contract
 * implementation to know to look at the transaction(s) immediately before it for these arguments.
 * Any transaction arguments must have a zero group ID.
 */
type MethodArgument = ABIValue | TransactionWithSigner;

/** Represents the output from a successful ABI method call. */
interface ABIResult {
  /** The TxID of the transaction that invoked the ABI method call. */
  txID: string;
  /**
   * The raw bytes of the return value from the ABI method call. This will be empty if the method
   * does not return a value (return type "void").
   */
  rawReturnValue: Uint8Array;
  /**
   * The return value from the ABI method call. This will be undefined if the method does not return
   * a value (return type "void"), or if the SDK was unable to decode the returned value.
   */
  returnValue?: ABIValue;
  /** If the SDK was unable to decode a return value, the error will be here. */
  decodeError?: Error;
}

The AtomicTransactionComposer class

enum AtomicTransactionComposerStatus {
  /** The atomic group is still under construction. */
  BUILDING,

  /** The atomic group has been finalized, but not yet signed. */
  BUILT,

  /** The atomic group has been finalized and signed, but not yet submitted to the network. */
  SIGNED,

  /** The atomic group has been finalized, signed, and submitted to the network. */
  SUBMITTED,

  /** The atomic group has been finalized, signed, submitted, and successfully committed to a block. */
  COMMITTED,
}

/** A class used to construct and execute atomic transaction groups */
class AtomicTransactionComposer {
  /** The maximum size of an atomic transaction group. */
  static MAX_GROUP_SIZE: number = 16;

  /**
   * Get the status of this composer's transaction group.
   */
  getStatus(): AtomicTransactionComposerStatus;

  /**
   * Get the number of transactions currently in this atomic group.
   */
  count(): number;

  /**
   * Create a new composer with the same underlying transactions. The new composer's status will be
   * BUILDING, so additional transactions may be added to it.
   */
  clone(): AtomicTransactionComposer;

  /**
   * Add a transaction to this atomic group.
   *
   * An error will be thrown if the transaction has a nonzero group ID, the composer's status is
   * not BUILDING, or if adding this transaction causes the current group to exceed MAX_GROUP_SIZE.
   */
  addTransaction(txnAndSigner: TransactionWithSigner): void;

  /**
   * Add a smart contract method call to this atomic group.
   *
   * An error will be thrown if the composer's status is not BUILDING, if adding this transaction
   * causes the current group to exceed MAX_GROUP_SIZE, or if the provided arguments are invalid
   * for the given method.
   */
  addMethodCall(options: {
    /** The ID of the smart contract to call */
    appId: number;
    /** The method to call on the smart contract */
    method: Method;
    /** The arguments to include in the method call. If omitted, no arguments will be passed to the method. */
    methodArgs?: MethodArgument[];
    /** The address of the sender of this application call */
    sender: string;
    /** Transactions params to use for this application call */
    suggestedParams: SuggestedParams;
    /** The OnComplete action to take for this application call. If omitted, OnApplicationComplete.NoOpOC will be used. */
    onComplete?: OnApplicationComplete;
    /** The note value for this application call */
    note?: Uint8Array;
    /** The lease value for this application call */
    lease?: Uint8Array;
    /** If provided, the address that the sender will be rekeyed to at the conclusion of this application call */
    rekeyTo?: string;
    /** A transaction signer that can authorize this application call from sender */
    signer: TransactionSigner;
  }): void;

  /**
   * Finalize the transaction group and returned the finalized transactions.
   *
   * An error will be thrown if the group size is 0.
   *
   * The composer's status will be at least BUILT after successfully executing this method.
   */
  buildGroup(): TransactionWithSigner[];

  /**
   * Obtain signatures for each transaction in this group. If signatures have already been obtained,
   * this method will return cached versions of the signatures.
   *
   * The composer's status will be at least SIGNED after executing this method.
   *
   * An error will be thrown if signing any of the transactions fails.
   *
   * @returns A promise that resolves to an array of signed transactions.
   */
  gatherSignatures(): Promise<Uint8Array[]>;

  /**
   * Send the transaction group to the network, but don't wait for it to be committed to a block. An
   * error will be thrown if submission fails.
   *
   * The composer's status must be SUBMITTED or lower before calling this method. If submission is
   * successful, this composer's status will update to SUBMITTED.
   *
   * Note: a group can only be submitted again if it fails.
   *
   * @returns A promise that, upon success, resolves to a list of TxIDs of the submitted transactions.
   */
  submit(client: Algodv2): Promise<string[]>;

  /**
   * Send the transaction group to the network and wait until it's committed to a block. An error
   * will be thrown if submission or execution fails.
   *
   * The composer's status must be SUBMITTED or lower before calling this method, since execution is
   * only allowed once. If submission is successful, this composer's status will update to SUBMITTED.
   * If the execution is also successful, this composer's status will update to COMMITTED.
   *
   * Note: a group can only be submitted again if it fails.
   *
   * @param client - An Algodv2 client
   * @param waitRounds - The maximum number of rounds to wait for transaction confirmation
   *
   * @returns A promise that, upon success, resolves to an object containing the confirmed round for
   *   this transaction, the txIDs of the submitted transactions, and an array of results containing
   *   one element for each method call transaction in this group.
   */
  execute(
    client: Algodv2,
    waitRounds: number
  ): Promise<{
    confirmedRound: number;
    txIDs: string[];
    methodResults: ABIResult[];
  }>;
}

Required changes to ARC-4

This design requires a few changes to ARC-4, which is the specification for the Algorand ABI. These changes are listed below.

Change Method Description JSON

After iterating on this design, we made the following changes to the Method description JSON object:

interface Method {
  name: string,
  desc?: string,
-  args: Array<{ name: string, type: string, desc?: string }>,
+  args: Array<{ type: string, name?: string, desc?: string }>,
-  returns?: { type: string, desc?: string }
+  returns: { type: string, desc?: string }
}

Concretely, arguments no longer require names, and the JSON description of a method which returns void should have "returns": { "type": "void" } instead of omitting the returns field.

Specify return value location

The return value will be encoded to a byte string and logged by the application, with a special 4-byte prefix. The prefix is defined as the first 4 bytes of the of the SHA-512/256 hash of the string return, which is the hex string 151f7c75

This prefix serves to identify that the log item represents a return value. In the case where multiple logged items contain this prefix, the last item with this prefix is taken to contain the return value.

@algochoi
Copy link

I think introducing more params to include foreign objects and "compacting" are good ideas. For the SDKs, I think the passed in foreign objects (as params) should be packed first, then anything that is passed in as method args can be compacted in after.

Also the method should probably check the following rule somehow: The other three arrays are limited to 8 total values combined, and of those, the accounts array can have no more than four values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment