saucer-swap-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/dist/abi/ERC20.json +222 -0
- package/dist/abi/QuoterV2.json +328 -0
- package/dist/abi/SwapRouter.json +481 -0
- package/dist/config.js +17 -0
- package/dist/constants.js +34 -0
- package/dist/errors.js +49 -0
- package/dist/index.js +19 -0
- package/dist/saucer-swap-v2-parameter-normaliser.js +102 -0
- package/dist/saucer-swap.zod.js +18 -0
- package/dist/service/pool-finder-service.js +24 -0
- package/dist/service/saucer-swap-rest-pools-service.interface.js +1 -0
- package/dist/service/saucer-swap-rest-pools-service.js +52 -0
- package/dist/service/saucer-swap-v2-config-service.js +36 -0
- package/dist/service/saucer-swap-v2-query-service-impl.js +96 -0
- package/dist/service/saucer-swap-v2-query-service.interface.js +1 -0
- package/dist/service/type.js +1 -0
- package/dist/service/utils.js +19 -0
- package/dist/tools/get-swap-quote-v2.js +72 -0
- package/dist/tools/swap-v2.js +79 -0
- package/dist/utils/hedera-mirrornode-utils.js +1 -0
- package/dist/utils/swap-path.js +25 -0
- package/dist/utils.js +98 -0
- package/package.json +45 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { AccountResolver } from "hedera-agent-kit";
|
|
2
|
+
import { getSwapQuoteV2Parameters, swapV2Parameters } from './saucer-swap.zod';
|
|
3
|
+
import { ethers } from "ethers";
|
|
4
|
+
import z from 'zod';
|
|
5
|
+
import { getHederaTokenAddress, getHederaTokenEVMAddress, getTokenDecimals, toBaseUnit } from "./utils";
|
|
6
|
+
import SwapRouterAbi from './abi/SwapRouter.json' assert { type: 'json' };
|
|
7
|
+
import { buildEncodedPath } from "./utils/swap-path";
|
|
8
|
+
import { SAUCER_SWAP_CONFIG } from "./constants";
|
|
9
|
+
import { PoolFinderService } from "./service/pool-finder-service";
|
|
10
|
+
import { InvalidAmountError } from "./errors";
|
|
11
|
+
export default class SaucerSwapV2ParameterNormaliser {
|
|
12
|
+
static parseParamsWithSchema(params, schema, context = {}) {
|
|
13
|
+
let parsedParams;
|
|
14
|
+
try {
|
|
15
|
+
parsedParams = schema(context).parse(params);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
if (e instanceof z.ZodError) {
|
|
19
|
+
const issues = this.formatZodIssues(e);
|
|
20
|
+
throw new Error(`Invalid parameters: ${issues}`);
|
|
21
|
+
}
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
return parsedParams;
|
|
25
|
+
}
|
|
26
|
+
static formatZodIssues(error) {
|
|
27
|
+
return error.errors.map(err => `Field "${err.path.join('.')}" - ${err.message}`).join('; ');
|
|
28
|
+
}
|
|
29
|
+
static async normaliseGetSwapQuoteV2Params(params, context, saucerSwapApiService) {
|
|
30
|
+
const parsedParams = this.parseParamsWithSchema(params, getSwapQuoteV2Parameters, context);
|
|
31
|
+
// Validate amount
|
|
32
|
+
if (parsedParams.amountIn <= 0) {
|
|
33
|
+
throw new InvalidAmountError(parsedParams.amountIn);
|
|
34
|
+
}
|
|
35
|
+
const tokenInEVM = getHederaTokenEVMAddress(parsedParams.tokenIn);
|
|
36
|
+
const inputTokenHedera = getHederaTokenAddress(parsedParams.tokenIn);
|
|
37
|
+
const tokenOutEVM = getHederaTokenEVMAddress(parsedParams.tokenOut);
|
|
38
|
+
const outputTokenHedera = getHederaTokenAddress(parsedParams.tokenOut);
|
|
39
|
+
// Use PoolFinderService to find the pool
|
|
40
|
+
const pool = await PoolFinderService.findPoolForTokens(inputTokenHedera, outputTokenHedera, saucerSwapApiService);
|
|
41
|
+
const poolFees = pool.fee;
|
|
42
|
+
const poolFeesInHexFormat = `0x${poolFees?.toString(16).padStart(6, '0')}`;
|
|
43
|
+
const decimals = getTokenDecimals(pool, inputTokenHedera);
|
|
44
|
+
const amountIn = toBaseUnit(parsedParams.amountIn, decimals).toNumber();
|
|
45
|
+
return {
|
|
46
|
+
tokenIn: tokenInEVM,
|
|
47
|
+
tokenOut: tokenOutEVM,
|
|
48
|
+
amountIn,
|
|
49
|
+
poolFeesInHexFormat: poolFeesInHexFormat
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
static async normaliseSwapV2Params(params, context, saucerSwapV2ConfigService, saucerSwapApiService, mirrorNode, client) {
|
|
53
|
+
const parsedParams = this.parseParamsWithSchema(params, swapV2Parameters, context);
|
|
54
|
+
// Validate amount is positive
|
|
55
|
+
if (parsedParams.amountIn <= 0) {
|
|
56
|
+
throw new InvalidAmountError(parsedParams.amountIn);
|
|
57
|
+
}
|
|
58
|
+
const inputTokenEVM = getHederaTokenEVMAddress(parsedParams.tokenIn);
|
|
59
|
+
const inputTokenHedera = getHederaTokenAddress(parsedParams.tokenIn);
|
|
60
|
+
const outputTokenEVM = getHederaTokenEVMAddress(parsedParams.tokenOut);
|
|
61
|
+
const outputTokenHedera = getHederaTokenAddress(parsedParams.tokenOut);
|
|
62
|
+
const recipient = AccountResolver.resolveAccount(parsedParams.recipientAddress, context, client);
|
|
63
|
+
const recipientAddress = await AccountResolver.getHederaEVMAddress(recipient, mirrorNode);
|
|
64
|
+
// Use PoolFinderService to find the pool
|
|
65
|
+
const pool = await PoolFinderService.findPoolForTokens(inputTokenHedera, outputTokenHedera, saucerSwapApiService);
|
|
66
|
+
const poolFees = pool.fee;
|
|
67
|
+
const decimals = getTokenDecimals(pool, inputTokenHedera);
|
|
68
|
+
const amountIn = toBaseUnit(parsedParams.amountIn, decimals).toNumber();
|
|
69
|
+
const poolFeesInHexFormat = `0x${poolFees?.toString(16).padStart(6, '0')}`;
|
|
70
|
+
const routeDataWithFee = buildEncodedPath(inputTokenEVM, poolFeesInHexFormat.toLowerCase(), outputTokenEVM);
|
|
71
|
+
const abiSwapRouterInterface = new ethers.Interface(SwapRouterAbi);
|
|
72
|
+
const swapRouterContractId = saucerSwapV2ConfigService.getSwapRouterContractId();
|
|
73
|
+
const wrappedHBarEvmAddress = saucerSwapV2ConfigService.getWrappedHBarEvmAddress();
|
|
74
|
+
//ExactInputParams
|
|
75
|
+
const exactInputParams = {
|
|
76
|
+
path: routeDataWithFee, //'0x...'
|
|
77
|
+
recipient: recipientAddress, //'0x...' - user's recipient address
|
|
78
|
+
deadline: Math.floor(Date.now() / 1000) + SAUCER_SWAP_CONFIG.DEFAULT_DEADLINE_SECONDS, // Unix seconds from now
|
|
79
|
+
amountIn: amountIn, //in Tinybar
|
|
80
|
+
amountOutMinimum: 0 //in token's smallest unit
|
|
81
|
+
};
|
|
82
|
+
//encode each function individually
|
|
83
|
+
const swapEncoded = abiSwapRouterInterface.encodeFunctionData('exactInput', [exactInputParams]);
|
|
84
|
+
const refundHBAREncoded = abiSwapRouterInterface.encodeFunctionData('refundETH');
|
|
85
|
+
//multi-call parameter: bytes[]
|
|
86
|
+
const multiCallParam = [swapEncoded, refundHBAREncoded];
|
|
87
|
+
//get encoded data for the multicall involving both functions
|
|
88
|
+
const encodedData = abiSwapRouterInterface.encodeFunctionData('multicall', [multiCallParam]);
|
|
89
|
+
//get encoded data as Uint8Array
|
|
90
|
+
const functionParameters = ethers.getBytes(encodedData);
|
|
91
|
+
let response = {
|
|
92
|
+
contractId: swapRouterContractId.toString(),
|
|
93
|
+
functionParameters,
|
|
94
|
+
gas: SAUCER_SWAP_CONFIG.SWAP_GAS_LIMIT,
|
|
95
|
+
payableAmount: undefined,
|
|
96
|
+
};
|
|
97
|
+
if (inputTokenEVM === wrappedHBarEvmAddress) {
|
|
98
|
+
response.payableAmount = amountIn;
|
|
99
|
+
}
|
|
100
|
+
return response;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const getSwapQuoteV2Parameters = () => z.object({
|
|
3
|
+
tokenIn: z.string().describe("Input token address"),
|
|
4
|
+
tokenOut: z.string().describe("Output token address"),
|
|
5
|
+
amountIn: z.number().describe("Amount of input tokens to swap"),
|
|
6
|
+
});
|
|
7
|
+
export const getSwapQuoteV2ParametersNormalised = () => z.object({
|
|
8
|
+
tokenIn: z.string().describe("Input token address"),
|
|
9
|
+
tokenOut: z.string().describe("Output token address"),
|
|
10
|
+
amountIn: z.number().describe("Amount of input tokens to swap"),
|
|
11
|
+
poolFeesInHexFormat: z.string().describe("Pool fees in hex format"),
|
|
12
|
+
});
|
|
13
|
+
export const swapV2Parameters = () => z.object({
|
|
14
|
+
tokenIn: z.string().describe("Input token address"),
|
|
15
|
+
tokenOut: z.string().describe("Output token address"),
|
|
16
|
+
amountIn: z.number().describe("Amount of input tokens to swap"),
|
|
17
|
+
recipientAddress: z.string().optional().describe("Recipient address"),
|
|
18
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PoolNotFoundError } from "../errors";
|
|
2
|
+
/**
|
|
3
|
+
* Service for finding pools by token pairs
|
|
4
|
+
*/
|
|
5
|
+
export class PoolFinderService {
|
|
6
|
+
/**
|
|
7
|
+
* Finds a pool for the given token pair
|
|
8
|
+
*
|
|
9
|
+
* @param tokenA - Hedera token address (e.g., "0.0.123456")
|
|
10
|
+
* @param tokenB - Hedera token address (e.g., "0.0.789012")
|
|
11
|
+
* @param apiService - SaucerSwap API service instance
|
|
12
|
+
* @returns The pool matching the token pair
|
|
13
|
+
* @throws {PoolNotFoundError} If no pool exists for the token pair
|
|
14
|
+
*/
|
|
15
|
+
static async findPoolForTokens(tokenA, tokenB, apiService) {
|
|
16
|
+
const pools = await apiService.getAllPoolsCompact();
|
|
17
|
+
const pool = pools.find(p => (p.tokenA.id === tokenA && p.tokenB.id === tokenB) ||
|
|
18
|
+
(p.tokenA.id === tokenB && p.tokenB.id === tokenA));
|
|
19
|
+
if (!pool) {
|
|
20
|
+
throw new PoolNotFoundError(tokenA, tokenB);
|
|
21
|
+
}
|
|
22
|
+
return pool;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { LedgerId } from "@hashgraph/sdk";
|
|
2
|
+
const SAUCERSWAP_REST_BASE_URLS = {
|
|
3
|
+
MAINNET: "https://api.saucerswap.finance",
|
|
4
|
+
TESTNET: "https://test-api.saucerswap.finance",
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Service to access SaucerSwap REST API V2 pools endpoint:
|
|
8
|
+
* GET /v2/pools – "Get compact data for all SaucerSwap V2 pools".
|
|
9
|
+
*
|
|
10
|
+
* Docs: https://docs.saucerswap.finance/v/developer/rest-api/pools-v2/pools
|
|
11
|
+
*/
|
|
12
|
+
export class SaucerSwapApiServiceImpl {
|
|
13
|
+
baseUrl;
|
|
14
|
+
apiKey;
|
|
15
|
+
constructor(ledgerId, apiKey) {
|
|
16
|
+
const network = this.mapLedgerToNetwork(ledgerId);
|
|
17
|
+
this.baseUrl = SAUCERSWAP_REST_BASE_URLS[network];
|
|
18
|
+
// The docs provide a demo key that is globally rate limited and not for production use:
|
|
19
|
+
// default: 875e1017-87b8-4b12-8301-6aa1f1aa073b
|
|
20
|
+
// See: https://docs.saucerswap.finance/v/developer/rest-api/pools-v1/pools
|
|
21
|
+
this.apiKey = apiKey;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Calls SaucerSwap V2 "Get compact data for all SaucerSwap V2 pools" endpoint.
|
|
25
|
+
* GET {baseUrl}/v2/pools
|
|
26
|
+
*/
|
|
27
|
+
async getAllPoolsCompact() {
|
|
28
|
+
const url = `${this.baseUrl}/v2/pools`;
|
|
29
|
+
const response = await fetch(url, {
|
|
30
|
+
method: "GET",
|
|
31
|
+
headers: {
|
|
32
|
+
"x-api-key": this.apiKey,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`SaucerSwap REST "GET /pools" failed with status ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
const pools = (await response.json());
|
|
39
|
+
return pools;
|
|
40
|
+
}
|
|
41
|
+
mapLedgerToNetwork(ledgerId) {
|
|
42
|
+
switch (ledgerId.toString()) {
|
|
43
|
+
case LedgerId.MAINNET.toString():
|
|
44
|
+
return "MAINNET";
|
|
45
|
+
case LedgerId.TESTNET.toString():
|
|
46
|
+
return "TESTNET";
|
|
47
|
+
default:
|
|
48
|
+
// Fallback to MAINNET base URL if an unsupported ledger is used.
|
|
49
|
+
return "MAINNET";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// write a config service to get the config from the config file
|
|
2
|
+
import { saucerSwapConfig } from "../config";
|
|
3
|
+
import { ContractId, TokenId } from "@hashgraph/sdk";
|
|
4
|
+
export class SaucerSwapV2ConfigService {
|
|
5
|
+
saucerSwapConfig;
|
|
6
|
+
ledgerId;
|
|
7
|
+
constructor(ledgerId) {
|
|
8
|
+
this.saucerSwapConfig = saucerSwapConfig;
|
|
9
|
+
this.ledgerId = ledgerId;
|
|
10
|
+
}
|
|
11
|
+
//get router address
|
|
12
|
+
getRouterAddress() {
|
|
13
|
+
return this.saucerSwapConfig.networks[this.ledgerId.toString()]?.router ?? '';
|
|
14
|
+
}
|
|
15
|
+
getSwapRouterContractId() {
|
|
16
|
+
return ContractId.fromEvmAddress(0, 0, this.saucerSwapConfig.networks[this.ledgerId.toString()]?.router ?? '');
|
|
17
|
+
}
|
|
18
|
+
getWrappedHBARTokenId() {
|
|
19
|
+
return TokenId.fromEvmAddress(0, 0, this.saucerSwapConfig.networks[this.ledgerId.toString()]?.wrappedHBAR ?? '');
|
|
20
|
+
}
|
|
21
|
+
getWrappedHBarEvmAddress() {
|
|
22
|
+
return this.saucerSwapConfig.networks[this.ledgerId.toString()]?.wrappedHBAR ?? '';
|
|
23
|
+
}
|
|
24
|
+
getSaucerSwapApiKey() {
|
|
25
|
+
//get the api key from environment variable
|
|
26
|
+
const apiKey = process.env.SAUCERSWAP_API_KEY;
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
throw new Error('SAUCERSWAP_API_KEY is not set');
|
|
29
|
+
}
|
|
30
|
+
return apiKey;
|
|
31
|
+
}
|
|
32
|
+
//get quoter address
|
|
33
|
+
getQuoterAddress() {
|
|
34
|
+
return this.saucerSwapConfig.networks[this.ledgerId.toString()]?.quoter ?? '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ethers } from 'ethers';
|
|
2
|
+
import QuoterV2Abi from '../abi/QuoterV2.json' assert { type: 'json' };
|
|
3
|
+
import ERC20Abi from '../abi/ERC20.json' assert { type: 'json' };
|
|
4
|
+
import { SAUCER_SWAP_CONFIG } from '../constants';
|
|
5
|
+
import { MirrorNodeError, InvalidAmountError } from '../errors';
|
|
6
|
+
export class SaucerSwapV2QueryServiceImpl {
|
|
7
|
+
abiQuoterInterface;
|
|
8
|
+
abiERC20Interface;
|
|
9
|
+
quoterEvmAddress;
|
|
10
|
+
mirrorNodeService;
|
|
11
|
+
saucerSwapV2ConfigService;
|
|
12
|
+
constructor(ledgerId, mirrorNodeService, saucerSwapV2ConfigService) {
|
|
13
|
+
this.mirrorNodeService = mirrorNodeService;
|
|
14
|
+
this.saucerSwapV2ConfigService = saucerSwapV2ConfigService;
|
|
15
|
+
this.abiQuoterInterface = new ethers.Interface(QuoterV2Abi);
|
|
16
|
+
this.abiERC20Interface = new ethers.Interface(ERC20Abi);
|
|
17
|
+
this.quoterEvmAddress = this.saucerSwapV2ConfigService.getQuoterAddress();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Gets a swap quote for the specified token pair
|
|
21
|
+
ken - The EVM address const poolFeesInHexFormat = this.saucerSwapV2ConfigService.getPoolFeesInHexFormat(inputToken, outputToken);
|
|
22
|
+
of the input token
|
|
23
|
+
* @param outputToken - The EVM address of the output token
|
|
24
|
+
* @param amountIn - The amount of input tokens in base units
|
|
25
|
+
* @param poolFeesInHexFormat - The pool fee in hex format (e.g., "0x001e" for 30 bps)
|
|
26
|
+
* @returns The amount of output tokens that would be received
|
|
27
|
+
* @throws {MirrorNodeError} If the mirror node call fails
|
|
28
|
+
*/
|
|
29
|
+
async getSwapQuote(inputToken, outputToken, amountIn, poolFeesInHexFormat) {
|
|
30
|
+
// Validate inputs
|
|
31
|
+
if (amountIn <= 0) {
|
|
32
|
+
throw new InvalidAmountError(amountIn);
|
|
33
|
+
}
|
|
34
|
+
const encodedData = this.abiQuoterInterface.encodeFunctionData(this.abiQuoterInterface.getFunction('quoteExactInputSingle'), [{
|
|
35
|
+
tokenIn: inputToken,
|
|
36
|
+
tokenOut: outputToken,
|
|
37
|
+
amountIn: amountIn,
|
|
38
|
+
fee: poolFeesInHexFormat,
|
|
39
|
+
sqrtPriceLimitX96: 0
|
|
40
|
+
}]);
|
|
41
|
+
const url = `${this.mirrorNodeService.getBaseUrl()}/contracts/call`;
|
|
42
|
+
const body = {
|
|
43
|
+
data: encodedData,
|
|
44
|
+
from: SAUCER_SWAP_CONFIG.MIRROR_NODE_FROM_ADDRESS,
|
|
45
|
+
to: this.quoterEvmAddress,
|
|
46
|
+
block: "latest",
|
|
47
|
+
estimate: false,
|
|
48
|
+
gas: SAUCER_SWAP_CONFIG.QUOTE_GAS_LIMIT,
|
|
49
|
+
gasPrice: SAUCER_SWAP_CONFIG.DEFAULT_GAS_PRICE,
|
|
50
|
+
value: 0,
|
|
51
|
+
};
|
|
52
|
+
const response = await fetch(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'content-type': 'application/json' },
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new MirrorNodeError(`Call failed with status ${response.status}`, response.status);
|
|
59
|
+
}
|
|
60
|
+
const json = await response.json();
|
|
61
|
+
const decoded = this.abiQuoterInterface.decodeFunctionResult('quoteExactInputSingle', json.result);
|
|
62
|
+
return Number(decoded.amountOut);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Gets the decimals for a token
|
|
66
|
+
*
|
|
67
|
+
* @param tokenEvmAddress - The EVM address of the token
|
|
68
|
+
* @returns The number of decimals for the token
|
|
69
|
+
* @throws {MirrorNodeError} If the mirror node call fails
|
|
70
|
+
*/
|
|
71
|
+
async getDecimals(tokenEvmAddress) {
|
|
72
|
+
const encodedData = this.abiERC20Interface.encodeFunctionData(this.abiERC20Interface.getFunction('decimals'), []);
|
|
73
|
+
const url = `${this.mirrorNodeService.getBaseUrl()}/contracts/call`;
|
|
74
|
+
const body = {
|
|
75
|
+
data: encodedData,
|
|
76
|
+
from: SAUCER_SWAP_CONFIG.MIRROR_NODE_FROM_ADDRESS,
|
|
77
|
+
to: tokenEvmAddress,
|
|
78
|
+
block: "latest",
|
|
79
|
+
estimate: false,
|
|
80
|
+
gas: SAUCER_SWAP_CONFIG.DECIMALS_GAS_LIMIT,
|
|
81
|
+
gasPrice: SAUCER_SWAP_CONFIG.DEFAULT_GAS_PRICE,
|
|
82
|
+
value: 0,
|
|
83
|
+
};
|
|
84
|
+
const response = await fetch(url, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'content-type': 'application/json' },
|
|
87
|
+
body: JSON.stringify(body),
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
throw new MirrorNodeError(`Call failed with status ${response.status}`, response.status);
|
|
91
|
+
}
|
|
92
|
+
const json = await response.json();
|
|
93
|
+
const decoded = this.abiERC20Interface.decodeFunctionResult('decimals', json.result);
|
|
94
|
+
return decoded[0];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function hexToUint8Array(hex) {
|
|
2
|
+
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
3
|
+
if (cleanHex.length % 2 !== 0) {
|
|
4
|
+
throw new Error('Invalid hex string length');
|
|
5
|
+
}
|
|
6
|
+
const array = new Uint8Array(cleanHex.length / 2);
|
|
7
|
+
for (let i = 0; i < array.length; i++) {
|
|
8
|
+
array[i] = parseInt(cleanHex.substr(i * 2, 2), 16);
|
|
9
|
+
}
|
|
10
|
+
return array;
|
|
11
|
+
}
|
|
12
|
+
export function buildEncodedPath(inputToken, hexFee, outputToken) {
|
|
13
|
+
const clean = (s) => s.startsWith('0x') ? s.slice(2) : s;
|
|
14
|
+
const pathParts = [];
|
|
15
|
+
pathParts.push(clean(inputToken));
|
|
16
|
+
pathParts.push(clean(hexFee));
|
|
17
|
+
pathParts.push(clean(outputToken));
|
|
18
|
+
return hexToUint8Array(pathParts.join(''));
|
|
19
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { PromptGenerator, getMirrornodeService } from "hedera-agent-kit";
|
|
2
|
+
import { getSwapQuoteV2Parameters } from "../saucer-swap.zod";
|
|
3
|
+
import { SaucerSwapV2QueryServiceImpl } from "../service/saucer-swap-v2-query-service-impl";
|
|
4
|
+
import SaucerSwapV2ParameterNormaliser from "../saucer-swap-v2-parameter-normaliser";
|
|
5
|
+
import { SaucerSwapV2ConfigService } from "../service/saucer-swap-v2-config-service";
|
|
6
|
+
import { SaucerSwapApiServiceImpl } from "../service/saucer-swap-rest-pools-service";
|
|
7
|
+
import { SaucerSwapError } from "../errors";
|
|
8
|
+
const getSwapQuoteV2Prompt = (context = {}) => {
|
|
9
|
+
const contextSnippet = PromptGenerator.getContextSnippet(context);
|
|
10
|
+
const usageInstructions = PromptGenerator.getParameterUsageInstructions();
|
|
11
|
+
return `
|
|
12
|
+
${contextSnippet}
|
|
13
|
+
|
|
14
|
+
This tool will get a quote for swapping from tokenIn to tokenOut. Provide either optional.amountIn (exact-in) or optional.amountOut (exact-out).
|
|
15
|
+
|
|
16
|
+
Parameters:
|
|
17
|
+
- tokenIn (str, required): The input token address.
|
|
18
|
+
- tokenOut (str, required): The output token address.
|
|
19
|
+
- amountIn (number, required): The amount of input tokens to swap.
|
|
20
|
+
${usageInstructions}
|
|
21
|
+
|
|
22
|
+
Example: "Get quote for swapping 1000000000000000000 amountIn from 0x1234567890abcdef1234567890abcdef12345678 tokenIn to 0xabcdef1234567890abcdef1234567890abcdef12345678 tokenOut"
|
|
23
|
+
`;
|
|
24
|
+
};
|
|
25
|
+
const postProcess = (quote, tokenAmountInBaseUnit, params) => {
|
|
26
|
+
return `Swapping ${tokenAmountInBaseUnit} token: ${params.tokenIn} to token: ${params.tokenOut} will result in ${quote} token: ${params.tokenOut}, the rate used is ${quote / tokenAmountInBaseUnit}`;
|
|
27
|
+
};
|
|
28
|
+
const getSwapQuoteV2 = async (client, context, params) => {
|
|
29
|
+
const mirrorNode = getMirrornodeService(context.mirrornodeService, client.ledgerId);
|
|
30
|
+
const saucerSwapV2ConfigService = new SaucerSwapV2ConfigService(client.ledgerId);
|
|
31
|
+
const saucerSwapV2QueryService = new SaucerSwapV2QueryServiceImpl(client.ledgerId, mirrorNode, saucerSwapV2ConfigService);
|
|
32
|
+
const saucerSwapApiService = new SaucerSwapApiServiceImpl(client.ledgerId, saucerSwapV2ConfigService.getSaucerSwapApiKey());
|
|
33
|
+
try {
|
|
34
|
+
const normalisedParams = await SaucerSwapV2ParameterNormaliser.normaliseGetSwapQuoteV2Params(params, context, saucerSwapApiService);
|
|
35
|
+
const quote = await saucerSwapV2QueryService.getSwapQuote(normalisedParams.tokenIn, normalisedParams.tokenOut, normalisedParams.amountIn, normalisedParams.poolFeesInHexFormat.toLowerCase());
|
|
36
|
+
return {
|
|
37
|
+
raw: { quote },
|
|
38
|
+
humanMessage: postProcess(quote, normalisedParams.amountIn, normalisedParams),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const desc = 'Failed to get quote';
|
|
43
|
+
let message;
|
|
44
|
+
if (error instanceof SaucerSwapError) {
|
|
45
|
+
message = `${desc}: ${error.message} (code: ${error.code})`;
|
|
46
|
+
}
|
|
47
|
+
else if (error instanceof Error) {
|
|
48
|
+
message = `${desc}: ${error.message}`;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
message = `${desc}: Unknown error occurred`;
|
|
52
|
+
}
|
|
53
|
+
console.error('[get_quote_tool]', message, error);
|
|
54
|
+
return { raw: { error: message }, humanMessage: message };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
export const GET_SWAP_QUOTE_V2_TOOL = "get_swap_quote_v2_tool";
|
|
58
|
+
const tool = (context) => ({
|
|
59
|
+
method: GET_SWAP_QUOTE_V2_TOOL,
|
|
60
|
+
name: "Get Quote (SaucerSwap V2)",
|
|
61
|
+
description: getSwapQuoteV2Prompt(context),
|
|
62
|
+
parameters: getSwapQuoteV2Parameters(),
|
|
63
|
+
execute: getSwapQuoteV2,
|
|
64
|
+
outputParser: (rawOutput) => {
|
|
65
|
+
const json = JSON.parse(rawOutput);
|
|
66
|
+
return {
|
|
67
|
+
raw: json,
|
|
68
|
+
humanMessage: json.quote,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
export default tool;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import SaucerSwapV2ParameterNormaliser from "../saucer-swap-v2-parameter-normaliser";
|
|
2
|
+
import { swapV2Parameters } from "../saucer-swap.zod";
|
|
3
|
+
import { getMirrornodeService, handleTransaction, HederaBuilder, PromptGenerator } from "hedera-agent-kit";
|
|
4
|
+
import { Status } from "@hashgraph/sdk";
|
|
5
|
+
import { SaucerSwapV2ConfigService } from "../service/saucer-swap-v2-config-service";
|
|
6
|
+
import { SaucerSwapApiServiceImpl } from "../service/saucer-swap-rest-pools-service";
|
|
7
|
+
import { SaucerSwapError } from "../errors";
|
|
8
|
+
const swapV2Prompt = (context = {}) => {
|
|
9
|
+
return `
|
|
10
|
+
${PromptGenerator.getContextSnippet(context)}
|
|
11
|
+
|
|
12
|
+
This tool will swap tokens using the SaucerSwap V2 protocol.
|
|
13
|
+
|
|
14
|
+
Parameters:
|
|
15
|
+
- tokenIn (str, required): The input token address
|
|
16
|
+
- tokenOut (str, required): The output token address
|
|
17
|
+
- amountIn (number, required): The amount of input tokens to swap
|
|
18
|
+
- recipientAddress (str, required): The address to receive the output tokens
|
|
19
|
+
`;
|
|
20
|
+
};
|
|
21
|
+
const postProcess = (response) => {
|
|
22
|
+
return `
|
|
23
|
+
Swap successful.
|
|
24
|
+
Transaction ID: ${response.transactionId}
|
|
25
|
+
`;
|
|
26
|
+
};
|
|
27
|
+
const swapV2 = async (client, context, params) => {
|
|
28
|
+
try {
|
|
29
|
+
const mirrorNode = getMirrornodeService(context.mirrornodeService, client.ledgerId);
|
|
30
|
+
const saucerSwapV2ConfigService = new SaucerSwapV2ConfigService(client.ledgerId);
|
|
31
|
+
const saucerSwapApiService = new SaucerSwapApiServiceImpl(client.ledgerId, saucerSwapV2ConfigService.getSaucerSwapApiKey());
|
|
32
|
+
const normalisedParams = await SaucerSwapV2ParameterNormaliser.normaliseSwapV2Params(params, context, saucerSwapV2ConfigService, saucerSwapApiService, mirrorNode, client);
|
|
33
|
+
const modifiedParams = { ...normalisedParams, gas: normalisedParams.gas };
|
|
34
|
+
const tx = HederaBuilder.executeTransaction(modifiedParams);
|
|
35
|
+
return handleTransaction(tx, client, context, postProcess);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const desc = 'Failed to swap tokens';
|
|
39
|
+
let message;
|
|
40
|
+
if (error instanceof SaucerSwapError) {
|
|
41
|
+
message = `${desc}: ${error.message} (code: ${error.code})`;
|
|
42
|
+
}
|
|
43
|
+
else if (error instanceof Error) {
|
|
44
|
+
message = `${desc}: ${error.message}`;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
message = `${desc}: Unknown error occurred`;
|
|
48
|
+
}
|
|
49
|
+
console.error('[swap_v2_tool]', message, error);
|
|
50
|
+
return {
|
|
51
|
+
raw: {
|
|
52
|
+
status: Status.InvalidTransaction.toString(),
|
|
53
|
+
accountId: null,
|
|
54
|
+
tokenId: null,
|
|
55
|
+
transactionId: '',
|
|
56
|
+
topicId: null,
|
|
57
|
+
scheduleId: null,
|
|
58
|
+
error: message
|
|
59
|
+
},
|
|
60
|
+
humanMessage: message
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
export const SWAP_V2_TOOL = 'swap_v2_tool';
|
|
65
|
+
const tool = (context) => ({
|
|
66
|
+
method: SWAP_V2_TOOL,
|
|
67
|
+
name: 'Swap V2',
|
|
68
|
+
description: swapV2Prompt(context),
|
|
69
|
+
parameters: swapV2Parameters(),
|
|
70
|
+
execute: swapV2,
|
|
71
|
+
outputParser: (rawOutput) => {
|
|
72
|
+
const json = JSON.parse(rawOutput);
|
|
73
|
+
return {
|
|
74
|
+
raw: json,
|
|
75
|
+
humanMessage: json.transactionId,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
export default tool;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function hexToUint8Array(hex) {
|
|
2
|
+
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
3
|
+
if (cleanHex.length % 2 !== 0) {
|
|
4
|
+
throw new Error('Invalid hex string length');
|
|
5
|
+
}
|
|
6
|
+
const array = new Uint8Array(cleanHex.length / 2);
|
|
7
|
+
for (let i = 0; i < array.length; i++) {
|
|
8
|
+
array[i] = parseInt(cleanHex.substr(i * 2, 2), 16);
|
|
9
|
+
}
|
|
10
|
+
return array;
|
|
11
|
+
}
|
|
12
|
+
export function buildEncodedPath(inputToken, hexFee, outputToken) {
|
|
13
|
+
const clean = (s) => s.toLowerCase().replace(/^0x/, '');
|
|
14
|
+
const inHex = clean(inputToken);
|
|
15
|
+
const feeHex = clean(hexFee).padStart(6, '0'); // ensure 3 bytes
|
|
16
|
+
const outHex = clean(outputToken);
|
|
17
|
+
if (inHex.length !== 40)
|
|
18
|
+
throw new Error(`tokenIn must be 20 bytes (40 hex), got ${inHex.length}`);
|
|
19
|
+
if (feeHex.length !== 6)
|
|
20
|
+
throw new Error(`fee must be 3 bytes (6 hex), got ${feeHex.length}`);
|
|
21
|
+
if (outHex.length !== 40)
|
|
22
|
+
throw new Error(`tokenOut must be 20 bytes (40 hex), got ${outHex.length}`);
|
|
23
|
+
const joined = inHex + feeHex + outHex; // total 86 hex chars (43 bytes)
|
|
24
|
+
return hexToUint8Array(joined);
|
|
25
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { TokenId } from "@hashgraph/sdk";
|
|
2
|
+
import BigNumber from 'bignumber.js';
|
|
3
|
+
import { AccountResolver } from "hedera-agent-kit";
|
|
4
|
+
/**
|
|
5
|
+
* Handles response formatting for both autonomous and manual modes
|
|
6
|
+
*/
|
|
7
|
+
export const handleResponse = (data, message) => {
|
|
8
|
+
return {
|
|
9
|
+
success: true,
|
|
10
|
+
data,
|
|
11
|
+
message,
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Converts decimal amount to tiny units (wei)
|
|
16
|
+
*/
|
|
17
|
+
export const toTiny = (amount) => {
|
|
18
|
+
const amountBigInt = typeof amount === "string" ? BigInt(amount) : BigInt(amount);
|
|
19
|
+
return amountBigInt.toString();
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Validates token addresses
|
|
23
|
+
*/
|
|
24
|
+
export const isValidAddress = (address) => {
|
|
25
|
+
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Normalizes token addresses to lowercase
|
|
29
|
+
*/
|
|
30
|
+
export const normalizeAddress = (address) => {
|
|
31
|
+
if (!isValidAddress(address)) {
|
|
32
|
+
throw new Error(`Invalid address: ${address}`);
|
|
33
|
+
}
|
|
34
|
+
return address.toLowerCase();
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Sorts token addresses for consistent pair ordering
|
|
38
|
+
*/
|
|
39
|
+
export const sortTokens = (tokenA, tokenB) => {
|
|
40
|
+
const normalizedA = normalizeAddress(tokenA);
|
|
41
|
+
const normalizedB = normalizeAddress(tokenB);
|
|
42
|
+
if (normalizedA === normalizedB) {
|
|
43
|
+
throw new Error("Cannot create pool with same token");
|
|
44
|
+
}
|
|
45
|
+
return normalizedA < normalizedB ? [normalizedA, normalizedB] : [normalizedB, normalizedA];
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Calculates slippage bounds
|
|
49
|
+
*/
|
|
50
|
+
export const calculateSlippageBounds = (amount, slippageBps) => {
|
|
51
|
+
const slippageMultiplier = BigInt(10000 - slippageBps);
|
|
52
|
+
const min = (amount * slippageMultiplier) / 10000n;
|
|
53
|
+
const maxMultiplier = BigInt(10000 + slippageBps);
|
|
54
|
+
const max = (amount * maxMultiplier) / 10000n;
|
|
55
|
+
return { min, max };
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Formats amount with decimals
|
|
59
|
+
*/
|
|
60
|
+
export const formatAmount = (amount, decimals) => {
|
|
61
|
+
const divisor = BigInt(10 ** decimals);
|
|
62
|
+
const whole = amount / divisor;
|
|
63
|
+
const fractional = amount % divisor;
|
|
64
|
+
if (fractional === 0n) {
|
|
65
|
+
return whole.toString();
|
|
66
|
+
}
|
|
67
|
+
const fractionalStr = fractional.toString().padStart(decimals, '0');
|
|
68
|
+
const trimmed = fractionalStr.replace(/0+$/, '');
|
|
69
|
+
if (trimmed === '') {
|
|
70
|
+
return whole.toString();
|
|
71
|
+
}
|
|
72
|
+
return `${whole}.${trimmed}`;
|
|
73
|
+
};
|
|
74
|
+
export const getHederaTokenEVMAddress = (address) => {
|
|
75
|
+
if (!AccountResolver.isHederaAddress(address)) {
|
|
76
|
+
return address;
|
|
77
|
+
}
|
|
78
|
+
const token = TokenId.fromString(address);
|
|
79
|
+
return '0x' + token.toEvmAddress();
|
|
80
|
+
};
|
|
81
|
+
export const getHederaTokenAddress = (address) => {
|
|
82
|
+
if (AccountResolver.isHederaAddress(address)) {
|
|
83
|
+
return address;
|
|
84
|
+
}
|
|
85
|
+
const token = TokenId.fromEvmAddress(0, 0, address);
|
|
86
|
+
return token.toString();
|
|
87
|
+
};
|
|
88
|
+
export function toBaseUnit(amount, decimals) {
|
|
89
|
+
const amountBN = new BigNumber(amount);
|
|
90
|
+
const multiplier = new BigNumber(10).pow(decimals);
|
|
91
|
+
return amountBN.multipliedBy(multiplier).integerValue(BigNumber.ROUND_FLOOR);
|
|
92
|
+
}
|
|
93
|
+
export const getTokenDecimals = (pool, hederaTokenAddress) => {
|
|
94
|
+
if (pool.tokenA.id === hederaTokenAddress) {
|
|
95
|
+
return pool.tokenA.decimals;
|
|
96
|
+
}
|
|
97
|
+
return pool.tokenB.decimals;
|
|
98
|
+
};
|