azeth_execute_agreement
Execute due payments from on-chain agreements for recurring services, allowing payers, payees, or third-party keepers to trigger validated transactions with pro-rata accrual.
Instructions
Execute a due payment from an on-chain agreement. Anyone can call this — the payer, payee, or a third-party keeper.
Use this when: You are a service provider collecting a recurring payment owed to you, a payer triggering your own agreement manually, or a keeper bot executing due agreements.
Keeper support: When the "account" is a foreign address (not owned by your private key), execution routes through your own account or EOA automatically. No special configuration needed.
The contract validates all conditions on-chain: interval elapsed, active, within caps and limits. Pro-rata accrual means the payout scales with elapsed time (capped at 3x the interval).
Returns: Transaction hash, amount paid, execution count, and next execution time. If the agreement soft-fails (insufficient balance, guardian limit), it returns the failure reason without reverting.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| chain | No | Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet"). | |
| account | Yes | The payer smart account whose agreement to execute: Ethereum address, participant name, "me", or "#N". | |
| agreementId | Yes | The agreement ID to execute (from azeth_create_payment_agreement or azeth_list_agreements). |
Implementation Reference
- src/tools/agreements.ts:177-411 (handler)Handler implementation for azeth_execute_agreement. It resolves the payer account, validates agreement executability using pre-flight contract checks, performs optional balance checks, executes the agreement transaction, and parses event logs to return detailed execution results.
async (args) => { let client; try { client = await createClient(args.chain); const chain = resolveChain(args.chain); // Resolve the payer account let accountResolved; try { accountResolved = await resolveAddress(args.account, client); } catch (resolveErr) { return handleError(resolveErr); } const account = accountResolved.address; const agreementId = BigInt(args.agreementId); // Pre-flight check: call canExecutePayment before submitting a transaction let canExec: { executable: boolean; reason: string }; try { canExec = await client.canExecutePayment(agreementId, account); } catch { return error('INVALID_INPUT', `Agreement #${args.agreementId} not found for account ${account}.`, 'Check the agreement ID with azeth_list_agreements.'); } if (!canExec.executable) { // Map reason strings to appropriate error codes and messages const reason = canExec.reason.toLowerCase(); if (reason.includes('not initialized') || reason.includes('not found') || reason.includes('agreement not exists')) { return error('INVALID_INPUT', `Agreement #${args.agreementId} not found for account ${account}.`, 'Check the agreement ID with azeth_list_agreements.'); } if (reason.includes('not active')) { // Get agreement to provide more context try { const agreement = await client.getAgreement(agreementId, account); const now = BigInt(Math.floor(Date.now() / 1000)); const status = deriveStatus(agreement, now); const decimals = tokenDecimals(agreement.token, chain); const totalPaid = formatUnits(agreement.totalPaid, decimals); const tokenSymbol = resolveTokenSymbol(agreement.token, chain); return error('INVALID_INPUT', `Agreement #${args.agreementId} is ${status}. Total paid: ${totalPaid} ${tokenSymbol}.`); } catch { return error('INVALID_INPUT', `Agreement #${args.agreementId} is not active.`); } } if (reason.includes('interval not elapsed')) { try { const nextTime = await client.getNextExecutionTime(agreementId, account); const nextDate = new Date(Number(nextTime) * 1000).toISOString(); const now = Math.floor(Date.now() / 1000); const countdown = formatCountdown(Number(nextTime) - now); return error('INVALID_INPUT', `Agreement #${args.agreementId} is not due yet. Next execution: ${nextDate} (${countdown}).`); } catch { return error('INVALID_INPUT', `Agreement #${args.agreementId} is not due yet.`); } } if (reason.includes('max executions')) { return error('INVALID_INPUT', `Agreement #${args.agreementId} has reached maximum executions.`); } if (reason.includes('total cap')) { return error('INVALID_INPUT', `Agreement #${args.agreementId} has reached its total payment cap.`); } if (reason.includes('token not whitelisted')) { return error('GUARDIAN_REJECTED', `Agreement #${args.agreementId} cannot execute: token not whitelisted by guardian.`, 'Add the token to the guardian whitelist.'); } if (reason.includes('exceeds max tx') || reason.includes('max tx amount')) { return error('GUARDIAN_REJECTED', `Agreement #${args.agreementId} cannot execute: payment exceeds per-transaction limit.`, 'Increase the guardian per-tx limit or reduce the agreement amount.'); } if (reason.includes('daily spend') || reason.includes('daily limit')) { return error('GUARDIAN_REJECTED', `Agreement #${args.agreementId} cannot execute: daily spend limit exceeded.`, 'Wait until tomorrow or increase the daily limit via guardian.'); } if (reason.includes('insufficient balance') || reason.includes('balance')) { return error('INSUFFICIENT_BALANCE', `Agreement #${args.agreementId} cannot execute: insufficient balance.`, 'Fund the payer account before retrying.'); } // Generic fallback return error('INVALID_INPUT', `Agreement #${args.agreementId} cannot execute: ${canExec.reason}.`); } // ── Balance pre-check (non-fatal: proceed if check fails) ── const accountAddr = account; const agreementIdNum = agreementId; try { const agreement = await client.getAgreement(agreementIdNum, accountAddr); if (agreement) { const token = agreement.token; const amount = agreement.amount; if (token && amount) { const ETH_ZERO = '0x0000000000000000000000000000000000000000' as `0x${string}`; let balance: bigint; if (token === ETH_ZERO) { balance = await client.publicClient.getBalance({ address: accountAddr }); } else { const erc20Abi = [{ type: 'function' as const, name: 'balanceOf', inputs: [{ name: 'account', type: 'address' }], outputs: [{ name: '', type: 'uint256' }], stateMutability: 'view' as const, }] as const; balance = await client.publicClient.readContract({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [accountAddr], }) as bigint; } if (balance < amount) { const decimals = tokenDecimals(token, chain); return error( 'INSUFFICIENT_BALANCE', `Account ${accountAddr} has insufficient balance to execute agreement #${args.agreementId}. ` + `Balance: ${formatUnits(balance, decimals)}, minimum needed: ${formatUnits(amount, decimals)}`, 'Deposit more funds into the smart account with azeth_deposit.', ); } } } } catch { // Non-fatal: if balance check fails, proceed and let the contract validate } // Capture pre-execution totalPaid as fallback for delta calculation let preExecutionTotal = 0n; try { const preExecAgreement = await client.getAgreement(agreementId, account); preExecutionTotal = preExecAgreement.totalPaid; } catch { // Non-fatal: delta fallback won't be available } // Execute the agreement const txHash = await client.executeAgreement(agreementId, account); // Primary: parse PaymentExecuted event from receipt for exact amount paid let executionAmount = 0n; try { const receipt = await client.publicClient.waitForTransactionReceipt({ hash: txHash, timeout: 120_000 }); for (const log of receipt.logs) { try { const decoded = decodeEventLog({ abi: PaymentAgreementModuleAbi, data: log.data, topics: log.topics, }); if (decoded.eventName === 'PaymentExecuted') { const eventArgs = decoded.args as { agreementId: bigint; amount: bigint }; if (eventArgs.agreementId === agreementId) { executionAmount = eventArgs.amount; break; } } } catch { // Not a PaymentExecuted event from this ABI — skip } } } catch { // Receipt fetch failed — fall back to delta approach below } // Enrich response with post-execution state const agreement = await client.getAgreement(agreementId, account); const decimals = tokenDecimals(agreement.token, chain); const tokenSymbol = resolveTokenSymbol(agreement.token, chain); const now = BigInt(Math.floor(Date.now() / 1000)); const status = deriveStatus(agreement, now); // Fallback: if event parsing didn't yield an amount, use delta approach if (executionAmount === 0n) { executionAmount = agreement.totalPaid - preExecutionTotal; } let nextExecutionTime: string; let nextExecutionIn: string; if (status !== 'active') { nextExecutionTime = 'completed'; nextExecutionIn = 'N/A (completed)'; } else { try { const nextTime = await client.getNextExecutionTime(agreementId, account); nextExecutionTime = new Date(Number(nextTime) * 1000).toISOString(); const nowSecs = Math.floor(Date.now() / 1000); nextExecutionIn = formatCountdown(Number(nextTime) - nowSecs); } catch { nextExecutionTime = 'unknown'; nextExecutionIn = 'unknown'; } } // Attempt payee name resolution const payeeName = await lookupPayeeName(client, agreement.payee); // USD conversion for stablecoins const amountPaidUSD = tokenAmountToUSD(executionAmount, agreement.token, chain); const totalPaidUSD = tokenAmountToUSD(agreement.totalPaid, agreement.token, chain); return success( { account, agreementId: args.agreementId.toString(), payee: agreement.payee, ...(payeeName ? { payeeName } : {}), token: agreement.token, tokenSymbol, amountPaid: formatUnits(executionAmount, decimals), ...(amountPaidUSD ? { amountPaidUSD } : {}), executionCount: agreement.executionCount.toString(), maxExecutions: agreement.maxExecutions === 0n ? 'unlimited' : agreement.maxExecutions.toString(), totalPaid: formatUnits(agreement.totalPaid, decimals), ...(totalPaidUSD ? { totalPaidUSD } : {}), totalCap: agreement.totalCap === 0n ? 'unlimited' : formatUnits(agreement.totalCap, decimals), remainingBudget: agreement.totalCap === 0n ? 'unlimited' : formatUnits(agreement.totalCap - agreement.totalPaid, decimals), nextExecutionTime, nextExecutionIn, active: agreement.active, }, { txHash }, ); } catch (err) { if (err instanceof Error && /AA24/.test(err.message)) { return guardianRequiredError( 'Agreement execution exceeds your standard spending limit.', { operation: 'execute_agreement' }, ); } return handleError(err); } finally { try { await client?.destroy(); } catch (e) { process.stderr.write(`[azeth-mcp] destroy error: ${e instanceof Error ? e.message : String(e)}\n`); } } }, ); - src/tools/agreements.ts:153-176 (registration)Registration of the 'azeth_execute_agreement' tool, including its description and input schema definition.
server.registerTool( 'azeth_execute_agreement', { description: [ 'Execute a due payment from an on-chain agreement. Anyone can call this — the payer, payee, or a third-party keeper.', '', 'Use this when: You are a service provider collecting a recurring payment owed to you,', 'a payer triggering your own agreement manually, or a keeper bot executing due agreements.', '', 'Keeper support: When the "account" is a foreign address (not owned by your private key),', 'execution routes through your own account or EOA automatically. No special configuration needed.', '', 'The contract validates all conditions on-chain: interval elapsed, active, within caps and limits.', 'Pro-rata accrual means the payout scales with elapsed time (capped at 3x the interval).', '', 'Returns: Transaction hash, amount paid, execution count, and next execution time.', 'If the agreement soft-fails (insufficient balance, guardian limit), it returns the failure reason without reverting.', ].join('\n'), inputSchema: z.object({ chain: z.string().optional().describe('Target chain. Defaults to AZETH_CHAIN env var or "baseSepolia". Accepts "base", "baseSepolia", "ethereumSepolia", "ethereum" (and aliases like "base-sepolia", "eth-sepolia", "sepolia", "eth", "mainnet").'), account: z.string().describe('The payer smart account whose agreement to execute: Ethereum address, participant name, "me", or "#N".'), agreementId: z.coerce.number().int().min(0).describe('The agreement ID to execute (from azeth_create_payment_agreement or azeth_list_agreements).'), }), },