procyon 0.1.1

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::process::Command;

use super::caatinga::{require_caatinga_project, run_caatinga, validate_source, validate_target};
use super::Tool;

pub struct CaatingaInvokeTool;

#[async_trait]
impl Tool for CaatingaInvokeTool {
    fn name(&self) -> &str {
        "caatinga_invoke"
    }

    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::Signing
    }

    fn description(&self) -> &str {
        "Invoke a function on a contract deployed by Caatinga, addressed as <contract>.<method> \
         using the contract's name from caatinga.config.ts — Caatinga resolves the id from its \
         artifacts, so no contract id is passed here. This signs and submits; use read for a \
         read-only call. Only works in a Caatinga project."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "target": {
                    "type": "string",
                    "description": "The call, as <contract>.<method> — e.g. 'token.transfer'. The contract name comes from caatinga.config.ts, not a contract id."
                },
                "args": {
                    "type": "array",
                    "items": {
                        "type": "string"
                    },
                    "description": "Arguments forwarded to the Stellar CLI after the method name, in order (e.g. [\"--to\", \"alice\", \"--amount\", \"100\"])"
                },
                "network": {
                    "type": "string",
                    "description": "Network name as configured in caatinga.config.ts (e.g. testnet). Required: this signs and submits, so it must not rely on a default. Mainnet is refused unless the operator enabled it."
                },
                "source": {
                    "type": "string",
                    "description": "Stellar CLI identity alias that signs, e.g. 'alice'. Never a secret key, seed phrase or raw address."
                }
            },
            "required": ["target", "network"]
        })
    }

    async fn execute(&self, input: Value) -> Result<String, String> {
        require_caatinga_project().await?;

        let target = input
            .get("target")
            .and_then(|v| v.as_str())
            .ok_or("Missing 'target' parameter (expected <contract>.<method>)")?;

        validate_target(target)?;

        let mut args = vec!["invoke".to_string(), target.to_string()];

        // This signs and submits, so the network is gated and must be explicit.
        let network =
            super::mainnet::resolve_signing_network(input.get("network").and_then(|v| v.as_str()))?;
        args.push("--network".to_string());
        args.push(network);

        if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
            validate_source(source)?;
            args.push("--source".to_string());
            args.push(source.to_string());
        }

        // Positional and last: the CLI forwards everything after the method name to the Stellar
        // CLI, so an option of its own appearing here would be swallowed by that forwarding.
        if let Some(fn_args) = input.get("args").and_then(|v| v.as_array()) {
            for arg in fn_args {
                if let Some(arg_str) = arg.as_str() {
                    args.push(arg_str.to_string());
                }
            }
        }

        let stdout = run_caatinga(&args).await?;
        Ok(format!("Invocation successful.\n\n{}", stdout))
    }
}

pub struct CaatingaReadTool;

#[async_trait]
impl Tool for CaatingaReadTool {
    fn name(&self) -> &str {
        "caatinga_read"
    }

    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::ReadOnly
    }

    fn description(&self) -> &str {
        "Simulate a read-only function on a contract Caatinga deployed, addressed as \
         <contract>.<method>. Nothing is signed and nothing is submitted, so it costs no fees and \
         changes no state — prefer this over caatinga_invoke whenever you only need to read a \
         value. Only works in a Caatinga project."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "target": {
                    "type": "string",
                    "description": "The call, as <contract>.<method> — e.g. 'token.balance'. The contract name comes from caatinga.config.ts, not a contract id."
                },
                "args": {
                    "type": "array",
                    "items": {
                        "type": "string"
                    },
                    "description": "Arguments forwarded to the Stellar CLI after the method name, in order"
                },
                "network": {
                    "type": "string",
                    "description": "Network name as configured in caatinga.config.ts (e.g. testnet)"
                },
                "source": {
                    "type": "string",
                    "description": "Stellar CLI identity alias used only as simulation context, e.g. 'alice'. Nothing is signed. Never a secret key, seed phrase or raw address."
                },
                "summary": {
                    "type": "boolean",
                    "description": "Print a compact summary instead of a large array payload in full"
                }
            },
            "required": ["target"]
        })
    }

    async fn execute(&self, input: Value) -> Result<String, String> {
        require_caatinga_project().await?;

        let target = input
            .get("target")
            .and_then(|v| v.as_str())
            .ok_or("Missing 'target' parameter (expected <contract>.<method>)")?;

        validate_target(target)?;

        let mut args = vec!["read".to_string(), target.to_string()];

        if let Some(network) = input.get("network").and_then(|v| v.as_str()) {
            args.push("--network".to_string());
            args.push(network.to_string());
        }

        // Simulation context rather than a signer, but the same hygiene applies: the value still
        // reaches the process list and this tool's error text.
        if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
            validate_source(source)?;
            args.push("--source".to_string());
            args.push(source.to_string());
        }

        if input.get("summary").and_then(|v| v.as_bool()) == Some(true) {
            args.push("--summary".to_string());
        }

        // Positional and last: everything after the method name is forwarded to the Stellar CLI.
        if let Some(fn_args) = input.get("args").and_then(|v| v.as_array()) {
            for arg in fn_args {
                if let Some(arg_str) = arg.as_str() {
                    args.push(arg_str.to_string());
                }
            }
        }

        let stdout = run_caatinga(&args).await?;
        Ok(format!(
            "Read (simulated, nothing submitted).\n\n{}",
            stdout
        ))
    }
}

