Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cosmos plugin #1393

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,11 @@ STORY_PRIVATE_KEY= # Story private key
STORY_API_BASE_URL= # Story API base URL
STORY_API_KEY= # Story API key
PINATA_JWT= # Pinata JWT for uploading files to IPFS

# Cosmos based networks
COSMOS_MNEMONIC= # Mnemonic to generate cosmos accounts
COSMOS_CHAIN_NAME= # chainName matching with chain-registry entries here: https://github.com/cosmos/chain-registry
COSMOS_RPC_URL= # (optional) rpc url for the chain. ex: https://rpc.osmosis.zone:443
COSMOS_CHAIN_DENOM= # (optional) the base token denom
COSMOS_CHAIN_DECIMALS=6 # (optional) the decimals for token actions. default is 6
COSMOS_COINGECKO_ID=osmosis # the coingecko id of the token
6 changes: 6 additions & 0 deletions packages/plugin-cosmos/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*

!dist/**
!package.json
!readme.md
!tsup.config.ts
3 changes: 3 additions & 0 deletions packages/plugin-cosmos/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import eslintGlobalConfig from "../../eslint.config.mjs";

export default [...eslintGlobalConfig];
29 changes: 29 additions & 0 deletions packages/plugin-cosmos/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@ai16z/plugin-cosmos",
"version": "0.1.0-alpha.1",
"main": "dist/index.js",
"type": "module",
"types": "dist/index.d.ts",
"dependencies": {
"@ai16z/eliza": "workspace:*",
"bignumber.js": "9.1.2",
"node-cache": "5.1.2",
"tsup": "8.3.5",
"@cosmjs/stargate": "^0.29.2",
"@cosmjs/amino": "^0.29.2",
"cosmjs-utils": "*",
"osmojs": "16.15.0",
"chain-registry": "*",
"vitest": "^0.34.1"
},
"scripts": {
"build": "tsup --format esm,cjs --dts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint . --fix"
},
"peerDependencies": {
"whatwg-url": "7.1.0",
"form-data": "4.0.1"
}
}
290 changes: 290 additions & 0 deletions packages/plugin-cosmos/src/actions/transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import {
ActionExample,
Content,
HandlerCallback,
IAgentRuntime,
Memory,
ModelClass,
State,
type Action,
composeContext,
generateObject,
} from "@ai16z/eliza";
import { chains } from "chain-registry";
import { getOfflineSignerProto as getOfflineSigner } from "cosmjs-utils";
import { SigningStargateClient } from "@cosmjs/stargate";
import { coins, StdFee } from "@cosmjs/amino";

export interface TransferContent extends Content {
recipient: string;
amount: string | number;
tokenAddress?: string; // optional if we want to handle cw20 or other tokens
}

function isTransferContent(
runtime: IAgentRuntime,
content: any
): content is TransferContent {
return (
typeof content.recipient === "string" &&
(typeof content.amount === "string" || typeof content.amount === "number")
);
}

const transferTemplate = `Respond with a JSON markdown block containing only the extracted values. Use null for any values that cannot be determined.

Example response:
\`\`\`json
{
"recipient": "osmo1abcd1234...",
"amount": "1.5",
"tokenAddress": null
}
\`\`\`

{{recentMessages}}

Given the recent messages and wallet information below:

{{walletInfo}}

Extract the following information about the requested token transfer:
- Recipient address
- Amount to transfer
- Token contract address (null for native transfers)

Respond with a JSON markdown block containing only the extracted values.
`;

/**
* Quickly checks if an RPC endpoint is reachable by fetching /status.
* Return true if ok, false if not.
*/
async function canGetStatus(rpcUrl: string): Promise<boolean> {
try {
const url = rpcUrl.endsWith("/") ? rpcUrl + "status" : `${rpcUrl}/status`;
const response = await fetch(url, { method: "GET" });
if (!response.ok) {
throw new Error(`RPC /status responded with HTTP ${response.status}`);
}
return true;
} catch {
return false;
}
}

