diff --git a/cli/commands/tributary/create.js b/cli/commands/tributary/create.js new file mode 100644 index 0000000..9e7c63e --- /dev/null +++ b/cli/commands/tributary/create.js @@ -0,0 +1,129 @@ +import BN from "bn.js"; +import { PublicKey } from "@solana/web3.js"; +import { + getTributarySdk, + sendTributaryInstructions, +} from "../../utils/tributary/sdk.js"; +import { + USDC_MINT, + DEFAULT_GATEWAY, + FREQUENCY_MAP, + USDC_DECIMALS, +} from "../../utils/tributary/constants.js"; +import { encodeMemo } from "../../utils/tributary/format.js"; +import { resolveWallet } from "../../utils/wallet/resolve.js"; +import { print, printError } from "../../utils/common/output.js"; +import { requireAgentToken } from "../../utils/trading/guards.js"; + +export default async function subscribeCreate(args, flags) { + const [amountStr, recipientStr] = args; + const interval = flags.interval; + + if (!amountStr || !recipientStr || !interval) { + printError( + "missing_args", + "Usage: zerion subscribe create --interval ", + { + example: "zerion subscribe create 10 2Nsnn... --interval monthly", + }, + ); + process.exit(1); + } + + const amount = parseFloat(amountStr); + if (Number.isNaN(amount) || amount <= 0) { + printError( + "invalid_amount", + `Amount must be a positive number, got "${amountStr}"`, + ); + process.exit(1); + } + + const frequency = FREQUENCY_MAP[interval]; + if (!frequency) { + printError("invalid_interval", `Unknown interval "${interval}"`, { + suggestion: `Valid: ${Object.keys(FREQUENCY_MAP).join(", ")}`, + }); + process.exit(1); + } + + const { walletName, address } = resolveWallet({ + ...flags, + chain: "solana", + }); + + const tokenMint = flags.token || USDC_MINT; + const gateway = flags.gateway || DEFAULT_GATEWAY; + const autoRenew = !flags["no-auto-renew"]; + const maxRenewals = flags["max-renewals"] + ? parseInt(flags["max-renewals"]) + : null; + const memo = encodeMemo(flags.memo, 64); + + const amountSmallest = new BN( + Math.round(amount * Math.pow(10, USDC_DECIMALS)), + ); + + const passphrase = await requireAgentToken( + "for Tributary subscription", + walletName, + ); + + const sdk = await getTributarySdk(address); + + try { + const instructions = await sdk.createSubscription( + new PublicKey(tokenMint), + new PublicKey(recipientStr), + new PublicKey(gateway), + amountSmallest, + autoRenew, + maxRenewals, + frequency, + memo, + ); + + if (flags["dry-run"]) { + print({ + dryRun: true, + instructionCount: instructions.length, + amount: amountStr, + recipient: recipientStr, + interval, + token: tokenMint, + gateway, + }); + return; + } + + const txResult = await sendTributaryInstructions( + instructions, + address, + walletName, + passphrase, + ); + + print({ + subscription: { + amount: amountSmallest.toString(), + amountHuman: `${amount} USDC`, + recipient: recipientStr, + gateway, + token: tokenMint, + interval, + autoRenew, + maxRenewals, + }, + transaction: { + hash: txResult.hash, + status: txResult.status, + }, + }); + } catch (err) { + printError( + err.code || "subscription_error", + `Failed to create subscription: ${err.message}`, + ); + process.exit(1); + } +} diff --git a/cli/commands/tributary/delete.js b/cli/commands/tributary/delete.js new file mode 100644 index 0000000..fe28b9c --- /dev/null +++ b/cli/commands/tributary/delete.js @@ -0,0 +1,72 @@ +import { PublicKey } from "@solana/web3.js"; +import { + getTributarySdk, + sendTributaryInstructions, +} from "../../utils/tributary/sdk.js"; +import { USDC_MINT } from "../../utils/tributary/constants.js"; +import { resolveWallet } from "../../utils/wallet/resolve.js"; +import { print, printError } from "../../utils/common/output.js"; +import { requireAgentToken } from "../../utils/trading/guards.js"; + +export default async function subscribeDelete(args, flags) { + const [policyIdStr] = args; + + if (!policyIdStr) { + printError("missing_args", "Usage: zerion subscribe delete ", { + example: "zerion subscribe delete 1 --token ", + }); + process.exit(1); + } + + const policyId = parseInt(policyIdStr, 10); + if (Number.isNaN(policyId) || policyId < 1) { + printError( + "invalid_policy_id", + `Policy ID must be a positive integer, got "${policyIdStr}"`, + ); + process.exit(1); + } + + const { walletName, address } = resolveWallet({ + ...flags, + chain: "solana", + }); + + const tokenMint = flags.token || USDC_MINT; + const passphrase = await requireAgentToken( + "for Tributary subscription", + walletName, + ); + + try { + const sdk = await getTributarySdk(address); + + const instructions = await sdk.deletePaymentPolicy( + new PublicKey(tokenMint), + policyId, + ); + + const txResult = await sendTributaryInstructions( + instructions, + address, + walletName, + passphrase, + ); + + print({ + action: "delete", + policyId, + token: tokenMint, + transaction: { + hash: txResult.hash, + status: txResult.status, + }, + }); + } catch (err) { + printError( + err.code || "delete_error", + `Failed to delete policy ${policyId}: ${err.message}`, + ); + process.exit(1); + } +} diff --git a/cli/commands/tributary/list.js b/cli/commands/tributary/list.js new file mode 100644 index 0000000..a6304e4 --- /dev/null +++ b/cli/commands/tributary/list.js @@ -0,0 +1,85 @@ +import { PublicKey } from "@solana/web3.js"; +import { getTributarySdk } from "../../utils/tributary/sdk.js"; +import { USDC_MINT, USDC_DECIMALS } from "../../utils/tributary/constants.js"; +import { resolveWallet } from "../../utils/wallet/resolve.js"; +import { print, printError } from "../../utils/common/output.js"; +import { + decodePolicyType, + decodeStatus, + decodeMemo, + formatAmount, + formatPrettyTable, +} from "../../utils/tributary/format.js"; +import { isPrettyMode } from "../../utils/common/output.js"; + +export default async function subscribeList(args, flags) { + const { walletName, address } = resolveWallet({ + ...flags, + chain: "solana", + }); + + const statusFilter = flags.status || "all"; + const tokenMints = [USDC_MINT]; + const policies = []; + + try { + const sdk = await getTributarySdk(address); + + for (const mint of tokenMints) { + const userPaymentPda = sdk.getUserPaymentPda( + new PublicKey(address), + new PublicKey(mint), + ); + const userPolicies = await sdk.getPaymentPoliciesByUserPayment( + userPaymentPda.address, + ); + + for (const p of userPolicies) { + const status = decodeStatus(p.account.status); + if (statusFilter !== "all" && status !== statusFilter) continue; + policies.push(p); + } + } + + if (flags.pretty || isPrettyMode()) { + process.stdout.write(formatPrettyTable(address, policies)); + } else { + print({ + wallet: address, + count: policies.length, + policies: policies.map((p) => { + const decoded = decodePolicyType(p.account.policyType); + const result = { + policyId: p.account.policyId, + policyPda: p.publicKey.toString(), + ...decoded, + status: decodeStatus(p.account.status), + recipient: p.account.recipient.toString(), + gateway: p.account.gateway.toString(), + }; + if (decoded.type === "subscription") { + result.amount = `${formatAmount(decoded.amount, USDC_DECIMALS)} USDC`; + result.nextPaymentDue = decoded.nextPaymentDue + ? new Date(decoded.nextPaymentDue * 1000).toISOString() + : null; + } + const memo = decodeMemo(p.account.memo); + if (memo) result.memo = memo; + if (p.account.createdAt) { + result.createdAt = new Date( + p.account.createdAt.toNumber() * 1000, + ).toISOString(); + } + return result; + }), + }); + } + } catch (err) { + console.trace(err) + printError( + err.code || "list_error", + `Failed to list subscriptions: ${err.message}`, + ); + process.exit(1); + } +} diff --git a/cli/commands/tributary/pause.js b/cli/commands/tributary/pause.js new file mode 100644 index 0000000..98c23c4 --- /dev/null +++ b/cli/commands/tributary/pause.js @@ -0,0 +1,73 @@ +import { PublicKey } from "@solana/web3.js"; +import { + getTributarySdk, + sendTributaryInstructions, +} from "../../utils/tributary/sdk.js"; +import { USDC_MINT } from "../../utils/tributary/constants.js"; +import { resolveWallet } from "../../utils/wallet/resolve.js"; +import { print, printError } from "../../utils/common/output.js"; +import { requireAgentToken } from "../../utils/trading/guards.js"; + +export default async function subscribePause(args, flags) { + const [policyIdStr] = args; + + if (!policyIdStr) { + printError("missing_args", "Usage: zerion subscribe pause ", { + example: "zerion subscribe pause 1 --token ", + }); + process.exit(1); + } + + const policyId = parseInt(policyIdStr, 10); + if (Number.isNaN(policyId) || policyId < 1) { + printError( + "invalid_policy_id", + `Policy ID must be a positive integer, got "${policyIdStr}"`, + ); + process.exit(1); + } + + const { walletName, address } = resolveWallet({ + ...flags, + chain: "solana", + }); + + const tokenMint = flags.token || USDC_MINT; + const passphrase = await requireAgentToken( + "for Tributary subscription", + walletName, + ); + + try { + const sdk = await getTributarySdk(address); + + const instructions = await sdk.changePaymentPolicyStatus( + new PublicKey(tokenMint), + policyId, + { paused: {} }, + ); + + const txResult = await sendTributaryInstructions( + instructions, + address, + walletName, + passphrase, + ); + + print({ + action: "pause", + policyId, + token: tokenMint, + transaction: { + hash: txResult.hash, + status: txResult.status, + }, + }); + } catch (err) { + printError( + err.code || "pause_error", + `Failed to pause policy ${policyId}: ${err.message}`, + ); + process.exit(1); + } +} diff --git a/cli/commands/tributary/resume.js b/cli/commands/tributary/resume.js new file mode 100644 index 0000000..37bf996 --- /dev/null +++ b/cli/commands/tributary/resume.js @@ -0,0 +1,73 @@ +import { PublicKey } from "@solana/web3.js"; +import { + getTributarySdk, + sendTributaryInstructions, +} from "../../utils/tributary/sdk.js"; +import { USDC_MINT } from "../../utils/tributary/constants.js"; +import { resolveWallet } from "../../utils/wallet/resolve.js"; +import { print, printError } from "../../utils/common/output.js"; +import { requireAgentToken } from "../../utils/trading/guards.js"; + +export default async function subscribeResume(args, flags) { + const [policyIdStr] = args; + + if (!policyIdStr) { + printError("missing_args", "Usage: zerion subscribe resume ", { + example: "zerion subscribe resume 1 --token ", + }); + process.exit(1); + } + + const policyId = parseInt(policyIdStr, 10); + if (Number.isNaN(policyId) || policyId < 1) { + printError( + "invalid_policy_id", + `Policy ID must be a positive integer, got "${policyIdStr}"`, + ); + process.exit(1); + } + + const { walletName, address } = resolveWallet({ + ...flags, + chain: "solana", + }); + + const tokenMint = flags.token || USDC_MINT; + const passphrase = await requireAgentToken( + "for Tributary subscription", + walletName, + ); + + try { + const sdk = await getTributarySdk(address); + + const instructions = await sdk.changePaymentPolicyStatus( + new PublicKey(tokenMint), + policyId, + { active: {} }, + ); + + const txResult = await sendTributaryInstructions( + instructions, + address, + walletName, + passphrase, + ); + + print({ + action: "resume", + policyId, + token: tokenMint, + transaction: { + hash: txResult.hash, + status: txResult.status, + }, + }); + } catch (err) { + printError( + err.code || "resume_error", + `Failed to resume policy ${policyId}: ${err.message}`, + ); + process.exit(1); + } +} diff --git a/cli/commands/tributary/show.js b/cli/commands/tributary/show.js new file mode 100644 index 0000000..7861dd6 --- /dev/null +++ b/cli/commands/tributary/show.js @@ -0,0 +1,93 @@ +import { PublicKey } from "@solana/web3.js"; +import { getTributarySdk } from "../../utils/tributary/sdk.js"; +import { USDC_MINT, USDC_DECIMALS } from "../../utils/tributary/constants.js"; +import { resolveWallet } from "../../utils/wallet/resolve.js"; +import { print, printError } from "../../utils/common/output.js"; +import { + decodePolicyType, + decodeStatus, + decodeMemo, + formatAmount, +} from "../../utils/tributary/format.js"; + +export default async function subscribeShow(args, flags) { + const [policyIdStr] = args; + + if (!policyIdStr) { + printError("missing_args", "Usage: zerion subscribe show ", { + example: "zerion subscribe show 1", + }); + process.exit(1); + } + + const policyId = parseInt(policyIdStr, 10); + if (Number.isNaN(policyId) || policyId < 1) { + printError( + "invalid_policy_id", + `Policy ID must be a positive integer, got "${policyIdStr}"`, + ); + process.exit(1); + } + + const { walletName, address } = resolveWallet({ + ...flags, + chain: "solana", + }); + + const tokenMint = flags.token || USDC_MINT; + + try { + const sdk = await getTributarySdk(address); + + const userPaymentPda = sdk.getUserPaymentPda( + new PublicKey(address), + new PublicKey(tokenMint), + ); + const policyPda = sdk.getPaymentPolicyPda(userPaymentPda.address, policyId); + + const policy = await sdk.getPaymentPolicy(policyPda.address); + + if (!policy) { + printError( + "policy_not_found", + `Policy ${policyId} not found for wallet ${address}`, + ); + process.exit(1); + } + + const decoded = decodePolicyType(policy.account.policyType); + const status = decodeStatus(policy.account.status); + const memo = decodeMemo(policy.account.memo); + + const result = { + policyId: policy.account.policyId, + policyPda: policy.publicKey.toString(), + ...decoded, + status, + recipient: policy.account.recipient.toString(), + gateway: policy.account.gateway.toString(), + token: tokenMint, + }; + + if (decoded.type === "subscription") { + result.amount = `${formatAmount(decoded.amount, USDC_DECIMALS)} USDC`; + result.nextPaymentDue = decoded.nextPaymentDue + ? new Date(decoded.nextPaymentDue * 1000).toISOString() + : null; + } + if (memo) result.memo = memo; + if (policy.account.createdAt) { + result.createdAt = new Date( + policy.account.createdAt.toNumber() * 1000, + ).toISOString(); + } + + print(result); + } catch (err) { + printError( + err.code || "show_error", + `Failed to show policy: ${err.message}`, + ); + process.exit(1); + } +} diff --git a/cli/router.js b/cli/router.js index 352731a..e6b458f 100644 --- a/cli/router.js +++ b/cli/router.js @@ -69,6 +69,16 @@ function printUsage() { "agent show-policy ": "Show policy details", "agent delete-policy ": "Delete a policy", }, + tributary: { + "subscribe create --interval ": + "Create on-chain recurring subscription (Solana + Tributary)", + "subscribe list [--status active|paused|all] [--pretty]": + "List payment policies for wallet", + "subscribe show ": "Show single policy details", + "subscribe pause ": "Pause a payment policy", + "subscribe resume ": "Resume a paused policy", + "subscribe delete ": "Permanently delete a policy", + }, watchlist: { "watch
--name