Skip to content

Commit

Permalink
Merge pull request #1045 from near/daniyar/abi-support
Browse files Browse the repository at this point in the history
feat: add support for ABI
  • Loading branch information
andy-haynes authored Jan 25, 2023
2 parents fe0f34d + cc5e8a2 commit 9189e11
Show file tree
Hide file tree
Showing 7 changed files with 1,225 additions and 853 deletions.
5 changes: 5 additions & 0 deletions .changeset/yellow-cars-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"near-api-js": minor
---

`Contract` can now optionally be instantiated with ABI
5 changes: 4 additions & 1 deletion packages/near-api-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"browser": "lib/browser-index.js",
"types": "lib/index.d.ts",
"dependencies": {
"ajv": "^8.11.2",
"ajv-formats": "^2.1.1",
"bn.js": "5.2.1",
"borsh": "^0.7.0",
"bs58": "^4.0.0",
Expand All @@ -19,6 +21,7 @@
"http-errors": "^1.7.2",
"js-sha256": "^0.9.0",
"mustache": "^4.0.0",
"near-abi": "0.1.1",
"node-fetch": "^2.6.1",
"text-encoding-utf-8": "^1.0.2",
"tweetnacl": "^1.0.1"
Expand Down Expand Up @@ -76,4 +79,4 @@
"browser-exports.js"
],
"author": "NEAR Inc"
}
}
106 changes: 92 additions & 14 deletions packages/near-api-js/src/contract.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import BN from 'bn.js';
import depd from 'depd';
import { AbiFunction, AbiFunctionKind, AbiRoot, AbiSerializationType } from 'near-abi';
import { Account } from './account';
import { getTransactionLastResult } from './providers';
import { PositionalArgsError, ArgumentTypeError } from './utils/errors';
import { PositionalArgsError, ArgumentTypeError, UnsupportedSerializationError, UnknownArgumentError, ArgumentSchemaError, ConflictingOptions } from './utils/errors';

// Makes `function.name` return given name
function nameFunction(name: string, body: (args?: any[]) => any) {
Expand All @@ -13,6 +16,52 @@ function nameFunction(name: string, body: (args?: any[]) => any) {
}[name];
}

function validateArguments(args: object, abiFunction: AbiFunction, ajv: Ajv, abiRoot: AbiRoot) {
if (!isObject(args)) return;

if (abiFunction.params && abiFunction.params.serialization_type !== AbiSerializationType.Json) {
throw new UnsupportedSerializationError(abiFunction.name, abiFunction.params.serialization_type);
}

if (abiFunction.result && abiFunction.result.serialization_type !== AbiSerializationType.Json) {
throw new UnsupportedSerializationError(abiFunction.name, abiFunction.result.serialization_type);
}

const params = abiFunction.params?.args || [];
for (const p of params) {
const arg = args[p.name];
const typeSchema = p.type_schema;
typeSchema.definitions = abiRoot.body.root_schema.definitions;
const validate = ajv.compile(typeSchema);
if (!validate(arg)) {
throw new ArgumentSchemaError(p.name, validate.errors);
}
}
// Check there are no extra unknown arguments passed
for (const argName of Object.keys(args)) {
const param = params.find((p) => p.name === argName);
if (!param) {
throw new UnknownArgumentError(argName, params.map((p) => p.name));
}
}
}

function createAjv() {
// Strict mode is disabled for now as it complains about unknown formats. We need to
// figure out if we want to support a fixed set of formats. `uint32` and `uint64`
// are added explicitly just to reduce the amount of warnings as these are very popular
// types.
const ajv = new Ajv({
strictSchema: false,
formats: {
uint32: true,
uint64: true
}
});
addFormats(ajv);
return ajv;
}

const isUint8Array = (x: any) =>
x && x.byteLength !== undefined && x.byteLength === x.length;

