Skip to content

Commit

Permalink
Merge pull request #59 from Concordium/invoke-contract
Browse files Browse the repository at this point in the history
add Invoke contract
  • Loading branch information
shjortConcordium authored Jun 14, 2022
2 parents 6357188 + e1b8418 commit 586f385
Show file tree
Hide file tree
Showing 8 changed files with 500 additions and 14 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 1.1.0 2022-06-11

### Added

- Support for the Invoke contract node entrypoint.

### Fixed

- Lossy parsing of uint64's from the node, if their value was above MAX_SAFE_INTEGER.

## 1.0.0 2022-05-11

Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,46 @@ const name = instanceInfo.name;
Note that only version 0 contracts returns the model. (use `isInstanceInfoV0`/`isInstanceInfoV1` to check the version)
## InvokeContract
Used to simulate a contract update, and to trigger view functions.
```js
const blockHash = "7f7409679e53875567e2ae812c9fcefe90ced8961d08554756f42bf268a42749";
const contractAddress = { index: 1n, subindex: 0n };
const invoker = new AccountAddress('3tXiu8d4CWeuC12irAB7YVb1hzp3YxsmmmNzzkdujCPqQ9EjDm');
const result = await client.invokeContract(
blockHash,
{
invoker: invoker,
contract: contractAddress,
method: 'PiggyBank.smash',
amount: undefined,
parameter: undefined,
energy: 30000n,
}
);

if (!result) {
// The node could not attempt the invocation, most likely the contract doesn't exist.
}

if (result.tag === 'failure') {
// Invoke was unsuccesful
const rejectReason = result.reason; // Describes why the update failed;
...
} else {
const events = result.events; // a list of events that would be generated by the update
const returnValue = result.returnValue; // If the invoked method has return value
...
}
```
Note that some of the parts of the context are optional:
- amount: defaults to 0
- energy: defaults to 10 million
- parameter: defaults to no parameters
- invoker: uses the zero account address, which can be used instead of finding a random address.
## Deserialize contract state
The following example demonstrates how to deserialize a contract's state:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@concordium/node-sdk",
"version": "1.0.0",
"version": "1.1.0",
"description": "Helpers for interacting with the Concordium node",
"repository": {
"type": "git",
Expand Down
93 changes: 93 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PeersRequest,
SendTransactionRequest,
TransactionHash,
InvokeContractRequest,
} from '../grpc/concordium_p2p_rpc_pb';
import {
serializeAccountTransactionForSubmission,
Expand Down Expand Up @@ -62,6 +63,8 @@ import {
ReduceStakePendingChangeV0,
PassiveDelegationStatus,
PassiveDelegationStatusDetails,
ContractContext,
InvokeContractResult,
} from './types';
import {
buildJsonResponseReviver,
Expand All @@ -70,6 +73,7 @@ import {
isValidHash,
unwrapBoolResponse,
unwrapJsonResponse,
stringToInt,
} from './util';
import { GtuAmount } from './types/gtuAmount';
import { ModuleReference } from './types/moduleReference';
Expand Down Expand Up @@ -801,6 +805,95 @@ export default class ConcordiumNodeClient {
return response;
}

/**
* Invokes a smart contract.
* @param blockHash the block hash at which the contract should be invoked at. The contract is invoked in the state at the end of this block.
* @param context the collection of details used to invoke the contract. Must include the address of the contract and the method invoked.
* @returns If the node was able to invoke, then a object describing the outcome is returned.
* The outcome is determined by the `tag` field, which is either `success` or `failure`.
* The `usedEnergy` field will always be present, and is the amount of NRG was used during the execution.
* If the tag is `success`, then an `events` field is present, and it contains the events that would have been generated.
* If invoking a V1 contract and it produces a return value, it will be present in the `returnValue` field.
* If the tag is `failure`, then a `reason` field is present, and it contains the reason the update would have been rejected.
* If either the block does not exist, or then node fails to parse of any of the inputs, then undefined is returned.
*/
async invokeContract(
blockHash: string,
contractContext: ContractContext
): Promise<InvokeContractResult | undefined> {
if (!isValidHash(blockHash)) {
throw new Error('The input was not a valid hash: ' + blockHash);
}

let invoker:
| {
type: 'AddressContract';
address: {
index: string;
subindex: string;
};
}
| {
type: 'AddressAccount';
address: string;
}
| null;

if (!contractContext.invoker) {
invoker = null;
} else if ((contractContext.invoker as Address).address) {
invoker = {
type: 'AddressAccount',
address: (contractContext.invoker as Address).address,
};
} else {
const invokerContract = contractContext.invoker as ContractAddress;
invoker = {
type: 'AddressContract',
address: {
subindex: invokerContract.subindex.toString(),
index: invokerContract.index.toString(),
},
};
}

const requestObject = new InvokeContractRequest();
requestObject.setBlockHash(blockHash);
requestObject.setContext(
stringToInt(
JSON.stringify({
invoker,
contract: {
subindex: contractContext.contract.subindex.toString(),
index: contractContext.contract.index.toString(),
},
amount:
contractContext.amount &&
contractContext.amount.microGtuAmount.toString(),
method: contractContext.method,
parameter:
contractContext.parameter &&
contractContext.parameter.toString('hex'),
energy:
contractContext.energy &&
Number(contractContext.energy.toString()),
}),
['index', 'subindex']
)
);

const response = await this.sendRequest(
this.client.invokeContract,
requestObject
);
const bigIntPropertyKeys = ['usedEnergy', 'index', 'subindex'];
return unwrapJsonResponse<InvokeContractResult>(
response,
buildJsonResponseReviver([], bigIntPropertyKeys),
intToStringTransformer(bigIntPropertyKeys)
);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
sendRequest<T>(command: any, input: T): Promise<Uint8Array> {
const deadline = new Date(Date.now() + this.timeout);
Expand Down
113 changes: 109 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@ export interface TransactionEvent {
| 'TransferredWithSchedule'
| 'CredentialsUpdated'
| 'DataRegistered'
| 'Interrupted'
| 'Resumed'
| 'BakerSetOpenStatus'
| 'BakerSetMetadataURL'
| 'BakerSetTransactionFeeCommission'
Expand All @@ -90,6 +88,18 @@ export interface ContractAddress {
subindex: bigint;
}

export interface InterruptedEvent {
tag: 'Interrupted';
address: ContractAddress;
events: string[];
}

export interface ResumedEvent {
tag: 'Resumed';
address: ContractAddress;
success: boolean;
}

export interface UpdatedEvent {
tag: 'Updated';
address: ContractAddress;
Expand Down Expand Up @@ -184,12 +194,79 @@ export enum RejectReasonTag {
PoolClosed = 'PoolClosed',
}

export interface RejectReason {
tag: RejectReasonTag;
export interface RejectedReceive {
tag: RejectReasonTag.RejectedReceive;
contractAddress: ContractAddress;
receiveName: string;
rejectReason: number;
parameter: string;
}

export interface RejectedInit {
tag: RejectReasonTag.RejectedInit;
rejectReason: number;
}

export type SimpleRejectReasonTag =
| RejectReasonTag.ModuleNotWF
| RejectReasonTag.RuntimeFailure
| RejectReasonTag.SerializationFailure
| RejectReasonTag.OutOfEnergy
| RejectReasonTag.InvalidProof
| RejectReasonTag.InsufficientBalanceForBakerStake
| RejectReasonTag.StakeUnderMinimumThresholdForBaking
| RejectReasonTag.BakerInCooldown
| RejectReasonTag.NonExistentCredentialID
| RejectReasonTag.KeyIndexAlreadyInUse
| RejectReasonTag.InvalidAccountThreshold
| RejectReasonTag.InvalidCredentialKeySignThreshold
| RejectReasonTag.InvalidEncryptedAmountTransferProof
| RejectReasonTag.InvalidTransferToPublicProof
| RejectReasonTag.InvalidIndexOnEncryptedTransfer
| RejectReasonTag.ZeroScheduledAmount
| RejectReasonTag.NonIncreasingSchedule
| RejectReasonTag.FirstScheduledReleaseExpired
| RejectReasonTag.InvalidCredentials
| RejectReasonTag.RemoveFirstCredential
| RejectReasonTag.CredentialHolderDidNotSign
| RejectReasonTag.NotAllowedMultipleCredentials
| RejectReasonTag.NotAllowedToReceiveEncrypted
| RejectReasonTag.NotAllowedToHandleEncrypted
| RejectReasonTag.MissingBakerAddParameters
| RejectReasonTag.FinalizationRewardCommissionNotInRange
| RejectReasonTag.BakingRewardCommissionNotInRange
| RejectReasonTag.TransactionFeeCommissionNotInRange
| RejectReasonTag.AlreadyADelegator
| RejectReasonTag.InsufficientBalanceForDelegationStake
| RejectReasonTag.MissingDelegationAddParameters
| RejectReasonTag.InsufficientDelegationStake
| RejectReasonTag.DelegatorInCooldown
| RejectReasonTag.StakeOverMaximumThresholdForPool
| RejectReasonTag.PoolWouldBecomeOverDelegated
| RejectReasonTag.PoolClosed;

export interface SimpleRejectReason {
tag: SimpleRejectReasonTag;
}

// TODO split this into types with contents properly typed/parsed;
export interface RejectReasonWithContents {
tag: Exclude<
RejectReasonTag,
| RejectReasonTag.RejectedReceive
| RejectReasonTag.RejectedInit
| SimpleRejectReasonTag
>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
contents: any;
}

export type RejectReason =
| RejectReasonWithContents
| SimpleRejectReason
| RejectedReceive
| RejectedInit;

interface RejectedEventResult {
outcome: 'reject';
rejectReason: RejectReason;
Expand All @@ -201,6 +278,8 @@ interface SuccessfulEventResult {
| TransactionEvent
| TransferredEvent
| UpdatedEvent
| ResumedEvent
| InterruptedEvent
| MemoEvent
| TransferredWithScheduleEvent
)[];
Expand Down Expand Up @@ -1312,6 +1391,32 @@ export type InstanceInfoSerialized =
| InstanceInfoSerializedV0
| InstanceInfoSerializedV1;

export interface ContractContext {
invoker?: ContractAddress | AccountAddress;
contract: ContractAddress;
amount?: GtuAmount;
method: string;
parameter?: Buffer;
energy?: bigint;
}

export interface InvokeContractSuccessResult
extends Pick<SuccessfulEventResult, 'events'> {
tag: 'success';
usedEnergy: bigint;
returnValue?: string;
}

export interface InvokeContractFailedResult {
tag: 'failure';
usedEnergy: bigint;
reason: RejectReason;
}

export type InvokeContractResult =
| InvokeContractSuccessResult
| InvokeContractFailedResult;

export interface CredentialDeploymentTransaction {
expiry: TransactionExpiry;
unsignedCdi: UnsignedCredentialDeploymentInformation;
Expand Down
21 changes: 21 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ export function intListToStringList(jsonStruct: string): string {
return jsonStruct.replace(/(\-?[0-9]+)/g, '"$1"');
}

/**
* Replaces a string in a JSON string with the same string as a
* number, i.e. removing quotes (") prior to and after the string. This
* is needed as the default JSON stringify cannot serialize BigInts as numbers.
* So one can turn them into strings, stringify the structure, and then use this function
* to make those fields into JSON numbers.
* @param jsonStruct the JSON structure as a string
* @param keys the keys where the strings has to be unquoted
* @returns the same JSON string where the strings at the supplied keys are unquoted
*/
export function stringToInt(jsonStruct: string, keys: string[]): string {
let result = jsonStruct;
for (const key of keys) {
result = result.replace(
new RegExp(`"${key}":\\s*"([0-9]+)"`, 'g'),
`"${key}":$1`
);
}
return result;
}

/**
* A transformer that converts all the values provided as keys to
* string values.
Expand Down
Loading

0 comments on commit 586f385

Please sign in to comment.