async function getWorkingRpcUrl(rpcUrls: string[]): Promise<string | null> {
for (const url of rpcUrls) {
if (await canGetStatus(url)) {
return url;
}
}
return null;
}

/**
* Transfer tokens, preferring env-based RPC/DENOM/DECIMALS, else chain-registry.
*/
async function transferTokens(
runtime: IAgentRuntime,
recipient: string,
amount: string
): Promise<string> {
// 1) Identify chain + mnemonic
const chainName = runtime.getSetting("COSMOS_CHAIN_NAME") || "osmosis";
const mnemonic = runtime.getSetting("COSMOS_MNEMONIC");
if (!mnemonic) {
throw new Error("COSMOS_MNEMONIC not configured");
}

// 2) Lookup chain in registry
const chain = chains.find((c) => c.chain_name === chainName);
if (!chain) {
throw new Error(`Chain '${chainName}' not found in chain-registry`);
}

// 3) Build a candidate RPC list
// First, check env-based RPC
const candidateRpcs: string[] = [];
const envRpc = runtime.getSetting("COSMOS_RPC_URL");
if (envRpc) {
candidateRpcs.push(envRpc);
}
// Then add chain-registry RPC endpoints
const registryRpcs = chain.apis?.rpc?.map((r) => r.address) ?? [];
candidateRpcs.push(...registryRpcs);

// 4) Find a working RPC by checking /status
const workingRpc = await getWorkingRpcUrl(candidateRpcs);
if (!workingRpc) {
throw new Error(`No working RPC endpoint found for '${chainName}'`);
}

// 5) Determine denom & decimals
// - If env is set, prefer that
// - else fallback to chain.fees
const chainFees = chain.fees?.fee_tokens?.[0];
const envDenom = runtime.getSetting("COSMOS_DENOM");
const envDecimals = runtime.getSetting("COSMOS_DECIMALS");

const defaultDenom = chainFees?.denom || "uosmo";
const denom = envDenom || defaultDenom;
const decimals = envDecimals ? Number(envDecimals) : 6; // or read from chain data

// average gas price
const averageGasPrice = chainFees?.average_gas_price ?? 0.025;

// 6) Create offline signer
const signer = await getOfflineSigner({
mnemonic,
chain,
});

// 7) Connect Stargate client w/ signer
const stargateClient = await SigningStargateClient.connectWithSigner(
workingRpc,
signer
);

// 8) Build the transaction
const [fromAccount] = await signer.getAccounts();
const fromAddress = fromAccount.address;
const shift = 10 ** decimals;
const sendAmount = String(Math.floor(Number(amount) * shift));

const msg = {
typeUrl: "/cosmos.bank.v1beta1.MsgSend",
value: {
fromAddress,
toAddress: recipient,
amount: coins(sendAmount, denom),
},
};
const messages = [msg];
const memo = "";

// 9) Estimate gas usage
const gasEstimated = await stargateClient.simulate(fromAddress, messages, memo);
const feeAmount = Math.floor(gasEstimated * averageGasPrice).toString();

const fee: StdFee = {
amount: coins(feeAmount, denom),
gas: gasEstimated.toString(),
};

// 10) Sign & broadcast
const result = await stargateClient.signAndBroadcast(
fromAddress,
messages,
fee,
memo
);

return result.transactionHash;
}

