use polyc_llm::ToolSpec;
use serde_json::json;
pub const WALLET_STATUS: &str = "wallet_status";
pub const WALLET_HISTORY: &str = "wallet_history";
pub const WALLET_DEPOSIT_ADDRESS: &str = "wallet_deposit_address";
pub const WALLET_LINK: &str = "wallet_link";
pub const WALLET_ROSTER: &str = "wallet_roster";
pub const WALLET_SET_POLICY: &str = "wallet_set_policy";
pub const WALLET_UPDATE_LIMIT: &str = "wallet_update_limit";
pub const ALL: &[&str] = &[
WALLET_STATUS,
WALLET_HISTORY,
WALLET_DEPOSIT_ADDRESS,
WALLET_LINK,
WALLET_ROSTER,
WALLET_SET_POLICY,
WALLET_UPDATE_LIMIT,
];
#[must_use]
pub fn all_specs() -> Vec<ToolSpec> {
vec![
status_spec(),
history_spec(),
deposit_address_spec(),
link_spec(),
roster_spec(),
set_policy_spec(),
update_limit_spec(),
]
}
#[must_use]
pub fn status_spec() -> ToolSpec {
ToolSpec::new(
WALLET_STATUS,
"Report the spending wallet linked to the person you're talking to: its \
address, whether it's linked and ready to pay, its balance, and the \
network. Use this to answer \"what's my wallet\", \"what's my balance\", \
\"am I linked\", or \"which account will pay\" — it is FREE and moves no \
money. Never use paid_fetch to answer a question like that; paid_fetch \
spends money. Shows only this person's own wallet, never anyone else's. \
Takes no arguments.",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
)
.titled("Check the linked wallet")
.read_only()
.cacheable_approval()
}
#[must_use]
pub fn history_spec() -> ToolSpec {
ToolSpec::new(
WALLET_HISTORY,
"List the payments this person has made in this conversation: for each, \
the amount, the settlement reference, a verified explorer link when one \
exists, and when it settled. Use this to answer \"what have I spent\", \
\"show my payments\", or \"did that payment go through\" — it is FREE and \
reads the signed onchain receipts, so it never spends money. Shows only \
this person's own payments, never anyone else's. Takes no arguments.",
json!({
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Most payments to return, most recent first (default 10).",
"minimum": 1
}
},
"additionalProperties": false
}),
)
.titled("List payments in this conversation")
.read_only()
.cacheable_approval()
}
#[must_use]
pub fn deposit_address_spec() -> ToolSpec {
ToolSpec::new(
WALLET_DEPOSIT_ADDRESS,
"Give the person their own address for RECEIVING money: an address someone else can \
send funds to that's tracked as theirs. Use this to answer \"what's my deposit \
address\" or \"where can people send me money\" — it is FREE and moves no money. It \
does NOT say which tokens are accepted; if asked that, say you don't have that \
information rather than guessing. This is NOT the wallet the agent pays FROM (use \
wallet_status for that) — it's a separate receiving address that forwards straight to \
this deployment's shared wallet. Shows only this person's own address, never anyone \
else's. Takes no arguments.",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
)
.titled("Check the deposit address")
.read_only()
.cacheable_approval()
}
#[must_use]
pub fn link_spec() -> ToolSpec {
ToolSpec::new(
WALLET_LINK,
"Give the person a secure one-time link to set up — or replace — their own \
spending wallet with a passkey. Use it when they ask to link, connect, or \
set up a wallet or a passkey, and when they need a wallet before they can \
pay. This is the only passkey anyone sets up here: the same one holds \
their wallet and signs them in. Returns a link; they finish on their own \
device, and completing it provisions the wallet. It only ever links this \
person's own wallet, and it moves no money. Takes no arguments.",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
)
.titled("Link a spending wallet")
}
#[must_use]
pub fn roster_spec() -> ToolSpec {
ToolSpec::new(
WALLET_ROSTER,
"For an admin only: list everyone who has taken part in this conversation \
and whether each has linked a spending wallet (with address and balance \
when they have). Use it when an admin asks to see \"our\" or \"everyone's\" \
wallets. If the person asking isn't an admin, it returns a refusal rather \
than anyone else's wallet — for their own wallet use wallet_status. Takes \
no arguments.",
json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
)
.titled("List everyone's wallets (admin)")
.read_only()
.cacheable_approval()
}
#[must_use]
pub fn set_policy_spec() -> ToolSpec {
ToolSpec::new(
WALLET_SET_POLICY,
"For an admin only: set a spend policy on the person's OWN linked wallet — a \
spend limit, how often it resets, how long the delegated key stays valid, \
and which hosts it may pay. Use it when an admin says things like \"cap my \
spending at $5 a day\", \"only let it pay api.example.com\", or \"make the \
wallet expire in a week\". Every argument is optional: give only the ones \
being changed, and leave the rest out. Setting `limit` to an empty string, \
`period` to \"once\", `expires_in_days` to 0, or `hosts` to an empty list \
clears that one setting back to the deployment default — it does not touch \
the others. Clearing a setting can WIDEN authority back to the deployment \
default rather than narrow it, so every call — narrowing or widening — pauses \
for the admin to confirm the resulting policy before it's applied. It never \
moves money. If the person asking isn't an admin, it returns a refusal instead \
of changing anything.",
json!({
"type": "object",
"properties": {
"limit": {
"type": "string",
"description": "Spend cap in the settlement currency's human units \
(e.g. \"5\"). Omit to leave unchanged; an empty string clears it \
back to the deployment default cap."
},
"period": {
"type": "string",
"enum": ["once", "day", "week", "month"],
"description": "How often the cap resets. \"once\" is a one-time cap \
(spent once, then exhausted); \"day\"/\"week\"/\"month\" reset on \
that cadence. Omit to leave unchanged."
},
"expires_in_days": {
"type": "integer",
"minimum": 0,
"description": "How many days the delegated key stays valid before a \
re-link is needed. Omit to leave unchanged; 0 clears it back to \
the deployment default (still bounded by the platform's maximum)."
},
"hosts": {
"type": "array",
"items": { "type": "string" },
"description": "Hosts (e.g. \"api.example.com\") this wallet may pay. \
Omit to leave unchanged; an empty list clears the restriction so \
any host allowed by the deployment is payable again."
}
},
"additionalProperties": false
}),
)
.destructive()
.approval_required()
.titled("Set the wallet's spend policy (admin)")
}
#[must_use]
pub fn update_limit_spec() -> ToolSpec {
ToolSpec::new(
WALLET_UPDATE_LIMIT,
"Give the person a secure one-time link to raise or lower their OWN linked wallet's \
daily spending cap in place, without a full re-link. Use it when they ask to change \
how much the agent can spend per day — e.g. \"raise my limit to $20\", \"lower my cap \
to $5\". Needs a wallet already linked; if none is linked, use wallet_link instead. \
This changes ONLY the spending cap — it can never change how often the cap resets. \
Returns a link; they finish on their own device, and completing it takes effect \
immediately. It only ever adjusts THIS person's own wallet, and it moves no money.",
json!({
"type": "object",
"properties": {
"new_limit": {
"type": "string",
"description": "The new daily spend cap in the settlement currency's human \
units (e.g. \"20\")."
}
},
"required": ["new_limit"],
"additionalProperties": false
}),
)
.titled("Update the wallet's spending cap")
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn set_policy_spec_is_gated_by_intent_not_just_caller_identity() {
let spec = set_policy_spec();
assert!(
spec.destructive,
"clearing a setting can widen spend authority back to the deployment default"
);
assert!(
spec.needs_approval,
"setting a wallet policy must always pause for a human check, regardless of \
whether this particular call narrows or widens authority"
);
}
#[test]
fn set_policy_spec_description_does_not_claim_narrow_only() {
let spec = set_policy_spec();
assert!(
!spec.description.to_lowercase().contains("can only narrow"),
"the description must not claim a guarantee the tool doesn't enforce — \
clearing a setting can widen authority back to the deployment default"
);
}
}