From 7ebccbacf712805dd119a3894581737c033eae6f Mon Sep 17 00:00:00 2001 From: Timothy Zakian Date: Wed, 20 Sep 2023 16:57:01 -0700 Subject: [PATCH] Add typescript sdk support --- sdk/typescript/src/bcs/index.ts | 6 +- sdk/typescript/src/builder/Inputs.ts | 17 ++ .../src/builder/TransactionBlock.ts | 22 +++ .../src/builder/__tests__/Transaction.test.ts | 37 +++++ sdk/typescript/src/types/transactions.ts | 7 + sdk/typescript/test/e2e/data/tto/Move.toml | 9 ++ .../test/e2e/data/tto/sources/tto1.move | 46 ++++++ .../test/e2e/receive-object.test.ts | 146 ++++++++++++++++++ 8 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 sdk/typescript/test/e2e/data/tto/Move.toml create mode 100644 sdk/typescript/test/e2e/data/tto/sources/tto1.move create mode 100644 sdk/typescript/test/e2e/receive-object.test.ts diff --git a/sdk/typescript/src/bcs/index.ts b/sdk/typescript/src/bcs/index.ts index 63362d364f952..9279f10998a02 100644 --- a/sdk/typescript/src/bcs/index.ts +++ b/sdk/typescript/src/bcs/index.ts @@ -25,7 +25,10 @@ export type SharedObjectRef = { /** * An object argument. */ -export type ObjectArg = { ImmOrOwned: SuiObjectRef } | { Shared: SharedObjectRef }; +export type ObjectArg = + | { ImmOrOwned: SuiObjectRef } + | { Shared: SharedObjectRef } + | { Receiving: SuiObjectRef }; /** * A pure argument. @@ -132,6 +135,7 @@ const BCS_SPEC: TypeSchema = { ObjectArg: { ImmOrOwned: 'SuiObjectRef', Shared: 'SharedObjectRef', + Receiving: 'SuiObjectRef', }, CallArg: { Pure: [VECTOR, BCS.U8], diff --git a/sdk/typescript/src/builder/Inputs.ts b/sdk/typescript/src/builder/Inputs.ts index 4e4e1c791a19d..017339ebd939f 100644 --- a/sdk/typescript/src/builder/Inputs.ts +++ b/sdk/typescript/src/builder/Inputs.ts @@ -18,6 +18,7 @@ const ObjectArg = union([ mutable: boolean(), }), }), + object({ Receiving: SuiObjectRef }), ]); export const PureCallArg = object({ Pure: array(integer()) }); @@ -61,6 +62,17 @@ export const Inputs = { }, }; }, + ReceivingRef({ objectId, digest, version }: SuiObjectRef): ObjectCallArg { + return { + Object: { + Receiving: { + digest, + version, + objectId: normalizeSuiAddress(objectId), + }, + }, + }; + }, }; export function getIdFromCallArg(arg: string | ObjectCallArg) { @@ -70,6 +82,11 @@ export function getIdFromCallArg(arg: string | ObjectCallArg) { if ('ImmOrOwned' in arg.Object) { return normalizeSuiAddress(arg.Object.ImmOrOwned.objectId); } + + if ('Receiving' in arg.Object) { + return normalizeSuiAddress(arg.Object.Receiving.objectId); + } + return normalizeSuiAddress(arg.Object.Shared.objectId); } diff --git a/sdk/typescript/src/builder/TransactionBlock.ts b/sdk/typescript/src/builder/TransactionBlock.ts index 33f755f6610aa..61e0d89104b48 100644 --- a/sdk/typescript/src/builder/TransactionBlock.ts +++ b/sdk/typescript/src/builder/TransactionBlock.ts @@ -87,6 +87,18 @@ function createTransactionResult(index: number): TransactionResult { }) as TransactionResult; } +function isReceivingType(normalizedType: SuiMoveNormalizedType): boolean { + const tag = extractStructTag(normalizedType); + if (tag) { + return ( + tag.Struct.address === '0x2' && + tag.Struct.module === 'transfer' && + tag.Struct.name === 'Receiving' + ); + } + return false; +} + function expectClient(options: BuildOptions): SuiClient { if (!options.client && !options.provider) { throw new Error( @@ -302,6 +314,14 @@ export class TransactionBlock { return this.object(Inputs.ObjectRef(...args)); } + /** + * Add a new receiving input to the transaction using the fully-resolved object reference. + * If you only have an object ID, use `builder.object(id)` instead. + */ + receivingRef(...args: Parameters<(typeof Inputs)['ReceivingRef']>) { + return this.object(Inputs.ReceivingRef(...args)); + } + /** * Add a new shared object input to the transaction using the fully-resolved shared object reference. * If you only have an object ID, use `builder.object(id)` instead. @@ -706,6 +726,8 @@ export class TransactionBlock { initialSharedVersion, mutable, }); + } else if (normalizedType && isReceivingType(normalizedType)) { + input.value = Inputs.ReceivingRef(getObjectReference(object)!); } else { input.value = Inputs.ObjectRef(getObjectReference(object as SuiObjectResponse)!); } diff --git a/sdk/typescript/src/builder/__tests__/Transaction.test.ts b/sdk/typescript/src/builder/__tests__/Transaction.test.ts index 12a4ab2773260..6dc6cab73702f 100644 --- a/sdk/typescript/src/builder/__tests__/Transaction.test.ts +++ b/sdk/typescript/src/builder/__tests__/Transaction.test.ts @@ -12,6 +12,23 @@ it('can construct and serialize an empty tranaction', () => { expect(() => tx.serialize()).not.toThrow(); }); +it('can construct a receiving transaction argument', () => { + const tx = new TransactionBlock(); + tx.object(Inputs.ReceivingRef(ref())); + expect(() => tx.serialize()).not.toThrow(); +}); + +it('receiving transaction argument different from object argument', () => { + const oref = ref(); + const rtx = new TransactionBlock(); + rtx.object(Inputs.ReceivingRef(oref)); + const otx = new TransactionBlock(); + otx.object(Inputs.ObjectRef(oref)); + expect(() => rtx.serialize()).not.toThrow(); + expect(() => otx.serialize()).not.toThrow(); + expect(otx.serialize()).not.toEqual(rtx.serialize()); +}); + it('can be serialized and deserialized to the same values', () => { const tx = new TransactionBlock(); tx.add(Transactions.SplitCoins(tx.gas, [tx.pure(100)])); @@ -109,6 +126,26 @@ describe('offline build', () => { await tx.build(); }); + it('uses a receiving argument', async () => { + const tx = setup(); + tx.object(Inputs.ObjectRef(ref())); + const coin = tx.add(Transactions.SplitCoins(tx.gas, [tx.pure(100)])); + tx.add(Transactions.MergeCoins(tx.gas, [coin, tx.object(Inputs.ObjectRef(ref()))])); + tx.add( + Transactions.MoveCall({ + target: '0x2::devnet_nft::mint', + typeArguments: [], + arguments: [tx.object(Inputs.ObjectRef(ref())), tx.object(Inputs.ReceivingRef(ref()))], + }), + ); + + const bytes = await tx.build(); + const tx2 = TransactionBlock.from(bytes); + const bytes2 = await tx2.build(); + + expect(bytes).toEqual(bytes2); + }); + it('builds a more complex interaction', async () => { const tx = setup(); const coin = tx.add(Transactions.SplitCoins(tx.gas, [tx.pure(100)])); diff --git a/sdk/typescript/src/types/transactions.ts b/sdk/typescript/src/types/transactions.ts index a01079721bd21..3823d12a9709a 100644 --- a/sdk/typescript/src/types/transactions.ts +++ b/sdk/typescript/src/types/transactions.ts @@ -104,6 +104,13 @@ export const SuiCallArg = union([ initialSharedVersion: string(), mutable: boolean(), }), + object({ + type: literal('object'), + objectType: literal('receiving'), + objectId: string(), + version: string(), + digest: string(), + }), ]); export type SuiCallArg = Infer; diff --git a/sdk/typescript/test/e2e/data/tto/Move.toml b/sdk/typescript/test/e2e/data/tto/Move.toml new file mode 100644 index 0000000000000..584e324d4ec51 --- /dev/null +++ b/sdk/typescript/test/e2e/data/tto/Move.toml @@ -0,0 +1,9 @@ +[package] +name = "tto" +version = "0.0.1" + +[dependencies] +Sui = { local = "../../../../../../crates/sui-framework/packages/sui-framework" } + +[addresses] +tto = "0x0" diff --git a/sdk/typescript/test/e2e/data/tto/sources/tto1.move b/sdk/typescript/test/e2e/data/tto/sources/tto1.move new file mode 100644 index 0000000000000..9d9d3d8cba6cf --- /dev/null +++ b/sdk/typescript/test/e2e/data/tto/sources/tto1.move @@ -0,0 +1,46 @@ +module tto::tto { + use sui::object::{Self, UID}; + use sui::tx_context::{Self, TxContext}; + use sui::transfer::{Self, Receiving}; + + struct A has key, store { + id: UID, + } + + struct B has key, store { + id: UID, + } + + public fun start(ctx: &mut TxContext) { + let a = A { id: object::new(ctx) }; + let a_address = object::id_address(&a); + let b = B { id: object::new(ctx) }; + let c = B { id: object::new(ctx) }; + transfer::share_object(c); + transfer::public_transfer(a, tx_context::sender(ctx)); + transfer::public_transfer(b, a_address); + } + + public entry fun receiver(parent: &mut A, x: Receiving) { + let b = transfer::receive(&mut parent.id, x); + transfer::public_transfer(b, @tto); + } + + public entry fun deleter(parent: &mut A, x: Receiving) { + let B { id } = transfer::receive(&mut parent.id, x); + object::delete(id); + } + + public fun return_(parent: &mut A, x: Receiving): B { + transfer::receive(&mut parent.id, x) + } + + public entry fun delete_(b: B) { + let B { id } = b; + object::delete(id); + } + + public fun invalid_call_immut_ref(_parent: &mut A, _x: &Receiving) { } + public fun invalid_call_mut_ref(_parent: &mut A, _x: &mut Receiving) { } + public fun dropper(_parent: &mut A, _x: Receiving) { } +} diff --git a/sdk/typescript/test/e2e/receive-object.test.ts b/sdk/typescript/test/e2e/receive-object.test.ts new file mode 100644 index 0000000000000..9ae7a57462ad9 --- /dev/null +++ b/sdk/typescript/test/e2e/receive-object.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; + +import { setup, TestToolbox, publishPackage } from './utils/setup'; +import { OwnedObjectRef, SuiClient } from '../../src/client'; +import { TransactionBlock } from '../../src/builder'; +import type { Keypair } from '../../src/cryptography'; + +function getOwnerAddress(o: OwnedObjectRef): string | undefined { + // const owner = getObjectOwner(o); + if (typeof o.owner == 'object' && 'AddressOwner' in o.owner) { + return o.owner.AddressOwner; + } else { + return undefined; + } +} + +describe('Transfer to Object', () => { + let toolbox: TestToolbox; + let packageId: string; + let parentObjectId: OwnedObjectRef; + let receiveObjectId: OwnedObjectRef; + let sharedObjectId: string; + + beforeAll(async () => { + const packagePath = __dirname + '/./data/tto'; + ({ packageId } = await publishPackage(packagePath)); + }); + + beforeEach(async () => { + toolbox = await setup(); + const tx = new TransactionBlock(); + tx.moveCall({ + target: `${packageId}::tto::start`, + typeArguments: [], + arguments: [], + }); + const x = await validateTransaction(toolbox.client, toolbox.keypair, tx); + const y = (x.effects?.created)!.map((o) => getOwnerAddress(o))!; + receiveObjectId = (x.effects?.created)!.filter( + (o) => !y.includes(o.reference.objectId) && getOwnerAddress(o) !== undefined, + )[0]; + parentObjectId = (x.effects?.created)!.filter( + (o) => y.includes(o.reference.objectId) && getOwnerAddress(o) !== undefined, + )[0]; + const sharedObject = (x.effects?.created)!.filter((o) => getOwnerAddress(o) === undefined)[0]; + sharedObjectId = sharedObject.reference.objectId; + }); + + it('Basic Receive: receive and then transfer', async () => { + const tx = new TransactionBlock(); + tx.moveCall({ + target: `${packageId}::tto::receiver`, + typeArguments: [], + arguments: [ + tx.object(parentObjectId.reference.objectId), + tx.object(receiveObjectId.reference.objectId), + ], + }); + await validateTransaction(toolbox.client, toolbox.keypair, tx); + }); + + it('Basic Receive: receive and then delete', async () => { + const tx = new TransactionBlock(); + tx.moveCall({ + target: `${packageId}::tto::deleter`, + typeArguments: [], + arguments: [ + tx.object(parentObjectId.reference.objectId), + tx.object(receiveObjectId.reference.objectId), + ], + }); + await validateTransaction(toolbox.client, toolbox.keypair, tx); + }); + + it('receive + return, then delete', async () => { + const tx = new TransactionBlock(); + const b = tx.moveCall({ + target: `${packageId}::tto::return_`, + typeArguments: [], + arguments: [ + tx.object(parentObjectId.reference.objectId), + tx.object(receiveObjectId.reference.objectId), + ], + }); + tx.moveCall({ + target: `${packageId}::tto::delete_`, + typeArguments: [], + arguments: [b], + }); + await validateTransaction(toolbox.client, toolbox.keypair, tx); + }); + + it('Basic Receive: &Receiving arg type', async () => { + const tx = new TransactionBlock(); + tx.moveCall({ + target: `${packageId}::tto::invalid_call_immut_ref`, + typeArguments: [], + arguments: [ + tx.object(parentObjectId.reference.objectId), + tx.object(receiveObjectId.reference.objectId), + ], + }); + await validateTransaction(toolbox.client, toolbox.keypair, tx); + }); + + it('Basic Receive: &mut Receiving arg type', async () => { + const tx = new TransactionBlock(); + tx.moveCall({ + target: `${packageId}::tto::invalid_call_mut_ref`, + typeArguments: [], + arguments: [ + tx.object(parentObjectId.reference.objectId), + tx.object(receiveObjectId.reference.objectId), + ], + }); + await validateTransaction(toolbox.client, toolbox.keypair, tx); + }); + + it.fails('Trying to pass shared object as receiving argument', async () => { + const tx = new TransactionBlock(); + tx.moveCall({ + target: `${packageId}::tto::receiver`, + typeArguments: [], + arguments: [tx.object(parentObjectId.reference.objectId), tx.object(sharedObjectId)], + }); + await validateTransaction(toolbox.client, toolbox.keypair, tx); + }); +}); + +async function validateTransaction(client: SuiClient, signer: Keypair, tx: TransactionBlock) { + tx.setSenderIfNotSet(signer.getPublicKey().toSuiAddress()); + const localDigest = await tx.getDigest({ client }); + const result = await client.signAndExecuteTransactionBlock({ + signer, + transactionBlock: tx, + options: { + showEffects: true, + }, + }); + expect(localDigest).toEqual(result.digest); + expect(result.effects?.status.status).toEqual('success'); + return result; +}