export const executeTransfer: Action = {
name: "SEND_COSMOS",
similes: ["TRANSFER_COSMOS", "SEND_TOKENS", "TRANSFER_TOKENS", "PAY_COSMOS"],
validate: async (_runtime: IAgentRuntime, _message: Memory) => {
// Add your validation logic if needed
return true;
},
description: "Transfer native Cosmos tokens to another address",
handler: async (
runtime: IAgentRuntime,
message: Memory,
state: State,
_options: { [key: string]: unknown },
callback?: HandlerCallback
): Promise<boolean> => {
// 1) Ensure up-to-date state
if (!state) {
state = (await runtime.composeState(message)) as State;
} else {
state = await runtime.updateRecentMessageState(state);
}

// 2) Compose transfer context
const transferContext = composeContext({
state,
template: transferTemplate,
});

// 3) Generate JSON from user conversation
const content = await generateObject({
runtime,
context: transferContext,
modelClass: ModelClass.SMALL,
});

// 4) Validate
if (!isTransferContent(runtime, content)) {
console.error("Invalid content for SEND_COSMOS action.");
if (callback) {
callback({
text: "Unable to process transfer request. Invalid content provided.",
content: { error: "Invalid transfer content" },
});
}
return false;
}

try {
// 5) Transfer
const txHash = await transferTokens(
runtime,
content.recipient,
content.amount.toString()
);

// 6) If successful
if (callback) {
callback({
text: `Successfully transferred ${content.amount} tokens to ${content.recipient}\nTransaction: ${txHash}`,
content: {
success: true,
signature: txHash,
amount: content.amount,
recipient: content.recipient,
},
});
}
return true;
} catch (error) {
console.error("Error during Cosmos transfer:", error);
if (callback) {
callback({
text: `Error transferring tokens: ${error}`,
content: { error },
});
}
return false;
}
},

// 7) Example usage
examples: [
[
{
user: "{{user1}}",
content: {
text: "Send 1.5 tokens to osmo1abcd1234...",
},
},
{
user: "{{user2}}",
content: {
text: "I'll send 1.5 OSMO now...",
action: "SEND_COSMOS",
},
},
{
user: "{{user2}}",
content: {
text: "Successfully sent 1.5 OSMO to osmo1abcd1234...\nTransaction: ABC123XYZ",
},
},
],
] as ActionExample[][],
} as Action;
53 changes: 53 additions & 0 deletions packages/plugin-cosmos/src/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { IAgentRuntime } from "@elizaos/core";
import { z } from "zod";

/**
* Example environment variables for Cosmos
* that mimic the NEAR example structure
*/
export const cosmosEnvSchema = z.object({
COSMOS_MNEMONIC: z.string().min(1, "Cosmos wallet mnemonic is required"),
COSMOS_CHAIN_NAME: z.string().default("osmosis"),
COSMOS_RPC_URL: z.string().default("https://rpc.osmosis.zone"),
COSMOS_DENOM: z.string().default("uosmo"),
COSMOS_DECIMALS: z.string().default("6"),
});

/**
* Type for the validated config
*/
export type CosmosConfig = z.infer<typeof cosmosEnvSchema>;

/**
* Simple config loader that merges runtime settings with environment variables
*/
export async function validateCosmosConfig(
runtime: IAgentRuntime
): Promise<CosmosConfig> {
try {
const config = {
COSMOS_MNEMONIC:
runtime.getSetting("COSMOS_MNEMONIC") || process.env.COSMOS_MNEMONIC,
COSMOS_CHAIN_NAME:
runtime.getSetting("COSMOS_CHAIN_NAME") || process.env.COSMOS_CHAIN_NAME,
COSMOS_RPC_URL:
runtime.getSetting("COSMOS_RPC_URL") || process.env.COSMOS_RPC_URL,
COSMOS_DENOM:
runtime.getSetting("COSMOS_DENOM") || process.env.COSMOS_DENOM,
COSMOS_DECIMALS:
runtime.getSetting("COSMOS_DECIMALS") || process.env.COSMOS_DECIMALS,
};

return cosmosEnvSchema.parse(config);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.errors
.map((err) => `${err.path.join(".")}: ${err.message}`)
.join("\n");
throw new Error(
`Cosmos configuration validation failed:\n${errorMessages}`
);
}
throw error;
}
}
Loading
Loading