pub struct StellarCliInvokeTool;

#[async_trait]
impl Tool for StellarCliInvokeTool {
    fn name(&self) -> &str {
        "stellar_invoke"
    }

    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::Signing
    }

    fn description(&self) -> &str {
        "Invoke a contract by raw id using stellar-cli. For a contract Caatinga deployed, prefer \
         caatinga_invoke: this path takes an id rather than a name, so it neither reads nor \
         updates caatinga.artifacts.json. Use it for a contract Caatinga does not manage, or a \
         feature it does not support."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "contract_id": {
                    "type": "string",
                    "description": "The contract ID to invoke"
                },
                "fn_name": {
                    "type": "string",
                    "description": "The function name to call"
                },
                "args": {
                    "type": "array",
                    "items": {
                        "type": "string"
                    },
                    "description": "Arguments to pass (format: --arg value)"
                },
                "network": {
                    "type": "string",
                    "enum": ["local", "testnet", "mainnet"],
                    "description": "Network to use. Required: this signs and submits. Mainnet is refused unless the operator enabled it."
                },
                "source": {
                    "type": "string",
                    "description": "Stellar CLI identity alias that signs, e.g. 'alice'. Never a secret key, seed phrase or raw address."
                }
            },
            "required": ["contract_id", "fn_name", "network"]
        })
    }

    async fn execute(&self, input: Value) -> Result<String, String> {
        let stellar_available = Command::new("stellar")
            .arg("--version")
            .output()
            .await
            .map(|o| o.status.success())
            .unwrap_or(false);

        if !stellar_available {
            return Err("stellar-cli is not installed".to_string());
        }

        let contract_id = input
            .get("contract_id")
            .and_then(|v| v.as_str())
            .ok_or("Missing 'contract_id' parameter")?;

        let fn_name = input
            .get("fn_name")
            .and_then(|v| v.as_str())
            .ok_or("Missing 'fn_name' parameter")?;

        let source = input
            .get("source")
            .and_then(|v| v.as_str())
            .unwrap_or("default");

        // Checked before the network gate deliberately: a caller who supplied key material needs
        // to hear about that first, not be sent away to name a network and told on the retry.
        // The hazard is the same on this path — a secret on a command line reaches the process
        // list, the error text and the session log, whichever CLI is being driven.
        validate_source(source)?;

        // Signs and submits like the Caatinga path, so it passes the same gate. The old default of
        // "testnet" is gone: a default that quietly decides where a transaction lands is exactly
        // what the gate exists to remove.
        let network =
            super::mainnet::resolve_signing_network(input.get("network").and_then(|v| v.as_str()))?;

        let mut args = vec![
            "contract".to_string(),
            "invoke".to_string(),
            "--id".to_string(),
            contract_id.to_string(),
            "--network".to_string(),
            network.to_string(),
            "--source".to_string(),
            source.to_string(),
            "--".to_string(),
            fn_name.to_string(),
        ];

        if let Some(fn_args) = input.get("args").and_then(|v| v.as_array()) {
            for arg in fn_args {
                if let Some(arg_str) = arg.as_str() {
                    args.push(arg_str.to_string());
                }
            }
        }

        let output = Command::new("stellar")
            .args(&args)
            .output()
            .await
            .map_err(|e| format!("Failed to execute stellar invoke: {}", e))?;

        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();

        if output.status.success() {
            let mut result = "Invocation successful!\n".to_string();
            if !stdout.is_empty() {
                result.push_str(&format!("\nResult:\n{}", stdout));
            }
            Ok(result)
        } else {
            Err(format!(
                "Invocation failed (exit code: {})\n\nstdout:\n{}\n\nstderr:\n{}",
                output.status.code().unwrap_or(-1),
                stdout,
                stderr
            ))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::ToolRegistry;

    // `read` simulates and `invoke` submits. The descriptions are what the model chooses between,
    // so the read-only one has to say plainly that it costs nothing.
    #[test]
    fn read_advertises_itself_as_costing_nothing() {
        let description = CaatingaReadTool.description();
        assert!(description.contains("Nothing is signed"), "{}", description);
        assert!(description.contains("no fees"), "{}", description);
    }

    // Both take a name-based target; neither should offer a contract_id parameter.
    #[test]
    fn neither_call_tool_accepts_a_contract_id() {
        for schema in [
            CaatingaInvokeTool.input_schema(),
            CaatingaReadTool.input_schema(),
        ] {
            let props = schema["properties"].as_object().unwrap();
            assert!(props.contains_key("target"));
            assert!(
                !props.contains_key("contract_id"),
                "a contract id must come from the artifacts, not the caller"
            );
        }
    }

    #[tokio::test]
    async fn read_refuses_a_project_without_a_caatinga_config() {
        let err = CaatingaReadTool
            .execute(json!({"target": "token.balance"}))
            .await
            .expect_err("read must refuse a non-Caatinga project");
        assert!(err.contains("not a Caatinga project"), "{}", err);
    }

    #[tokio::test]
    async fn read_refuses_a_target_that_is_not_contract_dot_method() {
        let err = CaatingaReadTool
            .execute(json!({"target": "balance"}))
            .await
            .expect_err("read must refuse a bare method");
        assert!(
            err.contains("<contract>.<method>") || err.contains("not a Caatinga project"),
            "got {}",
            err
        );
    }

    // The stellar path takes a raw id by design, but the credential hygiene is not optional there.
    #[tokio::test]
    async fn the_stellar_fallback_also_refuses_a_secret_source() {
        // Synthetic on purpose — see the fixtures in `caatinga`.
        let secret = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEXAMPLE";
        let err = StellarCliInvokeTool
            .execute(json!({
                "contract_id": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
                "fn_name": "balance",
                "network": "testnet",
                "source": secret
            }))
            .await
            .expect_err("a secret key must be refused on any path");
        assert!(
            err.contains("secret key") || err.contains("not installed"),
            "got {}",
            err
        );
        assert!(!err.contains(secret), "the error must not echo the secret");
    }

    // The gate has to be on every path that submits, and stated in the schema so the model does
    // not discover it by being refused.
    #[test]
    fn every_signing_tool_requires_its_network() {
        for schema in [
            CaatingaInvokeTool.input_schema(),
            StellarCliInvokeTool.input_schema(),
        ] {
            let required = schema["required"].as_array().unwrap();
            assert!(
                required.contains(&json!("network")),
                "a tool that submits must not fall back to a default network: {}",
                schema
            );
        }
    }

    // Read simulates, so it stays ungated on purpose: gating it would push the agent toward
    // invoke for questions read answers for free.
    #[test]
    fn read_does_not_require_a_network() {
        let schema = CaatingaReadTool.input_schema();
        let required = schema["required"].as_array().unwrap();
        assert!(!required.contains(&json!("network")));
    }

    #[tokio::test]
    async fn the_new_tools_register() {
        let mut registry = ToolRegistry::new();
        registry.register(Box::new(CaatingaReadTool));
        registry.register(Box::new(CaatingaInvokeTool));
        assert!(registry.get_tool("caatinga_read").is_some());
        assert!(registry.get_tool("caatinga_invoke").is_some());
    }
}