Skip to content

Commit

Permalink
feat: integrate Mayan SDK (#322)
Browse files Browse the repository at this point in the history
* feat: integrate Mayan SDK

* docs: add readme

* fix: use workspace in package.json

* build: update the lock file

* fix: use solana/web3 catalog version

* fix: lint and build errors

* fix: no need to wait on callERC20 result

* style: lint fix

* Minor readme fixes

* Add changeset

---------

Co-authored-by: Agustin Armellini Fischer <[email protected]>
  • Loading branch information
zoli and 0xaguspunk authored Feb 19, 2025
1 parent 274fe57 commit f68960d
Show file tree
Hide file tree
Showing 12 changed files with 621 additions and 351 deletions.
5 changes: 5 additions & 0 deletions typescript/.changeset/six-eels-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@goat-sdk/plugin-mayan": patch
---

Release package
1 change: 1 addition & 0 deletions typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ If you would like to see your wallet provider supported, please open an issue or
| Jupiter | Swap tokens on Jupiter | [@goat-sdk/plugin-jupiter](https://www.npmjs.com/package/@goat-sdk/plugin-jupiter) |
| KIM | Swap tokens on KIM | [@goat-sdk/plugin-kim](https://www.npmjs.com/package/@goat-sdk/plugin-kim) |
| Lulo | Deposit USDC on Lulo | [@goat-sdk/plugin-lulo](https://www.npmjs.com/package/@goat-sdk/plugin-lulo) |
| Mayan | Cross-chain token swap using Mayan SDK (Solana, EVM, SUI) | [@goat-sdk/plugin-mayan](https://www.npmjs.com/package/@goat-sdk/plugin-mayan) |
| Meteora | Create liquidity pools on Meteora | [@goat-sdk/plugin-meteora](https://www.npmjs.com/package/@goat-sdk/plugin-meteora) |
| Mode Governance | Create a governance proposal on Mode | [@goat-sdk/plugin-mode-governance](https://www.npmjs.com/package/@goat-sdk/plugin-mode-governance) |
| Mode Voting | Vote on a governance proposal on Mode | [@goat-sdk/plugin-mode-voting](https://www.npmjs.com/package/@goat-sdk/plugin-mode-voting) |
Expand Down
24 changes: 24 additions & 0 deletions typescript/packages/plugins/mayan/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Mayan GOAT Plugin

Cross-chain token swap using Mayan SDK (Solana, EVM, SUI)

## Installation
```bash
npm install @goat-sdk/plugin-mayan
```

## Usage
```typescript
import { mayan } from '@goat-sdk/plugin-mayan';

const tools = await getOnChainTools({
wallet: // ...
plugins: [
mayan()
]
});
```

## Tools
- Swap from Solana to Solana, EVM, SUI
- Swap from EVM to EVM, Solana, SUI
39 changes: 39 additions & 0 deletions typescript/packages/plugins/mayan/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@goat-sdk/plugin-mayan",
"version": "0.1.0",
"files": ["dist/**/*", "README.md", "package.json"],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "vitest run --passWithNoTests"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"homepage": "https://ohmygoat.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/goat-sdk/goat.git"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/goat-sdk/goat/issues"
},
"keywords": ["ai", "agents", "web3"],
"dependencies": {
"@goat-sdk/core": "workspace:*",
"@goat-sdk/wallet-evm": "workspace:*",
"@goat-sdk/wallet-solana": "workspace:*",
"@goat-sdk/wallet-sui": "workspace:*",
"@goat-sdk/wallet-viem": "workspace:*",
"@mayanfinance/swap-sdk": "10.2.0",
"@mysten/sui": "^1.18.0",
"@solana/web3.js": "catalog:",
"abitype": "1.0.8",
"bs58": "^6.0.0",
"ethers": "6.13.5",
"viem": "catalog:",
"zod": "catalog:"
}
}
2 changes: 2 additions & 0 deletions typescript/packages/plugins/mayan/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./mayan.plugin";
export * from "./parameters";
14 changes: 14 additions & 0 deletions typescript/packages/plugins/mayan/src/mayan.plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Chain, PluginBase } from "@goat-sdk/core";
import { MayanService } from "./mayan.service";

export class MayanPlugin extends PluginBase {
constructor() {
super("mayan", [new MayanService()]);
}

supportsChain = (chain: Chain) => ["sui", "evm", "solana"].includes(chain.type);
}

export function mayan() {
return new MayanPlugin();
}
298 changes: 298 additions & 0 deletions typescript/packages/plugins/mayan/src/mayan.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
import { Tool } from "@goat-sdk/core";
import { EVMWalletClient } from "@goat-sdk/wallet-evm";
import { SolanaWalletClient } from "@goat-sdk/wallet-solana";
import {
ChainName,
Erc20Permit,
Quote,
addresses,
createSwapFromSolanaInstructions,
fetchQuote,
fetchTokenList,
getSwapFromEvmTxPayload,
} from "@mayanfinance/swap-sdk";
import { TypedDataDomain } from "abitype";
import { Signature, TypedDataEncoder } from "ethers";
import { parseAbi } from "viem";
import { EVMSwapParameters, SwapParameters } from "./parameters";

const ERC20_ABI = parseAbi([
"function allowance(address owner, address spender) external view returns (uint256)",
"function approve(address spender, uint256 amount) external returns (bool)",
"function nonces(address owner) external returns (uint256)",
"function name() external returns (string)",
"function DOMAIN_SEPARATOR() external returns (bytes32)",
"function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)",
]);

export class MayanService {
@Tool({
name: "mayan_swap_from_solana",
description: "Swap from solana to solana, EVM, sui chain",
})
async swapSolanaTool(walletClient: SolanaWalletClient, params: SwapParameters): Promise<string> {
if (params.fromToken.length < 32) {
params.fromToken = await this.findTokenContract(params.fromToken, "solana");
}
if (params.toToken.length < 32) {
params.toToken = await this.findTokenContract(params.toToken, params.toChain);
}

const quotes = await fetchQuote({
amount: +params.amount,
fromChain: "solana",
toChain: params.toChain as ChainName,
fromToken: params.fromToken,
toToken: params.toToken,
slippageBps: params.slippageBps ?? "auto",
});
if (quotes.length === 0) {
throw new Error("There is no quote available for the tokens you requested.");
}

const { instructions, signers, lookupTables } = await createSwapFromSolanaInstructions(
quotes[0],
walletClient.getAddress(),
params.dstAddr,
null,
walletClient.getConnection(),
);
let hash: string;
try {
hash = (
await walletClient.sendTransaction({
instructions,
addressLookupTableAddresses: lookupTables.map((a) => a.key.toString()),
accountsToSign: signers,
})
).hash;
} catch (error) {
if (!hasSignatureProperty(error) || !error.signature) {
throw error;
}

await new Promise((f) => setTimeout(f, 3000));
hash = error.signature;
const res = await fetch(`https://explorer-api.mayan.finance/v3/swap/trx/${hash}`);
if (res.status !== 200) {
throw error;
}
}

return `https://explorer.mayan.finance/swap/${hash}`;
}

@Tool({
name: "mayan_swap_from_evm",
description: "Swap from EVM to solana, EVM, sui chain",
})
async swapEVMTool(walletClient: EVMWalletClient, params: EVMSwapParameters): Promise<string> {
if (params.fromToken.length < 32) {
params.fromToken = await this.findTokenContract(params.fromToken, params.fromChain);
}
if (params.toToken.length < 32) {
params.toToken = await this.findTokenContract(params.toToken, params.toChain);
}

const quotes = await fetchQuote({
amount: +params.amount,
fromChain: params.fromChain as ChainName,
toChain: params.toChain as ChainName,
fromToken: params.fromToken,
toToken: params.toToken,
slippageBps: params.slippageBps ?? "auto",
});
if (quotes.length === 0) {
throw new Error("There is no quote available for the tokens you requested.");
}

const amountIn = BigInt(quotes[0].effectiveAmountIn64);
const allowance: bigint = (await this.callERC20(walletClient, params.fromToken, "allowance", [
walletClient.getAddress(),
addresses.MAYAN_FORWARDER_CONTRACT,
])) as bigint;
if (allowance < amountIn) {
// Approve the spender to spend the tokens
const approveTx = (await this.callERC20(walletClient, params.fromToken, "approve", [
addresses.MAYAN_FORWARDER_CONTRACT,
amountIn,
])) as boolean;
if (!approveTx) {
throw new Error("couldn't get approve for spending allowance");
}
}

let permit: Erc20Permit | undefined;
if (quotes[0].fromToken.supportsPermit) {
permit = await this.getERC20Permit(walletClient, quotes[0], amountIn);
}

const transactionReq = getSwapFromEvmTxPayload(
quotes[0],
walletClient.getAddress(),
params.dstAddr,
null,
walletClient.getAddress(),
walletClient.getChain().id,
null,
permit,
);
const { hash } = await walletClient.sendTransaction({
to: transactionReq.to as string,
value: transactionReq.value ? BigInt(transactionReq.value) : undefined,
data: transactionReq.data ? (transactionReq.data as `0x${string}`) : undefined,
});

return `https://explorer.mayan.finance/swap/${hash}`;
}

//SuiKeyPairWalletClien isn't exported from @goat-sdk/wallet-sui
//so I couldn't test this.
//@Tool({
// name: "mayan_swap_from_sui",
// description: "Swap from sui to solana, EVM, sui chain",
//})
//async swapSUITool(
// walletClient: SuiWalletClient,
// params: SwapParameters
//): Promise<string> {
// if (params.fromToken.length < 32) {
// params.fromToken = await this.findTokenContract(
// params.fromToken,
// "sui"
// );
// }
// if (params.toToken.length < 32) {
// params.toToken = await this.findTokenContract(
// params.toToken,
// params.toChain
// );
// }
//
// const quotes = await fetchQuote({
// amount: +params.amount,
// fromChain: "sui",
// toChain: params.toChain as ChainName,
// fromToken: params.fromToken,
// toToken: params.toToken,
// slippageBps: params.slippageBps ?? "auto",
// });
// if (quotes.length === 0) {
// throw new Error(
// "There is no quote available for the tokens you requested."
// );
// }
//
// const transaction = await createSwapFromSuiMoveCalls(
// quotes[0],
// walletClient.getAddress(),
// params.dstAddr,
// null,
// null,
// walletClient.getClient()
// );
// const { hash } = await walletClient.sendTransaction({ transaction });
//
// return `https://explorer.mayan.finance/swap/${hash}`;
//}

private async findTokenContract(symbol: string, chain: string): Promise<string> {
const tokens = await fetchTokenList(chain as ChainName, true);
const token = tokens.find((t) => t.symbol.toLowerCase() === symbol.toLowerCase());
if (!token) {
throw new Error(`Couldn't find token with ${symbol} symbol`);
}

return token.contract;
}

private async callERC20(
walletClient: EVMWalletClient,
contract: string,
functionName: string,
args?: unknown[],
): Promise<unknown> {
const ret = await walletClient.read({
address: contract,
abi: ERC20_ABI,
functionName,
args,
});
return ret.value;
}

private async getERC20Permit(walletClient: EVMWalletClient, quote: Quote, amountIn: bigint): Promise<Erc20Permit> {
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const spender = addresses.MAYAN_FORWARDER_CONTRACT;
const walletSrcAddr = walletClient.getAddress();
const nonce = (await this.callERC20(walletClient, quote.fromToken.contract, "nonces", [
walletSrcAddr,
])) as bigint;
const name = (await this.callERC20(walletClient, quote.fromToken.contract, "name")) as string;

const domain: TypedDataDomain = {
name,
version: "1",
chainId: quote.fromToken.chainId,
verifyingContract: quote.fromToken.contract as `0x${string}`,
};
const domainSeparator = (await this.callERC20(
walletClient,
quote.fromToken.contract,
"DOMAIN_SEPARATOR",
)) as string;
for (let i = 1; i < 11; i++) {
domain.version = String(i);
const hash = TypedDataEncoder.hashDomain(domain);
if (hash.toLowerCase() === domainSeparator.toLowerCase()) {
break;
}
}

const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const value = {
owner: walletSrcAddr,
spender,
value: amountIn,
nonce,
deadline,
};

const { signature } = await walletClient.signTypedData({
domain,
types,
primaryType: "Permit",
message: value,
});
const { v, r, s } = Signature.from(signature);
await this.callERC20(walletClient, quote.fromToken.contract, "permit", [
walletSrcAddr,
spender,
amountIn,
deadline,
v,
r,
s,
]);

return {
value: amountIn,
deadline,
v,
r,
s,
};
}
}

function hasSignatureProperty(error: unknown): error is { signature?: string } {
return typeof error === "object" && error !== null && "signature" in error;
}
Loading

0 comments on commit f68960d

Please sign in to comment.