Skip to content

Commit 2266d0c

Browse files
feat: implement NFT transfer tool with tests (#423)
Signed-off-by: skurzyp-blockydevs <[email protected]>
1 parent b73a151 commit 2266d0c

File tree

12 files changed

+832
-14
lines changed

12 files changed

+832
-14
lines changed

docs/HEDERAPLUGINS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ A plugin for the Hedera **Token Service (HTS)**, enabling creation and managemen
105105
| [`TRANSFER_FUNGIBLE_TOKEN_WITH_ALLOWANCE_TOOL`](./HEDERATOOLS.md#transfer_fungible_token_with_allowance_tool) | Transfers fungible token using an allowance | [View Parameters & Examples](./HEDERATOOLS.md#transfer_fungible_token_with_allowance_tool) |
106106
| [`APPROVE_NFT_ALLOWANCE_TOOL`](./HEDERATOOLS.md#approve_nft_allowance_tool) | Approve NFT allowances | [View Parameters & Examples](./HEDERATOOLS.md#approve_nft_allowance_tool) |
107107
| [`TRANSFER_NFT_WITH_ALLOWANCE_TOOL`](./HEDERATOOLS.md#transfer_nft_with_allowance_tool) | Transfers NFTs using an allowance | [View Parameters & Examples](./HEDERATOOLS.md#transfer_nft_with_allowance_tool) |
108+
| [`TRANSFER_NON_FUNGIBLE_TOKEN_TOOL`](./HEDERATOOLS.md#transfer_non_fungible_token_tool) | Transfers NFTs from operator's account | [View Parameters & Examples](./HEDERATOOLS.md#transfer_non_fungible_token_tool) |
108109

109110
---
110111

docs/HEDERATOOLS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ For a high-level overview of available plugins, see [HEDERAPLUGINS.md](./HEDERAP
4343
- [DELETE_TOKEN_ALLOWANCE_TOOL](#delete_token_allowance_tool)
4444
- [TRANSFER_FUNGIBLE_TOKEN_WITH_ALLOWANCE_TOOL](#transfer_fungible_token_with_allowance_tool)
4545
- [APPROVE_NFT_ALLOWANCE_TOOL](#approve_nft_allowance_tool)
46+
- [TRANSFER_NON_FUNGIBLE_TOKEN_TOOL](#transfer_non_fungible_token_tool)
4647
- [TRANSFER_NFT_WITH_ALLOWANCE_TOOL](#transfer_nft_with_allowance_tool)
4748
- [Token Query Tools](#token-query-tools)
4849
- [GET_TOKEN_INFO_QUERY_TOOL](#get_token_info_query_tool)
@@ -750,6 +751,28 @@ Grant approval for the entire collection token 0.0.1010 to account 0.0.2020
750751

751752
---
752753

754+
### TRANSFER_NON_FUNGIBLE_TOKEN_TOOL
755+
756+
Transfers NFTs from the operator's account to specified recipients.
757+
758+
#### Parameters
759+
760+
| Parameter | Type | Required | Description |
761+
|-------------------|------------------------------------------------------|----------|---------------------------------------------------------------------|
762+
| `tokenId` | `string` || The NFT token ID to transfer. |
763+
| `recipients` | `Array<{recipientId: string, serialNumber: number}>` || List of recipients with recipient account ID and NFT serial number. |
764+
| `transactionMemo` | `string` || Memo for the transaction. |
765+
766+
#### Example Prompts
767+
768+
```
769+
Transfer NFT 0.0.12345 serial 1 to 0.0.222
770+
Send my NFT 0.0.12345 serials 1 and 2 to 0.0.333
771+
Transfer NFT token 0.0.5555 serial 3 to account 0.0.6666 with memo "gift"
772+
```
773+
774+
---
775+
753776
### TRANSFER_NFT_WITH_ALLOWANCE_TOOL
754777

755778
Transfers NFTs using an existing token allowance.

typescript/src/plugins/core-token-plugin/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import associateTokenTool, {
3131
import transferNonFungibleTokenWithAllowanceTool, {
3232
TRANSFER_NON_FUNGIBLE_TOKEN_WITH_ALLOWANCE_TOOL,
3333
} from '@/plugins/core-token-plugin/tools/non-fungible-token/transfer-non-fungible-token-with-allowance';
34+
import transferNonFungibleTokenTool, {
35+
TRANSFER_NON_FUNGIBLE_TOKEN_TOOL,
36+
} from '@/plugins/core-token-plugin/tools/non-fungible-token/transfer-non-fungible-token';
3437

3538
export const coreTokenPlugin: Plugin = {
3639
name: 'core-token-plugin',
@@ -48,6 +51,7 @@ export const coreTokenPlugin: Plugin = {
4851
dissociateTokenTool(context),
4952
associateTokenTool(context),
5053
transferNonFungibleTokenWithAllowanceTool(context),
54+
transferNonFungibleTokenTool(context),
5155
transferFungibleTokenWithAllowanceTool(context),
5256
];
5357
},
@@ -65,6 +69,7 @@ export const coreTokenPluginToolNames = {
6569
ASSOCIATE_TOKEN_TOOL,
6670
UPDATE_TOKEN_TOOL,
6771
TRANSFER_NON_FUNGIBLE_TOKEN_WITH_ALLOWANCE_TOOL,
72+
TRANSFER_NON_FUNGIBLE_TOKEN_TOOL,
6873
TRANSFER_FUNGIBLE_TOKEN_WITH_ALLOWANCE_TOOL,
6974
} as const;
7075

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { z } from 'zod';
2+
import type { Context } from '@/shared/configuration';
3+
import type { Tool } from '@/shared/tools';
4+
import { Client, Status } from '@hashgraph/sdk';
5+
import { handleTransaction, RawTransactionResponse } from '@/shared/strategies/tx-mode-strategy';
6+
import HederaBuilder from '@/shared/hedera-utils/hedera-builder';
7+
import { transferNonFungibleTokenParameters } from '@/shared/parameter-schemas/token.zod';
8+
import HederaParameterNormaliser from '@/shared/hedera-utils/hedera-parameter-normaliser';
9+
import { PromptGenerator } from '@/shared/utils/prompt-generator';
10+
import { transactionToolOutputParser } from '@/shared/utils/default-tool-output-parsing';
11+
12+
const transferNonFungibleTokenPrompt = (context: Context = {}) => {
13+
const contextSnippet = PromptGenerator.getContextSnippet(context);
14+
const usageInstructions = PromptGenerator.getParameterUsageInstructions();
15+
16+
return `
17+
${contextSnippet}
18+
This tool will transfer non-fungible tokens (NFTs) from the operator's account to specified recipients.
19+
20+
Parameters:
21+
- tokenId (string, required): The NFT token ID to transfer (e.g. "0.0.12345")
22+
- recipients (array, required): List of objects specifying recipients and serial numbers
23+
- recipientId (string): Account to transfer to
24+
- serialNumber (number): NFT serial number to transfer
25+
- transactionMemo (string, optional): Optional memo for the transaction
26+
${PromptGenerator.getScheduledTransactionParamsDescription(context)}
27+
28+
${usageInstructions}
29+
`;
30+
};
31+
32+
const postProcess = (response: RawTransactionResponse) => {
33+
if (response.scheduleId) {
34+
return `Scheduled non-fungible token transfer created successfully.
35+
Transaction ID: ${response.transactionId}
36+
Schedule ID: ${response.scheduleId.toString()}`;
37+
}
38+
return `Non-fungible tokens successfully transferred. Transaction ID: ${response.transactionId}`;
39+
};
40+
41+
const transferNonFungibleToken = async (
42+
client: Client,
43+
context: Context,
44+
params: z.infer<ReturnType<typeof transferNonFungibleTokenParameters>>,
45+
) => {
46+
try {
47+
const normalisedParams = await HederaParameterNormaliser.normaliseTransferNonFungibleToken(
48+
params,
49+
context,
50+
client,
51+
);
52+
53+
const tx = HederaBuilder.transferNonFungibleToken(normalisedParams);
54+
return await handleTransaction(tx, client, context, postProcess);
55+
} catch (error) {
56+
const desc = 'Failed to transfer non-fungible token';
57+
const message = desc + (error instanceof Error ? `: ${error.message}` : '');
58+
console.error('[transfer_non_fungible_token_tool]', message);
59+
return { raw: { status: Status.InvalidTransaction, error: message }, humanMessage: message };
60+
}
61+
};
62+
63+
export const TRANSFER_NON_FUNGIBLE_TOKEN_TOOL = 'transfer_non_fungible_token_tool';
64+
65+
const tool = (context: Context): Tool => ({
66+
method: TRANSFER_NON_FUNGIBLE_TOKEN_TOOL,
67+
name: 'Transfer Non Fungible Token',
68+
description: transferNonFungibleTokenPrompt(context),
69+
parameters: transferNonFungibleTokenParameters(context).innerType(),
70+
execute: transferNonFungibleToken,
71+
outputParser: transactionToolOutputParser,
72+
});
73+
74+
export default tool;

typescript/src/shared/hedera-utils/hedera-builder.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
updateTokenParametersNormalised,
3939
transferFungibleTokenWithAllowanceParametersNormalised,
4040
transferNonFungibleTokenWithAllowanceParametersNormalised,
41+
transferNonFungibleTokenParametersNormalised,
4142
} from '@/shared/parameter-schemas/token.zod';
4243
import z from 'zod';
4344
import {
@@ -96,6 +97,22 @@ export default class HederaBuilder {
9697
return tx;
9798
}
9899

100+
static transferNonFungibleToken(
101+
params: z.infer<ReturnType<typeof transferNonFungibleTokenParametersNormalised>>,
102+
) {
103+
const tx = new TransferTransaction();
104+
105+
for (const transfer of params.transfers) {
106+
tx.addNftTransfer(transfer.nftId, params.senderAccountId, transfer.receiver);
107+
}
108+
109+
if (params.transactionMemo) {
110+
tx.setTransactionMemo(params.transactionMemo);
111+
}
112+
113+
return HederaBuilder.maybeWrapInSchedule(tx, params.schedulingParams);
114+
}
115+
99116
static transferHbarWithAllowance(
100117
params: z.infer<ReturnType<typeof transferHbarWithAllowanceParametersNormalised>>,
101118
) {

typescript/src/shared/hedera-utils/hedera-parameter-normaliser.ts

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
mintNonFungibleTokenParametersNormalised,
2020
transferNonFungibleTokenWithAllowanceParameters,
2121
transferNonFungibleTokenWithAllowanceParametersNormalised,
22+
transferNonFungibleTokenParameters,
23+
transferNonFungibleTokenParametersNormalised,
2224
transferFungibleTokenWithAllowanceParameters,
2325
transferFungibleTokenWithAllowanceParametersNormalised,
2426
updateTokenParameters,
@@ -156,7 +158,7 @@ export default class HederaParameterNormaliser {
156158
// Normalize scheduling parameters (if present and isScheduled = true)
157159
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
158160
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
159-
.schedulingParams
161+
.schedulingParams
160162
: { isScheduled: false };
161163

162164
return {
@@ -206,7 +208,7 @@ export default class HederaParameterNormaliser {
206208
// Normalize scheduling parameters (if present and isScheduled = true)
207209
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
208210
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
209-
.schedulingParams
211+
.schedulingParams
210212
: { isScheduled: false };
211213

212214
return {
@@ -261,7 +263,7 @@ export default class HederaParameterNormaliser {
261263
// Normalize scheduling parameters (if present and isScheduled = true)
262264
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
263265
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
264-
.schedulingParams
266+
.schedulingParams
265267
: { isScheduled: false };
266268

267269
return {
@@ -448,6 +450,44 @@ export default class HederaParameterNormaliser {
448450
};
449451
}
450452

453+
static async normaliseTransferNonFungibleToken(
454+
params: z.infer<ReturnType<typeof transferNonFungibleTokenParameters>>,
455+
context: Context,
456+
client: Client,
457+
): Promise<z.infer<ReturnType<typeof transferNonFungibleTokenParametersNormalised>>> {
458+
// Validate input using schema
459+
const parsedParams: z.infer<ReturnType<typeof transferNonFungibleTokenParameters>> =
460+
this.parseParamsWithSchema(params, transferNonFungibleTokenParameters, context);
461+
462+
// Resolve sender account (defaults to operator)
463+
const senderAccountId = AccountResolver.getDefaultAccount(context, client);
464+
if (!senderAccountId) {
465+
throw new Error('Could not determine sender account ID');
466+
}
467+
468+
// Convert tokenId to SDK TokenId
469+
const tokenId = TokenId.fromString(parsedParams.tokenId);
470+
471+
// Map recipients to normalized NFT transfers
472+
const transfers = parsedParams.recipients.map(recipient => ({
473+
nftId: new NftId(tokenId, Number(recipient.serialNumber)),
474+
receiver: AccountId.fromString(recipient.recipientId),
475+
}));
476+
477+
// Normalize scheduling parameters (if present and isScheduled = true)
478+
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
479+
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
480+
.schedulingParams
481+
: { isScheduled: false };
482+
483+
return {
484+
senderAccountId: AccountId.fromString(senderAccountId),
485+
transactionMemo: parsedParams.transactionMemo,
486+
transfers,
487+
schedulingParams,
488+
};
489+
}
490+
451491
static async normaliseApproveTokenAllowance(
452492
params: z.infer<ReturnType<typeof approveTokenAllowanceParameters>>,
453493
context: Context,
@@ -549,7 +589,7 @@ export default class HederaParameterNormaliser {
549589
// Normalize scheduling parameters (if present and isScheduled = true)
550590
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
551591
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
552-
.schedulingParams
592+
.schedulingParams
553593
: { isScheduled: false };
554594

555595
return {
@@ -755,7 +795,7 @@ export default class HederaParameterNormaliser {
755795
// Normalize scheduling parameters (if present and isScheduled = true)
756796
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
757797
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
758-
.schedulingParams
798+
.schedulingParams
759799
: { isScheduled: false };
760800

761801
return {
@@ -793,7 +833,7 @@ export default class HederaParameterNormaliser {
793833
// Normalize scheduling parameters (if present and isScheduled = true)
794834
const schedulingParams = parsedParams.schedulingParams?.isScheduled
795835
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
796-
.schedulingParams
836+
.schedulingParams
797837
: { isScheduled: false };
798838

799839
return {
@@ -859,7 +899,7 @@ export default class HederaParameterNormaliser {
859899
// Normalize scheduling parameters (if present and isScheduled = true)
860900
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
861901
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
862-
.schedulingParams
902+
.schedulingParams
863903
: { isScheduled: false };
864904

865905
return {
@@ -897,7 +937,7 @@ export default class HederaParameterNormaliser {
897937
// Normalize scheduling parameters (if present and isScheduled = true)
898938
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
899939
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
900-
.schedulingParams
940+
.schedulingParams
901941
: { isScheduled: false };
902942

903943
return {
@@ -929,7 +969,7 @@ export default class HederaParameterNormaliser {
929969
// Normalize scheduling parameters (if present and isScheduled = true)
930970
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
931971
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
932-
.schedulingParams
972+
.schedulingParams
933973
: { isScheduled: false };
934974

935975
return {
@@ -953,7 +993,7 @@ export default class HederaParameterNormaliser {
953993
// Normalize scheduling parameters (if present and isScheduled = true)
954994
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
955995
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
956-
.schedulingParams
996+
.schedulingParams
957997
: { isScheduled: false };
958998

959999
return {
@@ -993,7 +1033,7 @@ export default class HederaParameterNormaliser {
9931033
// Normalize scheduling parameters (if present and isScheduled = true)
9941034
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
9951035
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
996-
.schedulingParams
1036+
.schedulingParams
9971037
: { isScheduled: false };
9981038

9991039
return {
@@ -1039,7 +1079,7 @@ export default class HederaParameterNormaliser {
10391079
// Normalize scheduling parameters (if present and isScheduled = true)
10401080
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
10411081
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
1042-
.schedulingParams
1082+
.schedulingParams
10431083
: { isScheduled: false };
10441084

10451085
return {
@@ -1078,7 +1118,7 @@ export default class HederaParameterNormaliser {
10781118
// Normalize scheduling parameters (if present and isScheduled = true)
10791119
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
10801120
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
1081-
.schedulingParams
1121+
.schedulingParams
10821122
: { isScheduled: false };
10831123

10841124
return {
@@ -1146,7 +1186,7 @@ export default class HederaParameterNormaliser {
11461186
// Normalize scheduling parameters (if present and isScheduled = true)
11471187
const schedulingParams = parsedParams?.schedulingParams?.isScheduled
11481188
? (await this.normaliseScheduledTransactionParams(parsedParams, context, client))
1149-
.schedulingParams
1189+
.schedulingParams
11501190
: { isScheduled: false };
11511191

11521192
return {

0 commit comments

Comments
 (0)