Skip to main content

arete_hash/protocol/
handler.rs

1//! The `arete.protocol-handler/v1` wire ABI: the input a protocol handler
2//! receives for a step and the operation it returns.
3//!
4//! A handler returns portable instructions only. It never returns signers,
5//! fees, memos or compiled messages; trusted code adds those after
6//! validating the operation against the step's declarations.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use super::error::{ProtocolContractError, ProtocolResult};
14use super::intent::{AccountReadResultV1, FeePlacement};
15use super::model::PROTOCOL_HANDLER_ABI_V1;
16use super::release::decode_base64;
17use super::shape::*;
18
19pub const MAX_OPERATION_TRANSACTIONS: usize = 8;
20pub const MAX_TRANSACTION_INSTRUCTIONS: usize = 64;
21pub const MAX_INSTRUCTION_ACCOUNTS: usize = 256;
22pub const MAX_INSTRUCTION_DATA_BYTES: usize = 10 * 1024;
23pub const MAX_HANDLER_RESULT_BYTES: usize = 2 * 1024 * 1024;
24
25/// What the API hands the host for one invocation. Every value is fixed by
26/// the API before dispatch; handlers have no clock, RPC or randomness.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct PrepareInputV1 {
30    pub abi: String,
31    pub step: String,
32    pub args: Value,
33    pub resources: BTreeMap<String, Value>,
34    pub params: Value,
35    pub instance: Option<InstanceInputV1>,
36    pub participant: Option<ParticipantInputV1>,
37    pub reads: BTreeMap<String, ReadInputV1>,
38    pub wallet: String,
39    pub execution_id: String,
40    pub ephemeral: BTreeMap<String, String>,
41    pub programs: BTreeMap<String, String>,
42    pub fee: Option<FeeContextV1>,
43    /// Unix seconds, fixed for the whole invocation.
44    pub now: i64,
45    /// The platform keys of the deployment the invocation runs under.
46    pub deployment: DeploymentInputV1,
47}
48
49/// The deployment an invocation runs under and the platform keys it names.
50/// The same values are the `deployment.*` expression root.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct DeploymentInputV1 {
54    /// The deployment id (a UUID).
55    pub id: String,
56    /// The deployment's attestation pubkey.
57    pub attestation: String,
58    /// The Squads proposer pubkey for the calling integrator account, or
59    /// `None` when the deployment has no `squadsProposer` step.
60    pub squads_proposer: Option<String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65pub struct InstanceInputV1 {
66    pub id: String,
67    pub state: String,
68    pub accounts: BTreeMap<String, String>,
69    pub roles: BTreeMap<String, String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase", deny_unknown_fields)]
74pub struct ParticipantInputV1 {
75    pub id: String,
76    pub wallet: String,
77    pub state: String,
78}
79
80/// A declared read: the raw account plus, for `account` reads, the fields
81/// decoded with the program's ProgramSpec.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct ReadInputV1 {
85    pub account: AccountReadResultV1,
86    pub decoded: Option<Value>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91pub struct FeeContextV1 {
92    pub gross: String,
93    pub fee: String,
94    pub net: String,
95    /// `"native"` or a mint address.
96    pub asset: String,
97    pub placement: FeePlacement,
98}
99
100/// What a handler returns.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct PreparedOperationV1 {
104    pub abi: String,
105    pub transactions: Vec<PreparedTransactionV1>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct PreparedTransactionV1 {
111    pub name: String,
112    pub instructions: Vec<PortableInstructionV1>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase", deny_unknown_fields)]
117pub struct PortableInstructionV1 {
118    pub program_id: String,
119    pub keys: Vec<PortableAccountMetaV1>,
120    pub data_base64: String,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase", deny_unknown_fields)]
125pub struct PortableAccountMetaV1 {
126    pub pubkey: String,
127    pub is_signer: bool,
128    pub is_writable: bool,
129}
130
131impl PortableInstructionV1 {
132    pub fn data(&self) -> Vec<u8> {
133        decode_base64(&self.data_base64).unwrap_or_default()
134    }
135}
136
137/// Validate a handler's result shape and size bounds. Program, account and
138/// signer policy is checked afterwards against the step by the trusted
139/// transaction compiler.
140pub fn parse_prepared_operation_v1(bytes: &[u8]) -> ProtocolResult<PreparedOperationV1> {
141    if bytes.len() > MAX_HANDLER_RESULT_BYTES {
142        return Err(ProtocolContractError::new(
143            "string-too-long",
144            "",
145            format!("handler results are at most {MAX_HANDLER_RESULT_BYTES} bytes"),
146        ));
147    }
148    let value = super::parse_contract_json(bytes)?;
149    validate_prepared_operation_value(&value)
150}
151
152pub fn validate_prepared_operation_value(value: &Value) -> ProtocolResult<PreparedOperationV1> {
153    reject_floats(value, "")?;
154    let root = Obj::new(value, "", &["abi", "transactions"])?;
155    schema_tag(root.req("abi")?, "/abi", PROTOCOL_HANDLER_ABI_V1)?;
156    let transactions = as_array(
157        root.req("transactions")?,
158        "/transactions",
159        MAX_OPERATION_TRANSACTIONS,
160    )?;
161    if transactions.is_empty() {
162        return Err(err(
163            "invalid-value",
164            "/transactions",
165            "an operation has at least one transaction",
166        ));
167    }
168    for (i, transaction) in transactions.iter().enumerate() {
169        let path = index("/transactions", i);
170        let transaction = Obj::new(transaction, &path, &["name", "instructions"])?;
171        identifier_value(transaction.req("name")?, &transaction.path_of("name"))?;
172        let instructions_path = transaction.path_of("instructions");
173        let instructions = as_array(
174            transaction.req("instructions")?,
175            &instructions_path,
176            MAX_TRANSACTION_INSTRUCTIONS,
177        )?;
178        if instructions.is_empty() {
179            return Err(err(
180                "invalid-value",
181                &instructions_path,
182                "a transaction has at least one instruction",
183            ));
184        }
185        for (j, instruction) in instructions.iter().enumerate() {
186            let path = index(&instructions_path, j);
187            let instruction = Obj::new(instruction, &path, &["programId", "keys", "dataBase64"])?;
188            pubkey(
189                instruction.req("programId")?,
190                &instruction.path_of("programId"),
191            )?;
192            let keys_path = instruction.path_of("keys");
193            for (k, key) in as_array(
194                instruction.req("keys")?,
195                &keys_path,
196                MAX_INSTRUCTION_ACCOUNTS,
197            )?
198            .iter()
199            .enumerate()
200            {
201                let key = Obj::new(
202                    key,
203                    &index(&keys_path, k),
204                    &["pubkey", "isSigner", "isWritable"],
205                )?;
206                pubkey(key.req("pubkey")?, &key.path_of("pubkey"))?;
207                as_bool(key.req("isSigner")?, &key.path_of("isSigner"))?;
208                as_bool(key.req("isWritable")?, &key.path_of("isWritable"))?;
209            }
210            let data_path = instruction.path_of("dataBase64");
211            let data = as_str(instruction.req("dataBase64")?, &data_path)?;
212            match decode_base64(data) {
213                Some(bytes) if bytes.len() <= MAX_INSTRUCTION_DATA_BYTES => {}
214                Some(_) => {
215                    return Err(err(
216                        "string-too-long",
217                        &data_path,
218                        "instruction data is too large",
219                    ))
220                }
221                None => {
222                    return Err(err(
223                        "invalid-value",
224                        &data_path,
225                        "expected canonical base64",
226                    ))
227                }
228            }
229        }
230    }
231    serde_json::from_value(value.clone()).map_err(|error| {
232        ProtocolContractError::new("invalid-value", "", format!("model mismatch: {error}"))
233    })
234}
235
236#[cfg(test)]
237mod tests {
238    use serde_json::json;
239
240    use super::*;
241
242    fn input(deployment: Value) -> Value {
243        json!({
244            "abi": PROTOCOL_HANDLER_ABI_V1, "step": "create", "args": {}, "resources": {},
245            "params": {}, "instance": null, "participant": null, "reads": {},
246            "wallet": "11111111111111111111111111111111",
247            "executionId": "00000000-0000-4000-8000-000000000001",
248            "ephemeral": {}, "programs": {}, "fee": null, "now": 1_789_000_000,
249            "deployment": deployment,
250        })
251    }
252
253    #[test]
254    fn prepare_input_carries_the_deployment_keys() {
255        let parsed: PrepareInputV1 = serde_json::from_value(input(json!({
256            "id": "00000000-0000-4000-8000-0000000000d1",
257            "attestation": "Vote111111111111111111111111111111111111111",
258            "squadsProposer": null,
259        })))
260        .expect("valid input");
261        assert_eq!(parsed.deployment.squads_proposer, None);
262        let wire = serde_json::to_value(&parsed).expect("serializes");
263        assert_eq!(wire["deployment"]["squadsProposer"], Value::Null);
264        assert_eq!(
265            wire["deployment"]["attestation"],
266            "Vote111111111111111111111111111111111111111"
267        );
268
269        let mut missing = input(Value::Null);
270        missing
271            .as_object_mut()
272            .expect("object")
273            .remove("deployment");
274        assert!(serde_json::from_value::<PrepareInputV1>(missing).is_err());
275        let extra = input(json!({
276            "id": "x", "attestation": "y", "squadsProposer": null, "other": 1,
277        }));
278        assert!(serde_json::from_value::<PrepareInputV1>(extra).is_err());
279    }
280}