Skip to main content

cratefield_adapter_turnkey/
api.rs

1//! The Turnkey API surface this crate speaks: JSON POSTs to
2//! `https://api.turnkey.com/public/v1/submit/<activity>` with an
3//! `X-Stamp` header, answered by an activity whose status is the
4//! verdict. One [`TurnkeySigner`] serves one venture: the parent
5//! organization id, and the Secrets-port name of the P-256 API key the
6//! delegated access user stamps with — the same key pair registered as
7//! our backend user's key on the parent organization.
8
9use std::sync::Arc;
10
11use bytes::Bytes;
12use http::header::{CONTENT_TYPE, HeaderValue};
13use http::{Request, StatusCode};
14use serde_json::{Value, json};
15use zeroize::Zeroizing;
16
17use cratefield_core::{Clock, HttpClient, HttpError};
18use cratefield_secrets::{Actor, SecretStore};
19use cratefield_signer::SignerError;
20
21use crate::policy::{AllowRule, da_consensus, validate_turnkey_id};
22
23/// Turnkey's API root. `with_base` overrides it for a fake in tests.
24const API_BASE: &str = "https://api.turnkey.com";
25
26/// The activity statuses that mean "a policy or a quorum said no":
27/// an explicit rejection, and a consensus that never formed because no
28/// allow policy covered the request. Both are the port's `Denied`.
29const DENIED_STATUSES: [&str; 2] = [
30    "ACTIVITY_STATUS_REJECTED",
31    "ACTIVITY_STATUS_CONSENSUS_NEEDED",
32];
33
34/// The end user's passkey, as the browser handed it back — the root
35/// user's WebAuthn authenticator at sub-organization creation.
36#[derive(Debug, Clone)]
37pub struct PasskeyAttestation {
38    /// Human-readable authenticator name.
39    pub authenticator_name: String,
40    /// The challenge, base64url, exactly as presented to the client.
41    pub challenge: String,
42    /// The credential id, CBOR-encoded then base64url.
43    pub credential_id: String,
44    /// The base64url client data JSON.
45    pub client_data_json: String,
46    /// The base64url attestation object.
47    pub attestation_object: String,
48    /// `AUTHENTICATOR_TRANSPORT_*` values, e.g. `HYBRID`.
49    pub transports: Vec<String>,
50}
51
52/// One end user's sub-organization setup: a name, the passkey their
53/// wallet is rooted in, and the allow rules the delegated access user
54/// is scoped to.
55#[derive(Debug, Clone)]
56pub struct SubOrgSetup {
57    /// The sub-organization's name, e.g. `acme:user-42`.
58    pub name: String,
59    /// The end user's name.
60    pub end_user_name: String,
61    /// The end user's email, when there is one.
62    pub end_user_email: Option<String>,
63    /// The passkey that becomes the root user's authenticator.
64    pub authenticator: PasskeyAttestation,
65    /// One `EFFECT_ALLOW` policy per rule, all scoped to the delegated
66    /// access user.
67    pub rules: Vec<AllowRule>,
68}
69
70/// What a successful setup hands back: everything [`crate::signer::key_ref`]
71/// needs, plus the ids later administration calls go by.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SubOrganization {
74    /// The sub-organization id.
75    pub id: String,
76    /// The end user's id — the root quorum's sole member once setup is
77    /// done.
78    pub end_user_id: String,
79    /// The delegated access user's id — the consensus every policy this
80    /// crate writes is scoped to.
81    pub da_user_id: String,
82    /// The wallet's id.
83    pub wallet_id: String,
84    /// The wallet's EVM account address (`0x`, lowercase), from the
85    /// `m/44'/60'/0'/0/0` account.
86    pub evm_address: Option<String>,
87    /// The wallet's Solana account pubkey (base58), from the
88    /// `m/44'/501'/0'/0'` account.
89    pub solana_address: Option<String>,
90}
91
92/// One user of a sub-organization, as `list_users` reports it: the id,
93/// and the public keys of any API keys registered on it — the shape
94/// user identification goes by.
95struct ListedUser {
96    id: String,
97    api_key_public_keys: Vec<String>,
98}
99
100/// The Turnkey provider for the [`KeySigner`](cratefield_signer::KeySigner)
101/// port: setup, policies, kill switches, and signing by key reference.
102///
103/// No `Debug` derive: the fields name nothing secret (the API key is
104/// referenced by its Secrets-port name), but a derived `Debug` would
105/// print whatever is added later.
106pub struct TurnkeySigner {
107    http: Arc<dyn HttpClient>,
108    clock: Arc<dyn Clock>,
109    /// The parent organization the backend's own API key belongs to;
110    /// sub-organization creation is submitted here, everything else to
111    /// the sub-organization itself.
112    organization_id: String,
113    /// The Secrets store holding the P-256 API key scalar.
114    secrets: Arc<SecretStore>,
115    /// Who every secrets access is attributed to.
116    actor: Actor,
117    /// The store name the API key scalar lives under.
118    api_key_secret: String,
119    /// `None` posts to [`API_BASE`]; a value overrides it for tests.
120    base: Option<String>,
121}
122
123impl TurnkeySigner {
124    /// A signer for `organization_id`, stamping with the P-256 scalar
125    /// sealed in the Secrets store at `api_key_secret`, attributed to
126    /// `actor`. Holds no credential itself — which key, and whose
127    /// store, is the deployment's to say.
128    #[must_use]
129    pub fn new(
130        http: Arc<dyn HttpClient>,
131        clock: Arc<dyn Clock>,
132        organization_id: impl Into<String>,
133        secrets: Arc<SecretStore>,
134        actor: Actor,
135        api_key_secret: impl Into<String>,
136    ) -> Self {
137        Self {
138            http,
139            clock,
140            organization_id: organization_id.into(),
141            secrets,
142            actor,
143            api_key_secret: api_key_secret.into(),
144            base: None,
145        }
146    }
147
148    /// Overrides the URL every request is posted to — a fake in tests.
149    #[must_use]
150    pub fn with_base(mut self, base: impl Into<String>) -> Self {
151        self.base = Some(base.into());
152        self
153    }
154
155    /// The API key's compressed public key, hex — the value registered
156    /// on the delegated access user at setup, and what an operator
157    /// registers on the parent organization for this same key pair.
158    ///
159    /// # Errors
160    ///
161    /// [`SignerError::UnknownKey`] when the secret is not in the store,
162    /// [`SignerError::Provider`] when it does not unseal to a scalar.
163    pub async fn da_public_key(&self) -> Result<String, SignerError> {
164        Ok(crate::stamp::compressed_public_key_hex(
165            &self.load_stamp_key().await?,
166        ))
167    }
168
169    /// Runs the whole setup flow against Turnkey, in order: create the
170    /// sub-organization (end user's passkey + the delegated access
171    /// user, both root, threshold 1, wallet with EVM and Solana
172    /// accounts), read its users back and identify them — delegated
173    /// access by this crate's API key, the end user as the other of
174    /// exactly two — then one `EFFECT_ALLOW` policy per rule scoped to
175    /// the delegated user, then `ACTIVITY_TYPE_UPDATE_ROOT_QUORUM` with
176    /// the end user alone. Afterwards the backend can sign what the
177    /// policies allow and nothing else — and cannot change the
178    /// policies.
179    ///
180    /// A failure between the policy and quorum steps leaves the
181    /// delegated user root: retry setup rather than abandon it, and
182    /// see `set_kill_switch` for freezing a half-set-up sub-organization.
183    ///
184    /// # Errors
185    ///
186    /// [`SignerError`] — the first activity that is refused, rejected
187    /// or fails stops the flow, as does a user listing that does not
188    /// identify exactly one delegated access user.
189    pub async fn create_sub_organization(
190        &self,
191        setup: &SubOrgSetup,
192    ) -> Result<SubOrganization, SignerError> {
193        let da_public_key = self.da_public_key().await?;
194        let activity = self
195            .submit(
196                "create_sub_organization",
197                "ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7",
198                &self.organization_id,
199                create_sub_organization_parameters(setup, &da_public_key),
200            )
201            .await?;
202        let result = &activity["createSubOrganizationResult"];
203        let id = turnkey_id(result["subOrganizationId"].as_str(), "subOrganizationId")?;
204        // Who is who is not an ordering question: `rootUserIds` rides
205        // in the order the users were sent, so it identifies nothing.
206        // The users are read back (`POST /public/v1/query/list_users`)
207        // and the delegated access user is whichever of them carries
208        // this crate's API key, the end user the other of exactly two.
209        // Anything ambiguous fails loudly here, before a policy or the
210        // quorum hand-over can act on a guess.
211        let listed = self.list_users(&id).await?;
212        if listed.len() != 2 {
213            return Err(SignerError::Provider(format!(
214                "the new sub-organization holds {} users, not the two that were created; refusing \
215                 to guess which is which",
216                listed.len()
217            )));
218        }
219        let carriers: Vec<&str> = listed
220            .iter()
221            .filter(|user| {
222                user.api_key_public_keys
223                    .iter()
224                    .any(|key| key == &da_public_key)
225            })
226            .map(|user| user.id.as_str())
227            .collect();
228        if carriers.len() != 1 {
229            return Err(SignerError::Provider(format!(
230                "exactly one user of the new sub-organization must carry the delegated access \
231                 API key, found {}; refusing to guess which is which",
232                carriers.len()
233            )));
234        }
235        let da_user_id = carriers[0].to_owned();
236        let end_user_id = listed
237            .iter()
238            .map(|user| user.id.as_str())
239            .find(|user_id| *user_id != da_user_id)
240            .ok_or_else(|| {
241                SignerError::Provider(
242                    "the new sub-organization has no second user to be the end user".to_owned(),
243                )
244            })?
245            .to_owned();
246        let wallet = &result["wallet"];
247        let wallet_id = wallet["walletId"]
248            .as_str()
249            .ok_or_else(|| {
250                SignerError::Provider("the sub-organization result has no walletId".to_owned())
251            })?
252            .to_owned();
253        // Addresses come back in account order: EVM, then Solana.
254        let addresses: Vec<&str> = wallet["addresses"]
255            .as_array()
256            .map(|list| list.iter().filter_map(Value::as_str).collect())
257            .unwrap_or_default();
258        let sub_org = SubOrganization {
259            id,
260            end_user_id,
261            da_user_id,
262            wallet_id,
263            evm_address: addresses.first().map(|address| (*address).to_owned()),
264            solana_address: addresses.get(1).map(|address| (*address).to_owned()),
265        };
266
267        for (at, rule) in setup.rules.iter().enumerate() {
268            self.create_policy(
269                &sub_org.id,
270                &format!("cratefield-da-allow-{at}"),
271                "EFFECT_ALLOW",
272                &sub_org.da_user_id,
273                Some(&rule.condition()),
274                "cratefield: allow the delegated access user one rule (issue #761)",
275            )
276            .await?;
277        }
278
279        self.update_root_quorum(&sub_org.id, 1, std::slice::from_ref(&sub_org.end_user_id))
280            .await?;
281        Ok(sub_org)
282    }
283
284    /// Creates one policy on `sub_organization_id`. `effect` is
285    /// `EFFECT_ALLOW` or `EFFECT_DENY`; the consensus is always the
286    /// delegated access user — these policies decide the backend's
287    /// requests and nobody else's. Returns the new policy's id.
288    ///
289    /// # Errors
290    ///
291    /// [`SignerError`] — a refused activity, or an id that is not a
292    /// UUID.
293    pub async fn create_policy(
294        &self,
295        sub_organization_id: &str,
296        name: &str,
297        effect: &str,
298        da_user_id: &str,
299        condition: Option<&str>,
300        notes: &str,
301    ) -> Result<String, SignerError> {
302        let parameters = json!({
303            "policyName": name,
304            "effect": effect,
305            "consensus": da_consensus(da_user_id)?,
306            "condition": condition,
307            "notes": notes,
308        });
309        let activity = self
310            .submit(
311                "create_policy",
312                "ACTIVITY_TYPE_CREATE_POLICY_V3",
313                sub_organization_id,
314                parameters,
315            )
316            .await?;
317        turnkey_id(
318            activity["createPolicyResult"]["policyId"].as_str(),
319            "policyId",
320        )
321    }
322
323    /// The kill switch: an `EFFECT_DENY` policy scoped to the delegated
324    /// access user whose condition is `true`. While it exists nothing
325    /// the backend signs is approved — allow policies say what *may*
326    /// pass, a deny policy says nothing does. Returns the policy id to
327    /// hand to [`TurnkeySigner::clear_kill_switch`].
328    ///
329    /// # Errors
330    ///
331    /// [`SignerError`] — see [`TurnkeySigner::create_policy`].
332    pub async fn set_kill_switch(
333        &self,
334        sub_organization_id: &str,
335        da_user_id: &str,
336    ) -> Result<String, SignerError> {
337        self.create_policy(
338            sub_organization_id,
339            "cratefield-da-kill-switch",
340            "EFFECT_DENY",
341            da_user_id,
342            Some("true"),
343            "cratefield: kill switch — deny the delegated access user everything (issue #761)",
344        )
345        .await
346    }
347
348    /// Lifts the kill switch by deleting the policy `policy_id` names.
349    ///
350    /// # Errors
351    ///
352    /// [`SignerError`] — a refused activity.
353    pub async fn clear_kill_switch(
354        &self,
355        sub_organization_id: &str,
356        policy_id: &str,
357    ) -> Result<(), SignerError> {
358        self.submit(
359            "delete_policy",
360            "ACTIVITY_TYPE_DELETE_POLICY",
361            sub_organization_id,
362            json!({ "policyId": policy_id }),
363        )
364        .await?;
365        Ok(())
366    }
367
368    /// Moves the root quorum to `threshold` approvals from `user_ids` —
369    /// the setup flow's last step, which takes the backend out of it.
370    ///
371    /// # Errors
372    ///
373    /// [`SignerError`] — a refused activity.
374    pub async fn update_root_quorum(
375        &self,
376        sub_organization_id: &str,
377        threshold: u32,
378        user_ids: &[String],
379    ) -> Result<(), SignerError> {
380        for id in user_ids {
381            validate_turnkey_id(id)?;
382        }
383        self.submit(
384            "update_root_quorum",
385            "ACTIVITY_TYPE_UPDATE_ROOT_QUORUM",
386            sub_organization_id,
387            json!({ "threshold": threshold, "userIds": user_ids }),
388        )
389        .await?;
390        Ok(())
391    }
392
393    /// Signs an unsigned transaction: `ACTIVITY_TYPE_SIGN_TRANSACTION_V2`,
394    /// the one activity Turnkey's policy engine parses `eth.tx.*`
395    /// conditions against. Returns the signed transaction hex.
396    ///
397    /// # Errors
398    ///
399    /// [`SignerError`] — denied by policy, or a failed activity.
400    pub(crate) async fn sign_transaction(
401        &self,
402        sub_organization_id: &str,
403        sign_with: &str,
404        unsigned_transaction_hex: &str,
405        transaction_type: &str,
406    ) -> Result<String, SignerError> {
407        let activity = self
408            .submit(
409                "sign_transaction",
410                "ACTIVITY_TYPE_SIGN_TRANSACTION_V2",
411                sub_organization_id,
412                json!({
413                    "signWith": sign_with,
414                    "unsignedTransaction": unsigned_transaction_hex,
415                    "type": transaction_type,
416                }),
417            )
418            .await?;
419        activity["signTransactionResult"]["signedTransaction"]
420            .as_str()
421            .map(str::to_owned)
422            .ok_or_else(|| {
423                SignerError::Provider("the sign result carries no signedTransaction".to_owned())
424            })
425    }
426
427    /// Signs a payload: `ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2`. Note the
428    /// policy engine does **not** parse raw payloads — `eth.tx.*` rules
429    /// only bind on [`TurnkeySigner::sign_transaction`]. Returns
430    /// `(r, s, v)` as hex strings.
431    ///
432    /// # Errors
433    ///
434    /// [`SignerError`] — denied by policy, or a failed activity.
435    pub(crate) async fn sign_raw_payload(
436        &self,
437        sub_organization_id: &str,
438        sign_with: &str,
439        payload_hex: &str,
440        hash_function: &str,
441    ) -> Result<(String, String, String), SignerError> {
442        let activity = self
443            .submit(
444                "sign_raw_payload",
445                "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
446                sub_organization_id,
447                json!({
448                    "signWith": sign_with,
449                    "payload": payload_hex,
450                    "encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
451                    "hashFunction": hash_function,
452                }),
453            )
454            .await?;
455        let result = &activity["signRawPayloadResult"];
456        let r = result["r"].as_str().ok_or_else(missing_signature)?;
457        let s = result["s"].as_str().ok_or_else(missing_signature)?;
458        let v = result["v"].as_str().ok_or_else(missing_signature)?;
459        Ok((r.to_owned(), s.to_owned(), v.to_owned()))
460    }
461
462    /// Posts one activity and returns its `result` object once the
463    /// activity has completed — every status short of that is an error,
464    /// mapped in [`activity_result`].
465    async fn submit(
466        &self,
467        path: &str,
468        activity_type: &str,
469        organization_id: &str,
470        parameters: Value,
471    ) -> Result<Value, SignerError> {
472        if organization_id.is_empty() {
473            return Err(SignerError::Invalid(
474                "a Turnkey organization id is required".to_owned(),
475            ));
476        }
477        // timestampMs rides as a string, and the clock is a port: a
478        // deployment's idea of "now" is its runtime's, not this crate's.
479        let timestamp_ms = self.clock.now().unix_timestamp_nanos() / 1_000_000;
480        let body = serde_json::to_vec(&json!({
481            "type": activity_type,
482            "timestampMs": timestamp_ms.to_string(),
483            "organizationId": organization_id,
484            "parameters": parameters,
485        }))
486        .map_err(|err| SignerError::Provider(format!("the request could not be built: {err}")))?;
487        let answer = self.post("submit", path, body).await?;
488        let activity = &answer["activity"];
489        activity_result(activity_type, activity)
490    }
491
492    /// The users of a sub-organization, from Turnkey's read endpoint
493    /// `POST /public/v1/query/list_users` — the query twin of the
494    /// submit posts: same stamp, but no activity envelope, the answer
495    /// is the JSON as it comes.
496    async fn list_users(&self, sub_organization_id: &str) -> Result<Vec<ListedUser>, SignerError> {
497        let body = serde_json::to_vec(&json!({ "organizationId": sub_organization_id })).map_err(
498            |err| SignerError::Provider(format!("the request could not be built: {err}")),
499        )?;
500        let answer = self.post("query", "list_users", body).await?;
501        let users = answer["users"].as_array().ok_or_else(|| {
502            SignerError::Provider("the list_users answer carries no users".to_owned())
503        })?;
504        let mut listed = Vec::with_capacity(users.len());
505        for user in users {
506            listed.push(ListedUser {
507                id: turnkey_id(user["userId"].as_str(), "userId")?,
508                api_key_public_keys: user["apiKeys"]
509                    .as_array()
510                    .map(|keys| {
511                        keys.iter()
512                            .filter_map(|key| key["credential"]["publicKey"].as_str())
513                            .map(str::to_owned)
514                            .collect()
515                    })
516                    .unwrap_or_default(),
517            });
518        }
519        Ok(listed)
520    }
521
522    /// Posts an already-built body to `public/v1/{kind}/{name}` —
523    /// `submit/…` activities and `query/…` reads are the same wire:
524    /// stamped, JSON in, JSON out.
525    async fn post(&self, kind: &str, name: &str, body: Vec<u8>) -> Result<Value, SignerError> {
526        let key = self.load_stamp_key().await?;
527        let stamp = crate::stamp::stamp_with_key(&body, &key);
528        drop(key);
529        let header = HeaderValue::from_str(&stamp)
530            .map_err(|_| SignerError::Provider("the stamp is not a legal header".to_owned()))?;
531        let uri = format!(
532            "{}/public/v1/{kind}/{name}",
533            self.base.as_deref().unwrap_or(API_BASE)
534        );
535        let request = Request::builder()
536            .method(http::Method::POST)
537            .uri(uri)
538            .header("X-Stamp", header)
539            .header(CONTENT_TYPE, "application/json")
540            .body(Bytes::from(body))
541            .map_err(|_| SignerError::Provider("the request could not be built".to_owned()))?;
542
543        let response = self.http.send(request).await.map_err(|err: HttpError| {
544            SignerError::Provider(format!("turnkey is unreachable: {err}"))
545        })?;
546        let status = response.status();
547        let text = String::from_utf8_lossy(response.body()).to_string();
548        if !status.is_success() {
549            return Err(SignerError::Provider(format!(
550                "turnkey answered {}: {text}",
551                StatusCode::as_u16(&status)
552            )));
553        }
554        serde_json::from_str(&text).map_err(|err| {
555            SignerError::Provider(format!("turnkey answered unparsable JSON: {err}"))
556        })
557    }
558
559    /// Unseals the API key scalar. The bytes exist in the clear exactly
560    /// here, in a `Zeroizing` buffer that drops with the call — the
561    /// same shape `SecretsSigner::unseal` keeps its keys in.
562    async fn load_stamp_key(&self) -> Result<p256::ecdsa::SigningKey, SignerError> {
563        let sealed = self
564            .secrets
565            .get(&self.api_key_secret, &self.actor)
566            .await
567            .map_err(|err| SignerError::Provider(format!("the secrets store refused: {err}")))?
568            .ok_or_else(|| SignerError::UnknownKey {
569                key_ref: self.api_key_secret.clone(),
570            })?;
571        let scalar = Zeroizing::new(<[u8; 32]>::try_from(sealed.expose()).map_err(|_| {
572            SignerError::Provider(format!(
573                "the Turnkey API key behind `{}` is {} bytes, not the 32-byte P-256 scalar",
574                self.api_key_secret,
575                sealed.expose().len()
576            ))
577        })?);
578        crate::stamp::signing_key(scalar.as_slice())
579    }
580}
581
582/// Reads one completed activity's `result` object, mapping every other
583/// status onto the port: policy rejections and unformed consensus are
584/// [`SignerError::Denied`], Turnkey-side failures and anything
585/// unexpected are [`SignerError::Provider`].
586fn activity_result(activity_type: &str, activity: &Value) -> Result<Value, SignerError> {
587    let status = activity["status"].as_str().unwrap_or("missing");
588    match status {
589        "ACTIVITY_STATUS_COMPLETED" => Ok(activity["result"].clone()),
590        status if DENIED_STATUSES.contains(&status) => Err(SignerError::Denied {
591            reason: format!(
592                "turnkey refused the {activity_type}: the activity is {status} — a policy denied \
593                 it, or the delegated user's consensus was not enough"
594            ),
595        }),
596        other => Err(SignerError::Provider(format!(
597            "the turnkey {activity_type} did not complete: status {other}"
598        ))),
599    }
600}
601
602/// The signature components the answer must carry.
603fn missing_signature() -> SignerError {
604    SignerError::Provider("the sign result carries no (r, s, v)".to_owned())
605}
606
607/// Reads a Turnkey object id out of an answer, refusing one that is not
608/// a UUID before it can travel anywhere.
609fn turnkey_id(value: Option<&str>, field: &str) -> Result<String, SignerError> {
610    let id = value
611        .ok_or_else(|| SignerError::Provider(format!("the activity result has no {field}")))?;
612    crate::policy::validate_turnkey_id(id)
613}
614
615/// The `CREATE_SUB_ORGANIZATION_V7` parameters: the end user (passkey
616/// attestation, nothing else) and the delegated access user (this
617/// crate's API key, no authenticator) as the two root users at
618/// threshold 1, and the wallet with one EVM and one Solana account.
619fn create_sub_organization_parameters(setup: &SubOrgSetup, da_public_key: &str) -> Value {
620    json!({
621        "subOrganizationName": setup.name,
622        "rootUsers": [
623            {
624                "userName": setup.end_user_name,
625                "userEmail": setup.end_user_email,
626                "apiKeys": [],
627                "authenticators": [{
628                    "authenticatorName": setup.authenticator.authenticator_name,
629                    "challenge": setup.authenticator.challenge,
630                    "attestation": {
631                        "credentialId": setup.authenticator.credential_id,
632                        "clientDataJson": setup.authenticator.client_data_json,
633                        "attestationObject": setup.authenticator.attestation_object,
634                        "transports": setup.authenticator.transports,
635                    },
636                }],
637                "oauthProviders": [],
638            },
639            {
640                // The delegated access user: API key only, no
641                // authenticator — nothing human can log in as it.
642                "userName": "cratefield-delegated",
643                "userEmail": null,
644                "apiKeys": [{
645                    "apiKeyName": "cratefield-da",
646                    "publicKey": da_public_key,
647                    "curveType": "API_KEY_CURVE_P256",
648                }],
649                "authenticators": [],
650                "oauthProviders": [],
651            },
652        ],
653        "rootQuorumThreshold": 1,
654        "wallet": {
655            "walletName": "cratefield",
656            "accounts": [
657                {
658                    "curve": "CURVE_SECP256K1",
659                    "pathFormat": "PATH_FORMAT_BIP32",
660                    "path": "m/44'/60'/0'/0/0",
661                    "addressFormat": "ADDRESS_FORMAT_ETHEREUM",
662                },
663                {
664                    "curve": "CURVE_ED25519",
665                    "pathFormat": "PATH_FORMAT_BIP32",
666                    "path": "m/44'/501'/0'/0'",
667                    "addressFormat": "ADDRESS_FORMAT_SOLANA",
668                },
669            ],
670        },
671    })
672}