Expand Down Expand Up @@ -42,6 +91,11 @@ export interface ContractMethods {
* @see {@link account!Account#viewFunction}
*/
viewMethods: string[];

/**
* ABI defining this contract's interface.
*/
abi: AbiRoot;
}

/**
Expand Down Expand Up @@ -90,45 +144,69 @@ export class Contract {
constructor(account: Account, contractId: string, options: ContractMethods) {
this.account = account;
this.contractId = contractId;
const { viewMethods = [], changeMethods = [] } = options;
viewMethods.forEach((methodName) => {
Object.defineProperty(this, methodName, {
const { viewMethods = [], changeMethods = [], abi: abiRoot } = options;

let viewMethodsWithAbi = viewMethods.map((name) => ({ name, abi: null as AbiFunction }));
let changeMethodsWithAbi = changeMethods.map((name) => ({ name, abi: null as AbiFunction }));
if (abiRoot) {
if (viewMethodsWithAbi.length > 0 || changeMethodsWithAbi.length > 0) {
throw new ConflictingOptions();
}
viewMethodsWithAbi = abiRoot.body.functions
.filter((m) => m.kind === AbiFunctionKind.View)
.map((m) => ({ name: m.name, abi: m }));
changeMethodsWithAbi = abiRoot.body.functions
.filter((methodAbi) => methodAbi.kind === AbiFunctionKind.Call)
.map((methodAbi) => ({ name: methodAbi.name, abi: methodAbi }));
}

const ajv = createAjv();
viewMethodsWithAbi.forEach(({ name, abi }) => {
Object.defineProperty(this, name, {
writable: false,
enumerable: true,
value: nameFunction(methodName, async (args: object = {}, options = {}, ...ignored) => {
value: nameFunction(name, async (args: object = {}, options = {}, ...ignored) => {
if (ignored.length || !(isObject(args) || isUint8Array(args)) || !isObject(options)) {
throw new PositionalArgsError();
}

if (abi) {
validateArguments(args, abi, ajv, abiRoot);
}

return this.account.viewFunction({
contractId: this.contractId,
methodName,
methodName: name,
args,
...options,
});
})
});
});
changeMethods.forEach((methodName) => {
Object.defineProperty(this, methodName, {
changeMethodsWithAbi.forEach(({ name, abi }) => {
Object.defineProperty(this, name, {
writable: false,
enumerable: true,
value: nameFunction(methodName, async (...args: any[]) => {
value: nameFunction(name, async (...args: any[]) => {
if (args.length && (args.length > 3 || !(isObject(args[0]) || isUint8Array(args[0])))) {
throw new PositionalArgsError();
}

if(args.length > 1 || !(args[0] && args[0].args)) {
if (args.length > 1 || !(args[0] && args[0].args)) {
const deprecate = depd('contract.methodName(args, gas, amount)');
deprecate('use `contract.methodName({ args, gas?, amount?, callbackUrl?, meta? })` instead');
return this._changeMethod({
methodName,
args[0] = {
args: args[0],
gas: args[1],
amount: args[2]
});
};
}

if (abi) {
validateArguments(args[0].args, abi, ajv, abiRoot);
}

return this._changeMethod({ methodName, ...args[0] });
return this._changeMethod({ methodName: name, ...args[0] });
})
});
});
Expand Down
30 changes: 28 additions & 2 deletions packages/near-api-js/src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ErrorObject } from 'ajv';

export class PositionalArgsError extends Error {
constructor() {
super('Contract method calls expect named arguments wrapped in object, e.g. { argName1: argValue1, argName2: argValue2 }');
Expand Down Expand Up @@ -28,7 +30,31 @@ export class ErrorContext {
}

export function logWarning(...args: any[]): void {
if (!process.env['NEAR_NO_LOGS']){
if (!process.env['NEAR_NO_LOGS']) {
console.warn(...args);
}
}
}

export class UnsupportedSerializationError extends Error {
constructor(methodName: string, serializationType: string) {
super(`Contract method '${methodName}' is using an unsupported serialization type ${serializationType}`);
}
}

export class UnknownArgumentError extends Error {
constructor(actualArgName: string, expectedArgNames: string[]) {
super(`Unrecognized argument '${actualArgName}', expected '${JSON.stringify(expectedArgNames)}'`);
}
}

export class ArgumentSchemaError extends Error {
constructor(argName: string, errors: ErrorObject[]) {
super(`Argument '${argName}' does not conform to the specified ABI schema: '${JSON.stringify(errors)}'`);
}
}

export class ConflictingOptions extends Error {
constructor() {
super('Conflicting contract method options have been passed. You can either specify ABI or a list of view/call methods.');
}
}
Loading

0 comments on commit 9189e11

Please sign in to comment.