Skip to content
Merged
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
37 changes: 32 additions & 5 deletions src/tools/mempool/estimate-fee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@ const SPEND_TYPES = [
'create_new_did_wallet',
] as const;

// Approximate CLVM cost of one standard XCH spend.
const DEFAULT_SPEND_COST = 11_000_000;

// coinset's get_fee_estimate accepts only `cost` + `target_times` (it rejects `spend_type`),
// so a spend_type hint is translated into an approximate cost here. These are rough multiples
// of one standard spend, meant only to bias the estimate — pass an explicit `cost` for precision.
const SPEND_TYPE_COST: Record<(typeof SPEND_TYPES)[number], number> = {
send_xch_transaction: DEFAULT_SPEND_COST,
cat_spend: DEFAULT_SPEND_COST * 3,
// chia.net's Jan-2024 fees post puts offer-take cost at 80-150M; *4 (44M)
// under-shoots by ~2-3x, which would translate directly to an underestimated fee.
take_offer: DEFAULT_SPEND_COST * 9,
cancel_offer: DEFAULT_SPEND_COST * 2,
nft_set_nft_did: DEFAULT_SPEND_COST * 5,
nft_transfer_nft: DEFAULT_SPEND_COST * 5,
create_new_pool_wallet: DEFAULT_SPEND_COST * 5,
pw_absorb_rewards: DEFAULT_SPEND_COST * 4,
create_new_did_wallet: DEFAULT_SPEND_COST * 4,
};

export function register(server: McpServer): void {
server.tool(
'estimate_fee',
Expand Down Expand Up @@ -51,11 +71,18 @@ export function register(server: McpServer): void {
async ({ target_times, cost, spend_type, spend_count, network }) => {
try {
const agent = getAgent(network as Network);
const payload: Parameters<typeof get_fee_estimate>[1] = { target_times };
if (cost !== undefined) payload.cost = cost;
if (spend_type !== undefined) payload.spend_type = spend_type;
if (spend_count !== undefined) payload.spend_count = spend_count;
const res = await get_fee_estimate(agent, payload);
// coinset's get_fee_estimate accepts only `cost` + `target_times`. Resolve to a cost:
// an explicit `cost`, else the spend_type's approximate cost (or a standard spend)
// scaled by spend_count. This avoids the "missing field cost" / "unknown field
// spend_type" errors coinset returns for the raw payload.
let effectiveCost: number;
if (cost !== undefined) {
effectiveCost = cost;
} else {
const perSpend = spend_type ? SPEND_TYPE_COST[spend_type] : DEFAULT_SPEND_COST;
effectiveCost = perSpend * (spend_count ?? 1);
}
const res = await get_fee_estimate(agent, { target_times, cost: effectiveCost });

const estimates_mojo = (res.estimates ?? []).map((e) => toBigInt(e).toString());
const estimates_xch = estimates_mojo.map((m) => mojoToXch(m));
Expand Down
58 changes: 48 additions & 10 deletions tests/tools.mempool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe('mempool tools (mocked RPC)', () => {
});

describe('estimate_fee', () => {
it('passes default target_times [60,300,900] when none supplied', async () => {
it('defaults target_times and a cost when none supplied', async () => {
mocks.get_fee_estimate.mockResolvedValue({
estimates: [0, 1000, 5000],
target_times: [60, 300, 900],
Expand All @@ -181,12 +181,15 @@ describe('mempool tools (mocked RPC)', () => {
expect(body.target_times).toEqual([60, 300, 900]);
expect(body.estimates_mojo).toEqual(['0', '1000', '5000']);
expect(body.estimates_xch[0]).toBe('0');
// coinset requires `cost`; with no args the tool defaults one (~ a standard XCH
// send) so a bare call doesn't error with "missing field cost".
expect(mocks.get_fee_estimate).toHaveBeenCalledWith(expect.anything(), {
target_times: [60, 300, 900],
cost: 11_000_000,
});
});

it('forwards spend_type, spend_count and cost when supplied', async () => {
it('uses an explicit cost as-is (no spend_type reaches coinset)', async () => {
mocks.get_fee_estimate.mockResolvedValue({
estimates: [0],
target_times: [60],
Expand All @@ -198,18 +201,32 @@ describe('mempool tools (mocked RPC)', () => {
});
await client.callTool({
name: 'estimate_fee',
arguments: {
target_times: [60],
cost: 1_000_000,
spend_type: 'take_offer',
spend_count: 2,
},
arguments: { target_times: [60], cost: 1_000_000 },
});
// Only `cost` + `target_times` reach the RPC: coinset rejects spend_type/spend_count.
expect(mocks.get_fee_estimate).toHaveBeenCalledWith(expect.anything(), {
target_times: [60],
cost: 1_000_000,
spend_type: 'take_offer',
spend_count: 2,
});
});

it('scales the default cost by spend_count when no explicit cost is given', async () => {
mocks.get_fee_estimate.mockResolvedValue({
estimates: [0],
target_times: [60],
current_fee_rate: 0,
mempool_size: 0,
mempool_fees: 0,
full_node_synced: true,
peak_height: 1,
});
await client.callTool({
name: 'estimate_fee',
arguments: { target_times: [60], spend_count: 2 },
});
expect(mocks.get_fee_estimate).toHaveBeenCalledWith(expect.anything(), {
target_times: [60],
cost: 22_000_000,
});
});

Expand All @@ -229,6 +246,27 @@ describe('mempool tools (mocked RPC)', () => {
expect(res.isError).toBe(true);
});

it('maps spend_type to an approximate cost', async () => {
mocks.get_fee_estimate.mockResolvedValue({
estimates: [0],
target_times: [60],
current_fee_rate: 0,
mempool_size: 0,
mempool_fees: 0,
full_node_synced: true,
peak_height: 1,
});
// cat_spend ~ 3x a standard spend (11_000_000); only the resolved cost reaches coinset.
await client.callTool({
name: 'estimate_fee',
arguments: { target_times: [60], spend_type: 'cat_spend' },
});
expect(mocks.get_fee_estimate).toHaveBeenCalledWith(expect.anything(), {
target_times: [60],
cost: 33_000_000,
});
});

it('rejects an unknown spend_type', async () => {
const res = (await client.callTool({
name: 'estimate_fee',
Expand Down