diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 0fa776cf2..4c5038158 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -8,4 +8,4 @@ ignore = [ # dev/test-only feature, restricted to cfg(not(target_arch = "wasm32"))), # never linked into the on-chain contract wasm. "RUSTSEC-2026-0222", -] \ No newline at end of file +] diff --git a/Cargo.lock b/Cargo.lock index 2c3b57390..26d9e60c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,7 +797,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1763,6 +1763,37 @@ dependencies = [ name = "defuse-nep461" version = "0.1.0" +[[package]] +name = "defuse-nep641" +version = "0.1.0" +dependencies = [ + "arbitrary", + "arbitrary_with", + "borsh", + "defuse-borsh-utils", + "defuse-crypto", + "defuse-digest", + "defuse-nep413", + "defuse-nep641", + "defuse-serde-utils", + "defuse-time", + "derive_more", + "digest-io", + "futures", + "hex", + "hex-literal", + "itertools", + "near-account-id", + "near-kit", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.19", + "tokio", + "tracing", +] + [[package]] name = "defuse-num-utils" version = "0.1.0" @@ -2022,6 +2053,7 @@ dependencies = [ "defuse-borsh-utils", "defuse-digest", "defuse-near-promise", + "defuse-nep641", "defuse-time", "defuse-wallet", "digest-io", @@ -2074,11 +2106,13 @@ dependencies = [ name = "defuse-wallet-sdk" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "borsh", "bs58 0.5.1", "defuse-mpc-signer", "defuse-near-sender", + "defuse-nep641", "defuse-wallet", "defuse-wallet-ed25519", "derive_more", @@ -2090,10 +2124,11 @@ dependencies = [ "rstest", "schemars 0.8.22", "serde", + "serde_json", "sha2 0.11.0", - "thiserror 2.0.19", "tokio", "tracing", + "tracing-subscriber", "trait-variant", ] @@ -2531,7 +2566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4980,7 +5015,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5371,7 +5406,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5430,7 +5465,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6236,10 +6271,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7435,7 +7470,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ffd7fe0e7..fd55a5e1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ members = [ "crates/signatures/erc191", "crates/signatures/nep413", "crates/signatures/nep461", + "crates/signatures/nep641", "crates/signatures/webauthn", "crates/signatures/sep53", "crates/signatures/tip191", @@ -125,6 +126,7 @@ defuse-token-id = { path = "crates/primitives/token-id", default-features = fals defuse-erc191.path = "crates/signatures/erc191" defuse-nep413.path = "crates/signatures/nep413" defuse-nep461.path = "crates/signatures/nep461" +defuse-nep641.path = "crates/signatures/nep641" defuse-sep53.path = "crates/signatures/sep53" defuse-tip191.path = "crates/signatures/tip191" defuse-ton-connect = { path = "crates/signatures/ton-connect", default-features = false } diff --git a/contracts/defuse/core/src/payload/nep413.rs b/contracts/defuse/core/src/payload/nep413.rs index 0052b7818..4194eeab0 100644 --- a/contracts/defuse/core/src/payload/nep413.rs +++ b/contracts/defuse/core/src/payload/nep413.rs @@ -1,4 +1,4 @@ -use defuse_crypto::ed25519::{Ed25519PublicKey, Ed25519Signature}; +use defuse_crypto::ed25519::{Ed25519, Ed25519PublicKey, Ed25519Signature}; pub use defuse_nep413::{Nep413, Nep413Payload}; use impl_tools::autoimpl; use near_sdk::AccountId; @@ -75,7 +75,7 @@ impl SignedPayload for SignedNep413Payload { type PublicKey = Ed25519PublicKey; fn verify(&self) -> Option { - Nep413::verify( + Nep413::verify::( &self.public_key.try_into().ok()?, &self.payload, &self.signature.into(), diff --git a/contracts/wallet/Cargo.toml b/contracts/wallet/Cargo.toml index 75e845648..1a91cddd0 100644 --- a/contracts/wallet/Cargo.toml +++ b/contracts/wallet/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true [dependencies] defuse-bitmap.workspace = true defuse-near-promise.workspace = true +defuse-nep641.workspace = true defuse-time.workspace = true near-account-id.workspace = true thiserror.workspace = true @@ -24,6 +25,7 @@ digest-io = { workspace = true, optional = true } impl-tools = { workspace = true, optional = true } schemars = { workspace = true, features = ["derive"], optional = true } serde = { workspace = true, features = ["derive"], optional = true } +serde_json = { workspace = true, optional = true } serde_with = { workspace = true, optional = true } near-sdk = { workspace = true, optional = true } @@ -31,12 +33,13 @@ near-sdk = { workspace = true, optional = true } [features] default = ["std"] -std = ["defuse-time/std"] +std = ["defuse-nep641/std", "defuse-time/std"] abi = ["borsh-schema", "near-sdk?/abi", "schemars-v0_8"] arbitrary = [ "defuse-bitmap/arbitrary", "defuse-near-promise/arbitrary", + "defuse-nep641/arbitrary", "defuse-time/arbitrary", "dep:arbitrary", "dep:arbitrary_with", @@ -45,6 +48,7 @@ arbitrary = [ borsh = [ "defuse-bitmap/borsh", "defuse-near-promise/borsh", + "defuse-nep641/borsh", "defuse-time/borsh", "dep:borsh", "dep:defuse-borsh-utils", @@ -56,14 +60,21 @@ borsh-schema = [ "defuse-bitmap/borsh-schema", "defuse-borsh-utils?/schema", "defuse-near-promise/borsh-schema", + "defuse-nep641/borsh-schema", "defuse-time/borsh-schema", "near-account-id/abi", ] -digest = ["dep:defuse-digest", "dep:digest-io"] -json = ["defuse-near-promise/json", "serde"] -near-kit = ["defuse-near-promise/near-kit"] +digest = ["defuse-nep641/digest", "dep:defuse-digest", "dep:digest-io"] +json = [ + "defuse-near-promise/json", + "defuse-nep641/json", + "dep:serde_json", + "serde", +] +near-kit = ["defuse-near-promise/near-kit", "defuse-nep641/near-kit"] schemars-v0_8 = [ "defuse-near-promise/schemars-v0_8", + "defuse-nep641/schemars-v0_8", "defuse-time/schemars-v0_8", "dep:schemars", "near-account-id/schemars-v0_8", @@ -71,6 +82,7 @@ schemars-v0_8 = [ ] serde = [ "defuse-near-promise/serde", + "defuse-nep641/serde", "defuse-time/serde", "dep:cfg_eval", "dep:serde", @@ -84,6 +96,7 @@ near-contract = [ "dep:impl-tools", "dep:near-sdk", "digest", + "json", "serde", "std", ] diff --git a/contracts/wallet/signatures/ed25519/src/lib.rs b/contracts/wallet/signatures/ed25519/src/lib.rs index 81975894a..b992c4960 100644 --- a/contracts/wallet/signatures/ed25519/src/lib.rs +++ b/contracts/wallet/signatures/ed25519/src/lib.rs @@ -12,16 +12,14 @@ use defuse_crypto::{ Curve, ed25519::{Ed25519, Ed25519PublicKey, Ed25519Signature}, }; -use defuse_wallet::{RequestMessage, SignatureSchema}; +use defuse_wallet::{RequestMessage, SignatureSchema, offchain::OffchainMessage}; /// Simple [`Ed25519`] wallet [signature schema](SignatureSchema) /// over [canonical request hash](RequestMessage::hash). pub struct WalletEd25519; -impl SignatureSchema for WalletEd25519 { - type PublicKey = Ed25519PublicKey; - - fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool { +impl WalletEd25519 { + fn verify_hash(public_key: &Ed25519PublicKey, hash: &[u8; 32], proof: &str) -> bool { let Ok(signature) = Ed25519Signature::from_str(proof) else { return false; }; @@ -30,7 +28,25 @@ impl SignatureSchema for WalletEd25519 { return false; }; - Ed25519::verify(&public_key, &msg.hash(), &signature.into()) + Ed25519::verify(&public_key, hash, &signature.into()) + } +} + +impl SignatureSchema for WalletEd25519 { + type PublicKey = Ed25519PublicKey; + + #[inline] + fn verify_request_msg(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool { + Self::verify_hash(public_key, &msg.hash(), proof) + } + + #[inline] + fn verify_offchain_msg( + public_key: &Self::PublicKey, + msg: &OffchainMessage, + proof: &str, + ) -> bool { + Self::verify_hash(public_key, &msg.hash(), proof) } } @@ -83,7 +99,7 @@ mod tests { #[case] proof: impl Into, ) { assert!( - WalletEd25519::verify(&public_key.into(), &msg, &proof.into().to_string()), + WalletEd25519::verify_request_msg(&public_key.into(), &msg, &proof.into().to_string()), "signature is invalid" ); } diff --git a/contracts/wallet/signatures/ed25519/src/signer.rs b/contracts/wallet/signatures/ed25519/src/signer.rs index 25485d648..524446938 100644 --- a/contracts/wallet/signatures/ed25519/src/signer.rs +++ b/contracts/wallet/signatures/ed25519/src/signer.rs @@ -31,7 +31,7 @@ use crate::WalletEd25519; /// let (msg, proof) = wallet.sign(Request::new()).await?; /// /// assert!( -/// WalletEd25519::verify(&wallet.public_key(), &msg, &proof), +/// WalletEd25519::verify_request_msg(&wallet.public_key(), &msg, &proof), /// "signer produced invalid signature", /// ); /// # Ok::<_, Box>(()) }).unwrap(); @@ -39,6 +39,17 @@ use crate::WalletEd25519; #[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::From, derive_more::AsRef)] pub struct WalletEd25519Signer(pub S); +impl WalletEd25519Signer +where + S: Signer, +{ + async fn sign_hash(&self, hash: &[u8; 32]) -> Result { + let sig = self.0.sign(hash).await?; + + Ok(Ed25519Signature::from(sig).to_string()) + } +} + impl WalletSigner for WalletEd25519Signer where S: Signer, @@ -50,10 +61,15 @@ where self.0.public_key().into() } - async fn sign_wallet_msg(&self, msg: &RequestMessage) -> Result { - let sig = self.0.sign(&msg.hash()).await?; + async fn sign_request_msg(&self, msg: &RequestMessage) -> Result { + self.sign_hash(&msg.hash()).await + } - Ok(Ed25519Signature::from(sig).to_string()) + async fn sign_offchain_msg( + &self, + msg: &defuse_wallet::offchain::OffchainMessage, + ) -> Result { + self.sign_hash(&msg.hash()).await } } @@ -81,12 +97,12 @@ mod tests { request: Request::new(), }; - let proof = WalletSigner::::sign_wallet_msg(&signer, &msg) + let proof = WalletSigner::::sign_request_msg(&signer, &msg) .await .unwrap(); assert!( - WalletEd25519::verify( + WalletEd25519::verify_request_msg( &WalletSigner::::public_key(&signer), &msg, &proof diff --git a/contracts/wallet/signatures/no-sign/src/lib.rs b/contracts/wallet/signatures/no-sign/src/lib.rs index 16808996e..7660162f3 100644 --- a/contracts/wallet/signatures/no-sign/src/lib.rs +++ b/contracts/wallet/signatures/no-sign/src/lib.rs @@ -6,7 +6,7 @@ use core::{ str::FromStr, }; -use defuse_wallet::{RequestMessage, SignatureSchema}; +use defuse_wallet::{RequestMessage, SignatureSchema, offchain::OffchainMessage}; /// [`SignatureSchema`] which always rejects the signature. /// @@ -19,7 +19,20 @@ impl SignatureSchema for NoSign { type PublicKey = NoPublicKey; #[inline] - fn verify(_public_key: &Self::PublicKey, _msg: &RequestMessage, _proof: &str) -> bool { + fn verify_request_msg( + _public_key: &Self::PublicKey, + _msg: &RequestMessage, + _proof: &str, + ) -> bool { + false + } + + #[inline] + fn verify_offchain_msg( + _public_key: &Self::PublicKey, + _msg: &OffchainMessage, + _proof: &str, + ) -> bool { false } } diff --git a/contracts/wallet/signatures/webauthn/src/ed25519.rs b/contracts/wallet/signatures/webauthn/src/ed25519.rs index 85c155c0c..3943ec392 100644 --- a/contracts/wallet/signatures/webauthn/src/ed25519.rs +++ b/contracts/wallet/signatures/webauthn/src/ed25519.rs @@ -36,12 +36,12 @@ mod tests { request: Request::new(), }; - let proof = WalletSigner::::sign_wallet_msg(&signer, &msg) + let proof = WalletSigner::::sign_request_msg(&signer, &msg) .await .unwrap(); assert!( - SS::verify(&WalletSigner::::public_key(&signer), &msg, &proof), + SS::verify_request_msg(&WalletSigner::::public_key(&signer), &msg, &proof), "signer produced invalid signature" ); } diff --git a/contracts/wallet/signatures/webauthn/src/lib.rs b/contracts/wallet/signatures/webauthn/src/lib.rs index 804f7abfb..050281c3d 100644 --- a/contracts/wallet/signatures/webauthn/src/lib.rs +++ b/contracts/wallet/signatures/webauthn/src/lib.rs @@ -11,7 +11,7 @@ pub use defuse_webauthn as webauthn; use core::marker::PhantomData; use defuse_crypto::Curve; -use defuse_wallet::{RequestMessage, SignatureSchema}; +use defuse_wallet::{RequestMessage, SignatureSchema, offchain::OffchainMessage}; use defuse_webauthn::{Algorithm, UserVerification, Webauthn, WebauthnAssertion}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -20,16 +20,14 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned}; /// See [`Webauthn`] for more. pub struct WalletWebauthn(PhantomData>); -impl SignatureSchema for WalletWebauthn +impl WalletWebauthn where A: WalletWebauthnAlgorithm, UV: UserVerification, ::Signature: TryFrom, for<'a> ::PublicKey: TryFrom<&'a A::PublicKey>, { - type PublicKey = A::PublicKey; - - fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool { + fn verify_hash(public_key: &A::PublicKey, hash: &[u8; 32], proof: &str) -> bool { // try to convert public key let Ok(public_key) = ::PublicKey::try_from(public_key) else { return false; @@ -51,7 +49,31 @@ where // * Authenticators are general-purpose signers and they usually // implement blind singing. // * This reduces length of the `proof` submitted on-chain. - Webauthn::::verify(&public_key, msg.hash(), &proof.assertion, &signature) + Webauthn::::verify(&public_key, hash, &proof.assertion, &signature) + } +} + +impl SignatureSchema for WalletWebauthn +where + A: WalletWebauthnAlgorithm, + UV: UserVerification, + ::Signature: TryFrom, + for<'a> ::PublicKey: TryFrom<&'a A::PublicKey>, +{ + type PublicKey = A::PublicKey; + + #[inline] + fn verify_request_msg(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool { + Self::verify_hash(public_key, &msg.hash(), proof) + } + + #[inline] + fn verify_offchain_msg( + public_key: &Self::PublicKey, + msg: &OffchainMessage, + proof: &str, + ) -> bool { + Self::verify_hash(public_key, &msg.hash(), proof) } } diff --git a/contracts/wallet/signatures/webauthn/src/mock.rs b/contracts/wallet/signatures/webauthn/src/mock.rs index 5b9db9263..7382f3e4f 100644 --- a/contracts/wallet/signatures/webauthn/src/mock.rs +++ b/contracts/wallet/signatures/webauthn/src/mock.rs @@ -1,5 +1,5 @@ use defuse_crypto::{Curve, Signer}; -use defuse_wallet::RequestMessage; +use defuse_wallet::{RequestMessage, offchain::OffchainMessage}; use defuse_wallet_sdk::{Proof, WalletSigner}; use defuse_webauthn::{UserVerification, mock::MockWebauthnSigner}; use impl_tools::autoimpl; @@ -30,6 +30,19 @@ where pub const fn signer(&self) -> &S { self.0.signer() } + + async fn sign_hash(&self, hash: &[u8; 32]) -> Result + where + A::Signature: Serialize + From<::Signature>, + { + let (assertion, signature) = self.0.sign(hash).await?; + + Ok(serde_json::to_string(&WalletWebauthnProof:: { + signature: signature.into(), + assertion, + }) + .expect("JSON: failed to serialize")) + } } impl WalletSigner> for MockWalletWebauthnSigner @@ -49,13 +62,11 @@ where self.signer().public_key().into() } - async fn sign_wallet_msg(&self, msg: &RequestMessage) -> Result { - let (assertion, signature) = self.0.sign(msg.hash()).await?; + async fn sign_request_msg(&self, msg: &RequestMessage) -> Result { + self.sign_hash(&msg.hash()).await + } - Ok(serde_json::to_string(&WalletWebauthnProof:: { - signature: signature.into(), - assertion, - }) - .expect("JSON: failed to serialize")) + async fn sign_offchain_msg(&self, msg: &OffchainMessage) -> Result { + self.sign_hash(&msg.hash()).await } } diff --git a/contracts/wallet/signatures/webauthn/src/p256.rs b/contracts/wallet/signatures/webauthn/src/p256.rs index e46b80cdd..0a66f793e 100644 --- a/contracts/wallet/signatures/webauthn/src/p256.rs +++ b/contracts/wallet/signatures/webauthn/src/p256.rs @@ -54,7 +54,11 @@ mod tests { #[case] proof: &str, ) { assert!( - WalletWebauthn::::verify(&public_key.into(), &msg, proof), + WalletWebauthn::::verify_request_msg( + &public_key.into(), + &msg, + proof + ), "signature is invalid" ); } @@ -75,12 +79,12 @@ mod tests { request: Request::new(), }; - let proof = WalletSigner::::sign_wallet_msg(&signer, &msg) + let proof = WalletSigner::::sign_request_msg(&signer, &msg) .await .unwrap(); assert!( - SS::verify(&WalletSigner::::public_key(&signer), &msg, &proof), + SS::verify_request_msg(&WalletSigner::::public_key(&signer), &msg, &proof), "signer produced invalid signature" ); } diff --git a/contracts/wallet/src/contract.rs b/contracts/wallet/src/contract.rs index 2538a3cd8..e876fca4e 100644 --- a/contracts/wallet/src/contract.rs +++ b/contracts/wallet/src/contract.rs @@ -12,6 +12,7 @@ use std::{collections::BTreeSet, fmt::Display}; use borsh::{BorshDeserialize, BorshSerialize}; use defuse_near_promise::{NearPromise, actions::NearAction}; +use defuse_nep641::{AuthResolver, AuthorizationResolution}; use defuse_time::Timestamp; use impl_tools::autoimpl; use near_account_id::{AccountId, AccountIdRef}; @@ -19,7 +20,7 @@ use near_sdk::{FunctionError, Promise, env, ext_contract}; pub use crate::ContractError as Error; use crate::{ - Request, RequestMessage, SignatureSchema, State, WalletOp, + Request, RequestMessage, SignatureSchema, State, WalletAuthorization, WalletOp, events::{Actor, WalletEvent}, }; @@ -45,7 +46,7 @@ pub trait Wallet { /// [`env::current_account_id()`](near_sdk::env::current_account_id) /// * [`msg.nonce`](RequestMessage::nonce) is already used, expired or /// from the future - /// * `proof` is [invalid](SignatureSchema::verify) or signature is + /// * `proof` is [invalid](SignatureSchema::verify_request_msg) or signature is /// [currently disabled](WalletOp::SetSignatureMode) fn w_execute_signed(&mut self, msg: RequestMessage, proof: String); @@ -96,24 +97,11 @@ pub trait Wallet { #[autoimpl(Debug where S::PublicKey: trait)] #[repr(transparent)] pub struct WalletImpl( - // TODO: simplify when https://github.com/near/borsh-rs/pull/373 is released - #[cfg_attr( - not(feature = "borsh-schema"), - borsh(bound( - serialize = "S::PublicKey: BorshSerialize", - deserialize = "S::PublicKey: BorshDeserialize", - )) - )] - #[cfg_attr( - feature = "borsh-schema", - borsh( - bound( - serialize = "S::PublicKey: BorshSerialize", - deserialize = "S::PublicKey: BorshDeserialize", - ), - schema(params = "S => S::PublicKey"), - ) - )] + #[borsh(bound( + serialize = "S::PublicKey: BorshSerialize", + deserialize = "S::PublicKey: BorshDeserialize", + ))] + #[cfg_attr(feature = "borsh-schema", borsh(schema(params = "S => S::PublicKey"),))] State, ); @@ -179,6 +167,10 @@ where S: SignatureSchema, { fn execute_signed(&mut self, msg: RequestMessage, proof: &str) -> Result<()> { + if !self.0.is_signature_allowed() { + return Err(Error::SignatureDisabled); + } + // TODO: change to the following when External Contract Calls land: // if !msg.pay_for_gas && env::is_external() { // return Err(Error::UnauthorizedGasPayment); @@ -187,10 +179,6 @@ where env::panic_str("`pay_for_gas` is not currently supported"); } - if !self.0.is_signature_allowed() { - return Err(Error::SignatureDisabled); - } - // check chain_id if msg.chain_id != env::chain_id() { return Err(Error::InvalidChainId); @@ -207,7 +195,7 @@ where .commit(msg.nonce, msg.created_at, msg.timeout)?; // verify signature - if !S::verify(&self.0.public_key, &msg, proof) { + if !S::verify_request_msg(&self.0.public_key, &msg, proof) { return Err(Error::InvalidSignature); } @@ -341,6 +329,85 @@ where } } +impl AuthResolver for WalletImpl +where + S: SignatureSchema, +{ + #[inline] + fn w_resolve_auth( + &self, + path: Vec, + authorization: String, + ) -> AuthorizationResolution { + self.resolve_auth(&path, &authorization) + .unwrap_or_else(|err| err.panic()) + } +} + +impl WalletImpl +where + S: SignatureSchema, +{ + fn resolve_auth( + &self, + path: &[AccountId], + authorization: &str, + ) -> Result { + let input: WalletAuthorization = serde_json::from_str(authorization)?; + + Ok(match input { + WalletAuthorization::Signature { msg, proof } => { + if !self.0.is_signature_allowed() { + return Err(Error::SignatureDisabled); + } + + // check chain_id + if msg.chain_id != env::chain_id() { + return Err(Error::InvalidChainId); + } + + // check signer_id + if msg.signer_id != env::current_account_id() { + return Err(Error::InvalidSignerId(msg.signer_id)); + } + + // check path + if msg.path != path { + return Err(Error::InvalidPath); + } + + // check timestamp + if Timestamp::now() < msg.timestamp { + return Err(Error::FromTheFuture); + } + + // verify signature + if !S::verify_offchain_msg(&self.0.public_key, &msg, &proof) { + return Err(Error::InvalidSignature); + } + + // authorize the payload + AuthorizationResolution::new(msg.payload) + } + WalletAuthorization::Extension { + account_id, + authorization, + payload, + } => { + // check whether extension is enabled + self.check_extension_enabled(&account_id)?; + + // authorize the payload if and only if the extension authorizes the same one + AuthorizationResolution::new(payload.clone()).add_pending( + account_id, + authorization, + payload, + ) + } + }) + } +} + impl From> for WalletImpl { #[inline] fn from(state: State) -> Self { @@ -355,7 +422,7 @@ impl From> for WalletImpl { /// /// ```rust /// # use core::fmt::{self, Display}; -/// use defuse_wallet::{RequestMessage, SignatureSchema, wallet}; +/// use defuse_wallet::{RequestMessage, SignatureSchema, wallet, offchain::OffchainMessage}; /// use near_sdk::near; /// /// // Define the contract struct and impl @@ -382,7 +449,15 @@ impl From> for WalletImpl { /// /// key and return whether verification passed. /// /// /// /// Used by the `w_execute_signed(msg, proof)` contract method. -/// fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool { +/// fn verify_request_msg(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool { +/// todo!("verify signature over `msg` in respect to the public key") +/// } +/// +/// /// Verify given proof over the NEP-641 offchain message in respect to the public key +/// /// and return whether verification passed. +/// /// +/// /// Used by the `w_resolve_auth(path, authorization)` contract method. +/// fn verify_offchain_msg(public_key: &Self::PublicKey, msg: &OffchainMessage, proof: &str) -> bool { /// todo!("verify signature over `msg` in respect to the public key") /// } /// } @@ -482,5 +557,17 @@ macro_rules! wallet { self.0.w_last_cleaned_at() } } + + #[$crate::near_sdk::near] + impl $crate::offchain::AuthResolver for $contract { + /// Resolve offchain authorization. + fn w_resolve_auth( + &self, + path: ::std::vec::Vec<$crate::AccountId>, + authorization: ::std::string::String, + ) -> $crate::offchain::AuthorizationResolution { + self.0.w_resolve_auth(path, authorization) + } + } }; } diff --git a/contracts/wallet/src/error.rs b/contracts/wallet/src/error.rs index 328eb39c7..04da420b9 100644 --- a/contracts/wallet/src/error.rs +++ b/contracts/wallet/src/error.rs @@ -14,17 +14,27 @@ pub enum ContractError { #[error("extension '{0}' is not enabled")] ExtensionNotEnabled(AccountId), + #[error("message is from the future")] + FromTheFuture, + + #[error("insufficient attached deposit")] + InsufficientDeposit, + #[error("invalid chain_id")] InvalidChainId, + #[error("invalid path")] + InvalidPath, + #[error("invalid signature")] InvalidSignature, #[error("invalid signer_id: {0}")] InvalidSignerId(AccountId), - #[error("insufficient attached deposit")] - InsufficientDeposit, + #[cfg(feature = "json")] + #[error("JSON: {0}")] + JSON(#[from] serde_json::Error), #[error("lockout: signature is disabled and extensions are empty")] Lockout, @@ -35,7 +45,7 @@ pub enum ContractError { #[error("self-calls are not allowed")] SelfCallsNotAllowed, - #[error("signature is disabled")] + #[error("signature is disabled, use extensions to act on behalf of this wallet")] SignatureDisabled, #[error("this signature mode is already set")] diff --git a/contracts/wallet/src/lib.rs b/contracts/wallet/src/lib.rs index f31b19086..bd7062b79 100644 --- a/contracts/wallet/src/lib.rs +++ b/contracts/wallet/src/lib.rs @@ -11,10 +11,11 @@ mod schema; mod state; pub use self::{error::*, message::*, nonces::*, request::*, schema::*, state::*}; +pub use defuse_nep641 as offchain; pub use defuse_time::Timestamp; pub use near_account_id::{AccountId, AccountIdRef}; // re-export for `wallet!` macro -#[doc(hidden)] #[cfg(feature = "near-contract")] +#[doc(hidden)] pub use near_sdk; diff --git a/contracts/wallet/src/message.rs b/contracts/wallet/src/message.rs index ca8a4d119..12d700bf0 100644 --- a/contracts/wallet/src/message.rs +++ b/contracts/wallet/src/message.rs @@ -1,5 +1,6 @@ use core::time::Duration; +use defuse_nep641::OffchainMessage; use defuse_time::Timestamp; use near_account_id::AccountId; @@ -15,13 +16,6 @@ use defuse_time::arbitrary::RangeNanos; #[cfg(feature = "serde")] use serde_with::DurationSeconds; -/// Domain prefix for signing [`RequestMessage`]. -/// -/// This prefix doesn't break NEP-461 assumptions, since first four bytes -/// borsh-deserialize to `1380009294u32`, which is in `[1 << 30, 1 << 31)` -/// range for on-chain messages. -pub const WALLET_DOMAIN: &[u8] = b"NEAR_WALLET_CONTRACT/V1"; - /// Chain id (e.g. `mainnet`) pub type ChainId = String; @@ -151,6 +145,9 @@ pub struct RequestMessage { } impl RequestMessage { + /// A prefix used for [canonical hash](Self::hash). + pub const DOMAIN_SEPARATOR: &[u8] = b"NEAR_WALLET_CONTRACT/V1"; + /// Returns canonical hash of the request message: /// /// ```text @@ -183,7 +180,7 @@ impl RequestMessage { use defuse_digest::{Digest, sha3::Sha3_256}; use digest_io::IoWrapper; - let mut hasher = IoWrapper(Sha3_256::new_with_prefix(WALLET_DOMAIN)); + let mut hasher = IoWrapper(Sha3_256::new_with_prefix(Self::DOMAIN_SEPARATOR)); // serialize directly to hasher ::borsh::to_writer(&mut hasher, self).expect("borsh: failed to serialize"); @@ -200,7 +197,7 @@ impl RequestMessage { /// reques has already expired or is from the future. #[cfg(feature = "std")] #[inline] - pub fn time_left(&self) -> Option { + pub fn duration_left(&self) -> Option { let now = Timestamp::now(); if now < self.created_at { return None; @@ -209,6 +206,95 @@ impl RequestMessage { } } +/// NEP-641 authorization for [`Wallet`](crate::contract::Wallet) contract. +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)), + serde(rename_all = "snake_case") +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum WalletAuthorization { + /// Authorize via signature. + Signature { + /// Offchain Message + msg: OffchainMessage, + /// Proof (i.e. signature) + proof: String, + }, + + /// Authorize via pending sub-authorization on an **enabled** extension. + Extension { + /// Extension ID. + /// + /// MUST fail if the extension is not enabled. + account_id: AccountId, + /// Sub-authorization blob for this extension. + authorization: String, + /// Payload to authorize, provided the extension resolves the sub-authorization to + /// **exactly the same** value. + payload: String, + }, +} + +impl WalletAuthorization { + /// Get the authorized payload + #[inline] + pub const fn payload(&self) -> &str { + match self { + Self::Signature { + msg: OffchainMessage { payload, .. }, + .. + } + | Self::Extension { payload, .. } => payload.as_str(), + } + } + + /// Extract the authorized payload + #[inline] + pub fn into_payload(self) -> String { + match self { + Self::Signature { + msg: OffchainMessage { payload, .. }, + .. + } + | Self::Extension { payload, .. } => payload, + } + } + + /// Wrap as extension with given ID + #[cfg(feature = "json")] + #[must_use] + #[inline] + pub fn as_extension_of(self, account_id: impl Into) -> Self { + Self::Extension { + account_id: account_id.into(), + authorization: (&self).into(), + payload: self.into_payload(), + } + } +} + +#[cfg(feature = "json")] +const _: () = { + impl From<&WalletAuthorization> for String { + /// Convert to the authorization blob + #[inline] + fn from(auth: &WalletAuthorization) -> Self { + serde_json::to_string(auth).expect("JSON: failed to serialize") + } + } + + impl From for String { + /// Convert to the authorization blob + #[inline] + fn from(auth: WalletAuthorization) -> Self { + (&auth).into() + } + } +}; + #[cfg(test)] mod tests { use hex_literal::hex; diff --git a/contracts/wallet/src/nonces.rs b/contracts/wallet/src/nonces.rs index 56d5949ab..ff0dee9b6 100644 --- a/contracts/wallet/src/nonces.rs +++ b/contracts/wallet/src/nonces.rs @@ -1,4 +1,4 @@ -use core::{mem, time::Duration}; +use core::time::Duration; use std::collections::BTreeMap; use defuse_bitmap::BitMap; @@ -141,7 +141,7 @@ impl Nonces { // check if it's time to rotate if self.last_cleaned_at < last_valid_nonce_at { // rotate current -> old - self.old = mem::take(&mut self.current); + self.old = std::mem::take(&mut self.current); // check if `2 * timeout` has passed since last rotation if self.last_cleaned_at < last_valid_nonce_at - self.timeout { // cleanup old nonces diff --git a/contracts/wallet/src/request/ops.rs b/contracts/wallet/src/request/ops.rs index 8a6e0260e..66af92e7b 100644 --- a/contracts/wallet/src/request/ops.rs +++ b/contracts/wallet/src/request/ops.rs @@ -32,3 +32,34 @@ pub enum WalletOp { /// If this extension is not currently enabled, the contract MUST panic. RemoveExtension { account_id: AccountId } = 2, } + +impl WalletOp { + #[inline] + pub const fn set_signature_mode(enable: bool) -> Self { + Self::SetSignatureMode { enable } + } + + #[inline] + pub const fn enable_signature() -> Self { + Self::set_signature_mode(true) + } + + #[inline] + pub const fn disable_signature() -> Self { + Self::set_signature_mode(false) + } + + #[inline] + pub fn add_extension(account_id: impl Into) -> Self { + Self::AddExtension { + account_id: account_id.into(), + } + } + + #[inline] + pub fn remove_extension(account_id: impl Into) -> Self { + Self::RemoveExtension { + account_id: account_id.into(), + } + } +} diff --git a/contracts/wallet/src/schema.rs b/contracts/wallet/src/schema.rs index 61aa7c20f..4baa56560 100644 --- a/contracts/wallet/src/schema.rs +++ b/contracts/wallet/src/schema.rs @@ -1,3 +1,5 @@ +use defuse_nep641::OffchainMessage; + use crate::RequestMessage; /// Signature schema used by [`Wallet`](crate::contract::Wallet) contract @@ -15,10 +17,21 @@ pub trait SignatureSchema { /// method. type PublicKey; - /// Verify given proof over the request message in respect to the - /// public key and return whether verification passed. + /// Verify given proof over the request message in respect to the public key + /// and return whether verification passed. + /// + /// Used by the [`w_execute_signed()`](crate::contract::Wallet::w_execute_signed) contract method. + #[must_use = "check if verification passed"] + fn verify_request_msg(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool; + + /// Verify given proof over the NEP-641 offchain message in respect to the public key + /// and return whether verification passed. /// - /// Used by the `w_execute_signed(msg, proof)` contract method. + /// Used by the [`w_resolve_auth()`](defuse_nep641::AuthResolver::w_resolve_auth) contract method. #[must_use = "check if verification passed"] - fn verify(public_key: &Self::PublicKey, msg: &RequestMessage, proof: &str) -> bool; + fn verify_offchain_msg( + public_key: &Self::PublicKey, + msg: &OffchainMessage, + proof: &str, + ) -> bool; } diff --git a/crates/crypto/src/fmt.rs b/crates/crypto/src/fmt.rs index 35bfc38e1..7c6d27772 100644 --- a/crates/crypto/src/fmt.rs +++ b/crates/crypto/src/fmt.rs @@ -55,6 +55,7 @@ pub enum ParseCurveError { /// checked_base58_decode_array::<9>("he11owor1d").expect_err("buffer too large"); /// # Ok::<(), ParseCurveError>(()) /// ``` +#[inline] pub fn checked_base58_decode_array( input: impl AsRef<[u8]>, ) -> Result<[u8; N], ParseCurveError> { diff --git a/crates/serde-utils/src/lib.rs b/crates/serde-utils/src/lib.rs index 7273b448b..a2c0b1f54 100644 --- a/crates/serde-utils/src/lib.rs +++ b/crates/serde-utils/src/lib.rs @@ -6,3 +6,6 @@ pub mod hex; #[cfg(feature = "tlb")] pub mod tlb; + +mod seq; +pub use self::seq::*; diff --git a/crates/serde-utils/src/seq.rs b/crates/serde-utils/src/seq.rs new file mode 100644 index 000000000..25183181a --- /dev/null +++ b/crates/serde-utils/src/seq.rs @@ -0,0 +1,40 @@ +use serde::{Serialize, Serializer}; +use serde_with::SerializeAs; + +/// An adaptor for serializing a sequence backwards +/// +/// # Examples +/// +/// ```rust +/// # use serde::Serialize; +/// # use serde_json::json; +/// use defuse_serde_utils::Reversed; +/// use serde_with::serde_as; +/// +/// #[serde_as] +/// #[derive(Serialize)] +/// struct A { +/// #[serde_as(as = "Reversed")] +/// #[serde(rename = "list")] +/// rev_list: Vec, +/// } +/// +/// assert_eq!( +/// serde_json::to_value(&A { rev_list: vec![3, 2, 1] }).unwrap(), +/// json!({ "list": [1, 2, 3] }), +/// ); +/// ``` +pub struct Reversed; + +impl SerializeAs<[T]> for Reversed +where + T: Serialize, +{ + #[inline] + fn serialize_as(source: &[T], serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_seq(source.iter().rev()) + } +} diff --git a/crates/signatures/nep413/Cargo.toml b/crates/signatures/nep413/Cargo.toml index 13a3b39ed..4243a2b8b 100644 --- a/crates/signatures/nep413/Cargo.toml +++ b/crates/signatures/nep413/Cargo.toml @@ -8,7 +8,7 @@ rust-version.workspace = true repository.workspace = true [dependencies] -defuse-crypto = { workspace = true, features = ["ed25519"] } +defuse-crypto.workspace = true defuse-digest = { workspace = true, features = ["sha2"] } defuse-nep461.workspace = true @@ -32,5 +32,7 @@ schemars-v0_8 = ["dep:schemars", "serde", "serde_with?/schemars_0_8"] [dev-dependencies] defuse-nep413 = { path = ".", features = ["arbitrary", "borsh-schema", "serde", "schemars-v0_8"] } + +defuse-crypto = { workspace = true, features = ["ed25519"] } hex-literal.workspace = true rstest.workspace = true diff --git a/crates/signatures/nep413/src/lib.rs b/crates/signatures/nep413/src/lib.rs index c2ec4196a..af67148ed 100644 --- a/crates/signatures/nep413/src/lib.rs +++ b/crates/signatures/nep413/src/lib.rs @@ -4,7 +4,7 @@ use core::fmt::Display; use borsh::{BorshDeserialize, BorshSerialize}; -use defuse_crypto::{Curve, ed25519::Ed25519}; +use defuse_crypto::Curve; use defuse_digest::{Digest, sha2::Sha256}; use defuse_nep461::{OffchainMessage, SignedMessageNep}; use digest_io::IoWrapper; @@ -18,12 +18,12 @@ impl Nep413 { /// [NEP-413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md). #[must_use = "check if verification passed"] #[inline] - pub fn verify( - public_key: &::PublicKey, + pub fn verify( + public_key: &C::PublicKey, payload: &Nep413Payload, - signature: &::Signature, + signature: &C::Signature, ) -> bool { - Ed25519::verify(public_key, &Self::prehash(payload), signature) + C::verify(public_key, &Self::prehash(payload), signature) } /// Derive prehash for signing. @@ -133,7 +133,7 @@ const _: () = { #[cfg(test)] mod tests { - use defuse_crypto::ed25519::{Ed25519PublicKey, Ed25519Signature}; + use defuse_crypto::ed25519::{Ed25519, Ed25519PublicKey, Ed25519Signature}; use hex_literal::hex; use rstest::rstest; @@ -155,7 +155,7 @@ mod tests { #[case] payload: Nep413Payload, #[case] signature: impl Into, ) { - assert!(Nep413::verify( + assert!(Nep413::verify::( &public_key.into().try_into().unwrap(), &payload, &signature.into().into() diff --git a/crates/signatures/nep641/Cargo.toml b/crates/signatures/nep641/Cargo.toml new file mode 100644 index 000000000..6c8576480 --- /dev/null +++ b/crates/signatures/nep641/Cargo.toml @@ -0,0 +1,114 @@ +lints.workspace = true + +[package] +name = "defuse-nep641" +edition.workspace = true +version.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +defuse-time.workspace = true +near-account-id.workspace = true + +arbitrary = { workspace = true, features = ["derive"], optional = true } +arbitrary_with = { workspace = true, optional = true } +borsh = { workspace = true, features = ["derive"], optional = true } +defuse-borsh-utils = { workspace = true, optional = true } +defuse-crypto = { workspace = true, features = ["ed25519", "secp256k1", "fmt"], optional = true } +defuse-digest = { workspace = true, features = ["sha3"], optional = true } +defuse-nep413 = { workspace = true, optional = true } +defuse-serde-utils = { workspace = true, optional = true } +derive_more = { workspace = true, optional = true } +digest-io = { workspace = true, optional = true } +futures = { workspace = true, features = ["std"], optional = true } +hex = { workspace = true, optional = true } +itertools = { workspace = true, optional = true } +near-kit = { workspace = true, optional = true } +schemars = { workspace = true, features = ["derive"], optional = true } +serde = { workspace = true, features = ["derive"], optional = true } +serde_json = { workspace = true, optional = true } +serde_with = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } + +[features] +default = ["std"] + +abi = ["borsh-schema", "schemars-v0_8"] +arbitrary = [ + "defuse-crypto?/arbitrary", + "defuse-nep413?/arbitrary", + "defuse-time/arbitrary", + "dep:arbitrary", + "dep:arbitrary_with", + "near-account-id/arbitrary", +] +borsh = [ + "defuse-crypto?/borsh", + "defuse-time/borsh", + "dep:borsh", + "dep:defuse-borsh-utils", + "near-account-id/borsh", +] +borsh-schema = [ + "borsh", + "borsh?/unstable__schema", + "defuse-borsh-utils?/schema", + "defuse-crypto?/borsh-schema", + "defuse-nep413?/borsh-schema", + "defuse-time/borsh-schema", + "near-account-id/abi", +] +digest = ["dep:defuse-digest", "dep:digest-io"] +json = ["dep:serde_json", "serde"] +near-kit = [ + "defuse-nep413?/near-kit", + "dep:defuse-serde-utils", + "dep:near-kit", + "serde", +] +nep413 = ["borsh", "dep:defuse-nep413", "dep:itertools", "digest"] +access-keys = [ + "dep:defuse-crypto", + "dep:derive_more", + "dep:hex", + "derive_more/from", + "nep413", +] +serde = [ + "defuse-crypto?/serde", + "defuse-nep413?/serde", + "defuse-time/serde", + "dep:serde", + "dep:serde_with", + "near-account-id/serde", +] +schemars-v0_8 = [ + "defuse-crypto?/schemars-v0_8", + "defuse-nep413?/schemars-v0_8", + "defuse-time/schemars-v0_8", + "dep:schemars", + "json", + "near-account-id/schemars-v0_8", + "serde", + "serde_with/schemars_0_8", +] +std = ["defuse-time/std"] +tracing = ["dep:tracing", "near-kit?/tracing"] +resolver = [ + "access-keys", + "dep:futures", + "dep:itertools", + "dep:thiserror", + "json", + "near-kit", + "serde", +] + +[dev-dependencies] +defuse-nep641 = { path = ".", features = ["abi", "borsh", "digest", "serde", "near-kit", "resolver", "tracing"] } + +hex-literal.workspace = true +near-kit = { workspace = true, features = ["sandbox"] } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/signatures/nep641/src/access_keys.rs b/crates/signatures/nep641/src/access_keys.rs new file mode 100644 index 000000000..50cb0159b --- /dev/null +++ b/crates/signatures/nep641/src/access_keys.rs @@ -0,0 +1,424 @@ +//! Authorization via full-access keys + +use core::{ + fmt::{self, Debug, Display}, + str::FromStr, +}; + +use defuse_crypto::{ + ed25519::{Ed25519, Ed25519PublicKey, Ed25519Signature}, + fmt::{ParseCurveError, TypedCurve, checked_base58_decode_array}, + secp256k1::{Secp256k1, Secp256k1RecoverableSignature, Secp256k1UncompressedPublicKey}, +}; +use defuse_digest::{Digest, sha3::Keccak256}; +use defuse_nep413::Nep413; +use near_account_id::AccountId; + +use crate::OffchainMessage; + +/// Authorization via full-access key +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)), + // reduce collisions with other authorization schemas on offchain resolver + serde(deny_unknown_fields), +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AccessKeyAuthorization { + /// Signed offchain message + pub msg: OffchainMessage, + + /// Signature schema and additional metadata used during signing process + pub via: AccessKeySchema, + + /// Access key with `FullAccess` permission + pub access_key: PublicKey, + + /// Signature + pub signature: Signature, +} + +impl AccessKeyAuthorization { + /// Verify the signature according to the signature schema used + #[must_use = "check if verification passed"] + #[inline] + pub fn verify(&self) -> bool { + self.via + .verify(&self.msg, &self.access_key, &self.signature) + } +} + +#[cfg(feature = "json")] +const _: () = { + impl From<&AccessKeyAuthorization> for String { + /// Convert to the authorization blob + #[inline] + fn from(auth: &AccessKeyAuthorization) -> Self { + serde_json::to_string(auth).expect("JSON: failed to serialize") + } + } + + impl From for String { + /// Convert to the authorization blob + #[inline] + fn from(auth: AccessKeyAuthorization) -> Self { + (&auth).into() + } + } +}; + +/// Signature schema and additional metadata used during signing of [`AccessKeyAuthorization`]. +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)), + serde(rename_all = "snake_case") +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum AccessKeySchema { + /// [NEP-413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md) signing schema. + Nep413 { + /// Optional callback URL, applicable to browser wallets. The URL to call after the signing + /// process. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + callback_url: Option, + }, +} + +impl AccessKeySchema { + /// Verify the signature + #[must_use = "check if verification passed"] + fn verify(&self, msg: &OffchainMessage, public_key: &PublicKey, signature: &Signature) -> bool { + // only NEP-413 is supported for now + let Self::Nep413 { callback_url } = self; + + // convert offchain message into NEP-413 payload + let payload = msg.clone().into_nep413_payload(callback_url.clone()); + + // verify + match (public_key, signature) { + // ed25519 + (PublicKey::Ed25519(pk), Signature::Ed25519(sig)) => { + let Ok(pk) = pk.try_into() else { + return false; + }; + Nep413::verify::(&pk, &payload, &sig.into()) + } + + // secp256k1 + (PublicKey::Secp256k1(pk), Signature::Secp256k1(sig)) => { + let Ok(pk) = pk.try_into() else { + return false; + }; + let Ok(sig) = sig.try_into() else { + return false; + }; + Nep413::verify::(&pk, &payload, &sig) + } + + // curve mismatch + _ => false, + } + } +} + +/// Public key for [`AccessKeyAuthorization`] +#[cfg_attr( + feature = "serde", + derive(::serde_with::SerializeDisplay, ::serde_with::DeserializeFromStr) +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[cfg_attr( + feature = "borsh", + derive(::borsh::BorshSerialize, ::borsh::BorshDeserialize), + cfg_attr(feature = "borsh-schema", derive(::borsh::BorshSchema)), + borsh(use_discriminant = true) +)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, derive_more::From)] +#[non_exhaustive] +#[repr(u8)] +pub enum PublicKey { + Ed25519(Ed25519PublicKey) = 0, + Secp256k1(Secp256k1UncompressedPublicKey) = 1, + // TODO: MlDsa65 (full, not hashed) = 2, +} + +impl PublicKey { + /// Derive implicit account ID from this public key + #[inline] + pub fn to_implicit_account_id(&self) -> AccountId { + match self { + Self::Ed25519(pk) => { + // https://docs.near.org/concepts/protocol/account-id#implicit-address + hex::encode(pk) + } + Self::Secp256k1(pk) => { + // https://ethereum.org/en/developers/docs/accounts/#account-creation + format!("0x{}", hex::encode(&Keccak256::digest(pk)[12..32])) + } + } + .try_into() + .unwrap_or_else(|_| unreachable!()) + } +} + +impl Debug for PublicKey { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Ed25519(pk) => pk.to_string(), + Self::Secp256k1(pk) => pk.to_string(), + } + ) + } +} + +impl Display for PublicKey { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +impl FromStr for PublicKey { + type Err = ParseCurveError; + + #[inline] + fn from_str(s: &str) -> Result { + let (curve, data) = s.split_once(':').ok_or(ParseCurveError::WrongCurveType)?; + + match curve { + Ed25519::CURVE_TYPE => checked_base58_decode_array(data) + .map(Ed25519PublicKey) + .map(Into::into), + Secp256k1::CURVE_TYPE => checked_base58_decode_array(data) + .map(Secp256k1UncompressedPublicKey) + .map(Into::into), + _ => Err(ParseCurveError::WrongCurveType), + } + } +} + +/// Signature for [`AccessKeyAuthorization`] +#[cfg_attr( + feature = "serde", + derive(::serde_with::SerializeDisplay, ::serde_with::DeserializeFromStr) +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[cfg_attr( + feature = "borsh", + derive(::borsh::BorshSerialize, ::borsh::BorshDeserialize), + cfg_attr(feature = "borsh-schema", derive(::borsh::BorshSchema)), + borsh(use_discriminant = true) +)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, derive_more::From)] +#[non_exhaustive] +#[repr(u8)] +pub enum Signature { + Ed25519(Ed25519Signature) = 0, + Secp256k1(Secp256k1RecoverableSignature) = 1, +} + +impl Debug for Signature { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Ed25519(sig) => sig.to_string(), + Self::Secp256k1(sig) => sig.to_string(), + } + ) + } +} + +impl Display for Signature { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +impl FromStr for Signature { + type Err = ParseCurveError; + + #[inline] + fn from_str(s: &str) -> Result { + let (curve, data) = s.split_once(':').ok_or(ParseCurveError::WrongCurveType)?; + + match curve { + Ed25519::CURVE_TYPE => checked_base58_decode_array(data) + .map(Ed25519Signature) + .map(Into::into), + Secp256k1::CURVE_TYPE => checked_base58_decode_array(data) + .map(Secp256k1RecoverableSignature) + .map(Into::into), + _ => Err(ParseCurveError::WrongCurveType), + } + } +} + +#[cfg(feature = "near-kit")] +const _: () = { + impl PublicKey { + #[allow(clippy::needless_pass_by_value)] + #[must_use] + #[inline] + pub fn from_kit(pk: near_kit::PublicKey) -> Option { + match pk { + near_kit::PublicKey::Ed25519(pk) => Some(Self::Ed25519(pk.into())), + near_kit::PublicKey::Secp256k1(pk) => Some(Self::Secp256k1(pk.into())), + _ => None, + } + } + } + + impl From for ::near_kit::PublicKey { + #[inline] + fn from(pk: PublicKey) -> Self { + match pk { + PublicKey::Ed25519(pk) => Self::Ed25519(pk.0), + PublicKey::Secp256k1(pk) => Self::Secp256k1(pk.0), + } + } + } + + impl Signature { + #[allow(clippy::needless_pass_by_value)] + #[must_use] + #[inline] + pub fn from_kit(sig: near_kit::Signature) -> Option { + #[allow(clippy::match_wildcard_for_single_variants)] + match sig { + near_kit::Signature::Ed25519(sig) => Some(Self::Ed25519(sig.into())), + near_kit::Signature::Secp256k1(sig) => Some(Self::Secp256k1(sig.into())), + _ => None, + } + } + } + + impl From for near_kit::Signature { + #[inline] + fn from(sig: Signature) -> Self { + match sig { + Signature::Ed25519(sig) => Self::Ed25519(sig.0), + Signature::Secp256k1(sig) => Self::Secp256k1(sig.0), + } + } + } +}; + +#[cfg(feature = "schemars-v0_8")] +const _: () = { + use std::borrow::Cow; + + use schemars::{ + JsonSchema, SchemaGenerator, + schema::{InstanceType, Metadata, Schema, SchemaObject}, + }; + + impl JsonSchema for PublicKey { + #[inline] + fn schema_name() -> String { + "PublicKey".to_owned() + } + + #[inline] + fn schema_id() -> Cow<'static, str> { + Cow::Borrowed(concat!(module_path!(), "::", "PublicKey")) + } + + #[inline] + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some( + Metadata { + examples: [Self::example_ed25519(), Self::example_secp256k1()] + .map(serde_json::to_value) + .map(Result::unwrap) + .into(), + ..Default::default() + } + .into(), + ), + ..Default::default() + } + .into() + } + } + + impl PublicKey { + #[inline] + fn example_ed25519() -> Self { + "ed25519:5TagutioHgKLh7KZ1VEFBYfgRkPtqnKm9LoMnJMJugxm" + .parse() + .unwrap() + } + + #[inline] + fn example_secp256k1() -> Self { + "secp256k1:3aMVMxsoAnHUbweXMtdKaN1uJaNwsfKv7wnc97SDGjXhyK62VyJwhPUPLZefKVthcoUcuWK6cqkSU4M542ipNxS3" + .parse() + .unwrap() + } + } + + impl JsonSchema for Signature { + #[inline] + fn schema_name() -> String { + "Signature".to_owned() + } + + #[inline] + fn schema_id() -> Cow<'static, str> { + Cow::Borrowed(concat!(module_path!(), "::", "Signature")) + } + + #[inline] + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some( + Metadata { + examples: [Self::example_ed25519(), Self::example_secp256k1()] + .map(serde_json::to_value) + .map(Result::unwrap) + .into(), + ..Default::default() + } + .into(), + ), + ..Default::default() + } + .into() + } + } + + impl Signature { + #[inline] + fn example_ed25519() -> Self { + "ed25519:DNxoVu7L7sHr9pcHGWQoJtPsrwheB8akht1JxaGpc9hGrpehdycXBMLJg4ph1bQ9bXdfoxJCbbwxj3Bdrda52eF" + .parse() + .unwrap() + } + + #[inline] + fn example_secp256k1() -> Self { + "secp256k1:7huDZxNnibusy6wFkbUBQ9Rqq2VmCKgTWYdJwcPj8VnciHjZKPa41rn5n6WZnMqSUCGRHWMAsMjKGtMVVmpETCeCs" + .parse() + .unwrap() + } + } +}; diff --git a/crates/signatures/nep641/src/client.rs b/crates/signatures/nep641/src/client.rs new file mode 100644 index 000000000..81e5d14b9 --- /dev/null +++ b/crates/signatures/nep641/src/client.rs @@ -0,0 +1,27 @@ +//! Bindings to [`AuthResolver`](crate::AuthResolver) contract. + +use defuse_serde_utils::Reversed; +use near_account_id::AccountId; +use serde::Serialize; +use serde_with::serde_as; + +use crate::AuthorizationResolution; + +/// Bindings for [`AuthResolver`](crate::AuthResolver) contract interface. +#[near_kit::contract] +pub trait AuthResolverContract { + /// See [`w_resolve_auth()`](crate::AuthResolver::w_resolve_auth) method. + fn w_resolve_auth(&self, args: WResolveAuthArgs<'_>) -> AuthorizationResolution; +} + +/// Arguments for [`w_resolve_auth()`](AuthResolverContractClient::w_resolve_auth) view-method. +#[serde_as] +#[derive(Serialize)] +pub struct WResolveAuthArgs<'a> { + /// **REVERSED** path (will be serialized backwards) + #[serde_as(as = "Reversed")] + #[serde(rename = "path")] + pub rev_path: &'a [AccountId], + + pub authorization: &'a str, +} diff --git a/crates/signatures/nep641/src/lib.rs b/crates/signatures/nep641/src/lib.rs new file mode 100644 index 000000000..bda337095 --- /dev/null +++ b/crates/signatures/nep641/src/lib.rs @@ -0,0 +1,301 @@ +//! # NEP-641: Offchain Authorizations for Smart-Contracts + +#[cfg(feature = "access-keys")] +pub mod access_keys; +#[cfg(feature = "near-kit")] +pub mod client; +mod message; +#[cfg(feature = "resolver")] +pub mod resolver; + +pub use self::message::*; + +use near_account_id::AccountId; + +/// A smart-contract implementing NEP-641 interface. +pub trait AuthResolver { + #[allow(clippy::doc_markdown)] + /// A view-method to resolve [offchain](#offchain-only) authorization according to NEP-641. + /// + /// The implementation SHOULD resolve given `authorization` blob along with [`path`](#path) + /// and return an authorized [`payload`](field@AuthorizationResolution::payload) along with + /// an _optional_ list of [pending sub-authorizations](PendingAuthorization). The authorized + /// payload SHOULD be accepted by the offchain resolver if and only if **all** pending + /// sub-authorizations resolve successfully into corresponding + /// [expected](PendingAuthorization::expect) payloads. + /// + /// # Panics + /// + /// The implementation MUST panic if the authorization is invalid. The panic SHOULD include + /// informative message explaining the failure reason. + /// + /// # Path + /// + /// Offchain verifier starts the verification procedure from the top-level authorization with + /// empty `path`. If the authorization resolves successfully and the pending sub-authorizations + /// list is not empty, then the current resolver contract ID is _prepended at the start_ of + /// `path` and the latter gets propagated to all sub-resolvers. + /// + /// Thus, for any non-top-level resolver the `path` argument includes the whole traversal path + /// _starting_ from its closest ancestor, i.e. direct parent which has triggered the current + /// `w_resolve_auth()`, and _ending_ with the top-level resolver: + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + ///
DepthResolver IDArgsReturn
0resolver.near + /// + /// ```json + /// { + /// "path": [], + /// "authorization": "auth0" + /// } + /// ``` + /// + /// + /// + /// ```json + /// { + /// "payload": "payload0", + /// "pending": [{ + /// "account_id": "sub.resolver.near", + /// "authorization": "auth1", + /// "expect": "payload1" + /// }] + /// } + /// ``` + /// + ///
1sub.resolver.near + /// + /// ```json + /// { + /// "path": ["resolver.near"], + /// "authorization": "auth1" + /// } + /// ``` + /// + /// + /// + /// ```json + /// { + /// "payload": "payload1", + /// "pending": [{ + /// "account_id": "sub.sub.resolver.near", + /// "authorization": "auth2", + /// "expect": "payload2" + /// }] + /// } + /// ``` + /// + ///
2sub.sub.resolver.near + /// + /// ```json + /// { + /// "path": ["sub.resolver.near", "resolver.near"], + /// "authorization": "auth2" + /// } + /// ``` + /// + /// + /// + /// ```json + /// { + /// "payload": "payload2" + /// } + /// ``` + /// + ///
+ /// + /// # Cycles + /// + /// Cycles between sub-resolvers are _allowed_ only as long as they are _finite_. However, + /// keep in mind that offchain verifiers MAY impose limits on the number and/or recursion + /// depth for pending sub-authorizations to prevent from DoS attacks and reject long + /// sub-authorizations chains. + /// + /// # Offchain Only + /// + ///
+ /// + /// **DO NOT** call this view-method in on-chain transactions! + /// + ///
+ /// + /// NEP-641 standard is **NOT** designed to be used for on-chain transfer "approvals" + /// or any other actions that modify state of the blockchain. Offchain messages are + /// intended to be verified _only_ offchain as they don't mutate any state and, hence, + /// cannot prevent replay attacks. + /// + /// Instead, use on-chain messages (e.g. request messages, transactions, delegate actions), + /// which are specifically designed with replay-protection mechanism in mind. + fn w_resolve_auth( + &self, + path: Vec, + authorization: String, + ) -> AuthorizationResolution; +} + +/// Authorization resolution returned from [`w_resolve_auth()`](AuthResolver::w_resolve_auth) +/// method. +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AuthorizationResolution { + /// A payload that was successfully authorized from given authorization blob + /// at the current contract state. + /// + /// dApps and protocols are recommended to use human-readable [`JsonPayload`] top-level + /// structure for user-facing interactions and maximum compatibility across wallets. + pub payload: String, + + /// Optional list of pending sub-authorizations that MUST be successfully + /// [resolved](AuthResolver::w_resolve_auth) before accepting the authorized + /// [payload](field@Self::payload). + /// + /// If empty, then this authorization is a leaf that terminates the current branch. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Vec::is_empty") + )] + pub pending: Vec, +} + +impl AuthorizationResolution { + /// Create a leaf authorization resolution with given payload. + /// + /// See [`.add_pending()`](Self::add_pending) to add pending sub-authorizations. + /// + /// # Examples + /// + /// ```rust + /// # use defuse_nep641::AuthorizationResolution; + /// let auth = AuthorizationResolution::new("payload"); + /// assert!(auth.is_leaf()); + /// ``` + #[inline] + pub fn new(payload: impl Into) -> Self { + Self { + payload: payload.into(), + pending: Vec::new(), + } + } + + /// Add a pending downstream authorization resolution on given account ID. + /// + /// # Examples + /// + /// ```rust + /// # use near_account_id::AccountIdRef; + /// # use defuse_nep641::AuthorizationResolution; + /// let auth = AuthorizationResolution::new("payload") + /// .add_pending( + /// AccountIdRef::new_or_panic("sub.resolver.near"), + /// "auth1", + /// "payload1", + /// ); + /// assert!(!auth.is_leaf()); + /// ``` + #[must_use] + #[inline] + pub fn add_pending( + mut self, + account_id: impl Into, + authorization: impl Into, + expect: impl Into, + ) -> Self { + self.pending.push(PendingAuthorization { + account_id: account_id.into(), + authorization: authorization.into(), + expect: expect.into(), + }); + self + } + + /// Returns whether this authorization resolution is a leaf, i.e. doesn't + /// have any pending ones. + /// + /// # Examples + /// + /// ```rust + /// # use near_account_id::AccountIdRef; + /// # use defuse_nep641::AuthorizationResolution; + /// let leaf = AuthorizationResolution::new("output"); + /// assert!(leaf.is_leaf()); + /// + /// let intermediate = leaf.add_pending( + /// AccountIdRef::new_or_panic("sub.resolver.near"), + /// "auth", + /// "payload", + /// ); + /// assert!(!intermediate.is_leaf()); + /// ``` + #[inline] + pub const fn is_leaf(&self) -> bool { + self.pending.as_slice().is_empty() + } +} + +impl Extend for AuthorizationResolution { + #[inline] + fn extend>(&mut self, iter: T) { + self.pending.extend(iter); + } +} + +/// A [pending](field@AuthorizationResolution::pending) sub-authorization. +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PendingAuthorization { + /// Account ID to [resolve](AuthResolver::w_resolve_auth) the + /// [sub-authorization](field@Self::authorization) on. + pub account_id: AccountId, + + /// Authorization blob to pass to [`w_resolve_auth()`](AuthResolver::w_resolve_auth) + /// method on the [sub-resolver](field@Self::account_id). + pub authorization: String, + + /// Expected authorized [`payload`](field@AuthorizationResolution::payload) to be + /// successfully [resolved](AuthResolver::w_resolve_auth) by the + /// [sub-resolver](field@Self::account_id) from this + /// [sub-authorization](field@Self::authorization). + /// + /// If the pending authorization resolution fails or returns a different payload, then + /// the whole verification procedure MUST fail immediately and top-level authorization + /// MUST be considered invalid. + pub expect: String, +} diff --git a/crates/signatures/nep641/src/message.rs b/crates/signatures/nep641/src/message.rs new file mode 100644 index 000000000..973250eec --- /dev/null +++ b/crates/signatures/nep641/src/message.rs @@ -0,0 +1,352 @@ +pub use defuse_time::Timestamp; +use near_account_id::AccountId; + +#[cfg(feature = "borsh")] +use ::{defuse_borsh_utils::As, defuse_time::borsh::TimestampNanoSeconds}; +#[cfg(feature = "arbitrary")] +use defuse_time::arbitrary::RangeNanos; + +/// Signable offchain message. +/// +/// The [verifying](crate::AuthResolver::w_resolve_auth) contract MUST verify a signature over +/// this entire message, including **all** of its fields, and panic if the signature is invalid. +/// +/// See the documentation of each individual field for additional requirements. +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema)) +)] +#[cfg_attr(feature = "arbitrary", derive(::arbitrary::Arbitrary))] +#[cfg_attr( + feature = "borsh", + derive(::borsh::BorshSerialize, ::borsh::BorshDeserialize), + cfg_attr(feature = "borsh-schema", derive(::borsh::BorshSchema)) +)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct OffchainMessage { + /// Chain ID. + /// + /// The [verifying](crate::AuthResolver::w_resolve_auth) contract MUST panic if it doesn't + /// match its chain ID. + pub chain_id: String, + + /// Signer ID. + /// + /// The [verifying](crate::AuthResolver::w_resolve_auth) contract MUST panic if it doesn't + /// match its current account ID. + pub signer_id: AccountId, + + /// Path to the top-level [resolver](crate::AuthResolver) ID. + /// + /// The path is oriented "bottom-top", where the parent resolver ID is the _first_ element and + /// the top-level resolver ID is the _last_ one. Empty path means that this message is a + /// top-level authorization itself. + /// + /// The verifying contract MUST panic if it doesn't match the `path` passed as an argument + /// to [`w_resolve_auth(path, authorization)`](crate::AuthResolver::w_resolve_auth) method. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Vec::is_empty") + )] + pub path: Vec, + + /// UNIX timestamp at the time of signing. + /// + /// The [verifying](crate::AuthResolver::w_resolve_auth) contract MUST panic if the timestamp + /// is later than the current block timestamp. This prevents an offchain resolver from + /// validating this authorization against chain state from the past, which may also happen + /// unintentionally when its RPC endpoint lags behind the tip of the network. + /// + /// The contract MAY also panic if it performs some additional checks, such as TTL. + /// + /// Clients are recommended to set it slightly (e.g. 60 seconds) before the actual signing + /// time to accommodate clock skew and the normal lag of block timestamps. + #[cfg_attr( + feature = "arbitrary", + arbitrary(with = ::arbitrary_with::As::>::arbitrary), + )] + #[cfg_attr( + feature = "borsh", + borsh( + serialize_with = "As::>::serialize", + deserialize_with = "As::>::deserialize", + ), + cfg_attr( + feature = "borsh-schema", + borsh(schema(with_funcs( + definitions = "As::>::add_definitions_recursively", + declaration = "As::>::declaration", + ))) + ) + )] + pub timestamp: Timestamp, + + /// The authorized payload. + /// + /// dApps and protocols are recommended to use human-readable [`JsonPayload`] top-level + /// structure for user-facing interactions and maximum compatibility across wallets. + pub payload: String, +} + +impl OffchainMessage { + /// A prefix used for [canonical hash](Self::hash). + pub const DOMAIN_SEPARATOR: &[u8] = b"NEAR_NEP641_OFFCHAIN_MESSAGE/V1"; + + /// Returns whether this message is a top-level authorization. + /// + /// # Examples + /// + /// ```rust + /// # use defuse_nep641::{OffchainMessage, Timestamp}; + /// let msg = OffchainMessage { + /// chain_id: "mainnet".to_string(), + /// signer_id: "wallet.near".parse().unwrap(), + /// path: [].into(), + /// timestamp: Timestamp::now(), + /// payload: "Hello, Near!".to_string(), + /// }; + /// assert!(msg.is_top_level()); + /// + /// let msg = OffchainMessage { + /// chain_id: "mainnet".to_string(), + /// signer_id: "wallet.near".parse().unwrap(), + /// path: vec!["v1.signer".parse().unwrap()], + /// timestamp: Timestamp::now(), + /// payload: "Hello, Near!".to_string(), + /// }; + /// assert!(!msg.is_top_level()); + /// ``` + #[inline] + pub const fn is_top_level(&self) -> bool { + self.path.as_slice().is_empty() + } + + /// Returns depth of this authorization, i.e. how many hops away the top-level resolver ID is. + /// + /// # Examples + /// + /// ```rust + /// # use defuse_nep641::{OffchainMessage, Timestamp}; + /// let msg = OffchainMessage { + /// chain_id: "mainnet".to_string(), + /// signer_id: "wallet.near".parse().unwrap(), + /// path: [].into(), + /// timestamp: Timestamp::now(), + /// payload: "Hello, Near!".to_string(), + /// }; + /// assert_eq!(msg.depth(), 0); + /// + /// let msg = OffchainMessage { + /// chain_id: "mainnet".to_string(), + /// signer_id: "extension.near".parse().unwrap(), + /// path: vec![ + /// "wallet.near".parse().unwrap(), + /// "v1.signer".parse().unwrap(), + /// ], + /// timestamp: Timestamp::now(), + /// payload: "Hello, Near!".to_string(), + /// }; + /// assert_eq!(msg.depth(), 2); + /// ``` + #[inline] + pub const fn depth(&self) -> usize { + self.path.as_slice().len() + } + + /// Returns a top-level resolver account ID that this message authorizes the payload for. + /// + /// # Examples + /// + /// ```rust + /// # use defuse_nep641::{OffchainMessage, Timestamp}; + /// let msg = OffchainMessage { + /// chain_id: "mainnet".to_string(), + /// signer_id: "wallet.near".parse().unwrap(), + /// path: [].into(), + /// timestamp: Timestamp::now(), + /// payload: "Hello, Near!".to_string(), + /// }; + /// assert_eq!(msg.top_level_id(), "wallet.near"); + /// + /// let msg = OffchainMessage { + /// chain_id: "mainnet".to_string(), + /// signer_id: "extension.near".parse().unwrap(), + /// path: vec![ + /// "wallet.near".parse().unwrap(), + /// "v1.signer".parse().unwrap(), + /// ], + /// timestamp: Timestamp::now(), + /// payload: "Hello, Near!".to_string(), + /// }; + /// assert_eq!(msg.top_level_id(), "v1.signer"); + /// ``` + #[inline] + pub const fn top_level_id(&self) -> &AccountId { + if let Some(top_level) = self.path.as_slice().last() { + return top_level; + } + &self.signer_id + } + + /// Returns canonical hash of this offchain message, calculated as: + /// + /// ```text + /// SHA3_256(b"NEAR_NEP641_OFFCHAIN_MESSAGE/V1" || borsh(msg)) + /// ``` + /// + /// # Examples + /// + /// ```rust + /// # use defuse_nep641::OffchainMessage; + /// # use hex_literal::hex; + /// let msg = OffchainMessage { + /// chain_id: "mainnet".to_string(), + /// signer_id: "extension.near".parse().unwrap(), + /// path: vec!["wallet.near".parse().unwrap()], + /// timestamp: "2026-08-05T07:28:00.123456789Z".parse().unwrap(), + /// payload: "Hello, Near!".to_string(), + /// }; + /// + /// assert_eq!( + /// msg.hash(), + /// hex!("ac9fc5df2a1da51c4e0446185c8e5b58b65f37d12c544d144fa8f026239962ae"), + /// ); + /// ``` + #[cfg(all(feature = "digest", feature = "borsh"))] + #[inline] + pub fn hash(&self) -> [u8; 32] { + use defuse_digest::{Digest, sha3::Sha3_256}; + use digest_io::IoWrapper; + + let mut hasher = IoWrapper(Sha3_256::new_with_prefix(Self::DOMAIN_SEPARATOR)); + // serialize directly to hasher + ::borsh::to_writer(&mut hasher, self).expect("borsh: failed to serialize"); + + hasher.0.finalize().into() + } + + /// Deterministically convert this message into NEP-413 payload, with optional callback URL. + /// + /// # Examples + /// + /// ```rust + /// # use hex_literal::hex; + /// use defuse_nep641::OffchainMessage; + /// use defuse_nep413::Nep413Payload; + /// + /// let msg = OffchainMessage { + /// chain_id: "mainnet".to_string(), + /// signer_id: "extension.near".parse().unwrap(), + /// path: vec![ + /// "wallet.near".parse().unwrap(), + /// "v1.signer".parse().unwrap(), + /// ], + /// timestamp: "2026-08-05T07:28:00.123456789Z".parse().unwrap(), + /// payload: "Hello, Near!".to_string(), + /// }; + /// + /// assert_eq!( + /// msg.into_nep413_payload("https://wallet.com/callback".to_string()), + /// Nep413Payload { + /// message: "Hello, Near!".to_string(), + /// nonce: hex!("039f8afb4ef2d76c621d3952f214bd2a080ce977ef351d52ff65a090eebbf72c"), + /// recipient: "mainnet @ extension.near -> wallet.near -> v1.signer".to_string(), + /// callback_url: Some("https://wallet.com/callback".to_string()), + /// }, + /// ); + /// ``` + #[cfg(feature = "nep413")] + pub fn into_nep413_payload( + self, + callback_url: impl Into>, + ) -> ::defuse_nep413::Nep413Payload { + use defuse_nep413::Nep413Payload; + use itertools::Itertools; + + Nep413Payload { + // bound full message contents via hash + nonce: self.hash(), + // ` @ [ -> path]...` + recipient: format!( + "{} @ {}", + self.chain_id, + std::iter::once(&self.signer_id) + .chain(&self.path) + .join(" -> ") + ), + // the actual authorized payload + message: self.payload, + callback_url: callback_url.into(), + } + } +} + +#[cfg(feature = "nep413")] +const _: () = { + use defuse_nep413::Nep413Payload; + + impl From for Nep413Payload { + /// Convert into NEP-413 message **without** callback URL + #[inline] + fn from(msg: OffchainMessage) -> Self { + msg.into_nep413_payload(None) + } + } + + #[cfg(feature = "near-kit")] + impl From for ::near_kit::nep413::SignMessageParams { + /// Convert into NEP-413 message **without** callback URL + #[inline] + fn from(msg: OffchainMessage) -> Self { + Nep413Payload::from(msg).into() + } + } +}; + +/// Domain-separated JSON [payload](field@OffchainMessage::payload). +#[cfg(feature = "json")] +#[cfg_attr(feature = "schemars-v0_8", derive(::schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JsonPayload { + /// dApp or protocol domain, e.g. `near.com` or `Near MPC`. + pub domain: String, + + /// An action to be taken on dApp/protocol, e.g. `Login` or `Sign`. + pub action: String, + + /// A generic dApp/protocol-specific message in human-readable format, e.g. plain text or JSON. + /// + /// The message can contain arbitrary data, such as timestamps, deadlines, TTLs, nonces, etc... + pub msg: String, +} + +#[cfg(feature = "json")] +const _: () = { + use core::str::FromStr; + + impl From<&JsonPayload> for String { + #[inline] + fn from(value: &JsonPayload) -> Self { + // pretty JSON for better readability + serde_json::to_string_pretty(value).expect("JSON") + } + } + + impl From for String { + #[inline] + fn from(value: JsonPayload) -> Self { + (&value).into() + } + } + + impl FromStr for JsonPayload { + type Err = serde_json::Error; + + #[inline] + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } + } +}; diff --git a/crates/signatures/nep641/src/resolver/access_keys.rs b/crates/signatures/nep641/src/resolver/access_keys.rs new file mode 100644 index 000000000..556137b6e --- /dev/null +++ b/crates/signatures/nep641/src/resolver/access_keys.rs @@ -0,0 +1,218 @@ +use futures::join; +use near_account_id::AccountId; +use near_kit::{AccessKeyPermissionView, BlockReference, RpcError}; +#[cfg(feature = "tracing")] +use tracing::instrument; + +use crate::{ + AuthorizationResolution, + access_keys::{AccessKeyAuthorization, PublicKey}, + resolver::{ResolveErrorKind, Resolved, RpcResolver}, +}; + +impl RpcResolver { + /// Try to resolve a single authorization via `FullAccessKey` + #[cfg_attr( + feature = "tracing", + instrument(level = "DEBUG", skip_all, err(level = "TRACE")) + )] + pub(super) async fn resolve_access_key( + &self, + account_id: &AccountId, + rev_path: &[AccountId], + auth: &str, + block: BlockReference, + ) -> Result { + let auth: AccessKeyAuthorization = serde_json::from_str(auth)?; + + // check chain_id + if auth.msg.chain_id != self.chain_id { + return Err(AccessKeyError::InvalidChainId.into()); + } + + // check signer_id + if auth.msg.signer_id != *account_id { + return Err(AccessKeyError::InvalidSignerId(auth.msg.signer_id).into()); + } + + // check REVERSED path + if !auth.msg.path.iter().eq(rev_path.iter().rev()) { + return Err(AccessKeyError::InvalidPath.into()); + } + + // verify signature + if !auth.verify() { + return Err(AccessKeyError::InvalidSignature.into()); + } + + let (block, access_key) = { + let rpc_pk = auth.access_key.clone().into(); + if let BlockReference::Hash(block_hash) = block { + // fetch the block concurrently with access key only if block_hash is already known + join!( + // TODO: cache resolved blocks with some TTL and other limis + self.client.block(block_hash.into()), + self.client + .view_access_key(account_id, &rpc_pk, block_hash.into()) + ) + } else { + // otherwise, fetch the block first + let block = self.client.block(block).await?; + // and then the access key against fetched block hash + let access_key = self + .client + .view_access_key(account_id, &rpc_pk, block.header.hash.into()) + .await; + (Ok(block), access_key) + } + }; + let block = block?; + + // check timestamp + if auth.msg.timestamp.as_nanos() > block.header.timestamp.into() { + return Err(AccessKeyError::FromTheFuture.into()); + } + + // check access key + let is_full_access = match access_key { + // Access key exists -> allow only if it has FullAccess permission. + Ok(ref access_key) => matches!( + access_key.permission, + AccessKeyPermissionView::FullAccess + | AccessKeyPermissionView::GasKeyFullAccess { .. } + ), + + // Account exists but it doesn't have this public key added as an access key -> reject. + // Even if this is an implicit account ID derived from this public key, it could have + // been deleted by the owner. + // + // TODO: A Universal Implicit AccountId can be first "created" by incoming transfer, + // and only later initialized via StateInit. So, we need to fetch account's metadata + // and fallback to `Err(AccountNotFound(_))` branch below if "initialized" flag is + // not set. + Err(RpcError::AccessKeyNotFound { .. }) => false, + + // Account doesn't exist on-chain yet -> allow only if it's an implicit account derived + // from this public key, since it can be initialized any time in the future. + Err(RpcError::AccountNotFound(_)) => { + // TODO: or check if we have `self.state_inits.get(&account_id)` with this public + // key added, since it can be just one of them + auth.access_key.to_implicit_account_id() == *account_id + } + + // Other RPC error -> reject. + Err(err) => return Err(err.into()), + }; + + if !is_full_access { + return Err(AccessKeyError::NoFullAccess(auth.access_key).into()); + } + + // authorize signed payload, without any pending sub-authorizations + Ok(Resolved { + res: AuthorizationResolution::new(auth.msg.payload), + block_hash: block.header.hash, + block_height: block.header.height, + }) + } +} + +/// Access key [`ResolveErrorKind`] +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum AccessKeyError { + #[error("message is from the future")] + FromTheFuture, + + #[error("invalid chain_id")] + InvalidChainId, + + #[error("invalid path")] + InvalidPath, + + #[error("invalid signer_id: {0}")] + InvalidSignerId(AccountId), + + #[error("invalid signature")] + InvalidSignature, + + #[error("access key without FullAccess permission: {0}")] + NoFullAccess(PublicKey), +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use defuse_time::Timestamp; + use near_kit::{Final, InMemorySigner, Signer, sandbox::SandboxConfig}; + + use crate::{ + OffchainMessage, + access_keys::{AccessKeySchema, Signature}, + }; + + use super::*; + + #[tokio::test] + async fn full_access_key() { + const PAYLOAD: &str = "Hello, Near!"; + + let sandbox = SandboxConfig::fresh().await; + let mut near = sandbox.client(); + + let msg = OffchainMessage { + chain_id: near.chain_id().as_str().to_string(), + signer_id: near.account_id().clone(), + path: vec![], + timestamp: Timestamp::now() - Duration::from_mins(1), + payload: PAYLOAD.to_string(), + }; + + let signed = near.sign_message(msg.clone().into()).await.unwrap(); + let authorization: String = AccessKeyAuthorization { + msg, + via: AccessKeySchema::Nep413 { callback_url: None }, + access_key: PublicKey::from_kit(signed.public_key).unwrap(), + signature: Signature::from_kit(signed.signature).unwrap(), + } + .into(); + + let resolver = RpcResolver::new(near.rpc().clone()) + .await + .expect("failed to initialize RPC resolver") + // set explicitly, we're testing a top-level auth via FullAccessKey + .with_max_sub_authorizations(0) + .with_max_depth(0); + + let resolved = resolver + .resolve_auth(near.account_id(), &authorization) + .await + .expect("invalid authorization"); + + println!("{authorization}\n{} -> {resolved}", near.account_id()); + assert_eq!(PAYLOAD, resolved, "resolved invalid payload"); + + let new_signer = InMemorySigner::generate_implicit(); + // check on other account + resolver + .resolve_auth(new_signer.account_id(), &authorization) + .await + .expect_err("authorization must be invalid only for non-signer accounts"); + + // rotate key + near.add_full_access_key(new_signer.public_key().clone()) + .delete_key(near.public_key().unwrap()) + .wait_until::() // wait for finalization + .await + .unwrap() + .result() + .unwrap(); + near = near.with_signer(new_signer); + + resolver + .resolve_auth(near.account_id(), &authorization) + .await + .expect_err("old authorization must be invalid after key rotation"); + } +} diff --git a/crates/signatures/nep641/src/resolver/contract.rs b/crates/signatures/nep641/src/resolver/contract.rs new file mode 100644 index 000000000..f9f27e312 --- /dev/null +++ b/crates/signatures/nep641/src/resolver/contract.rs @@ -0,0 +1,71 @@ +use near_account_id::AccountId; +use near_kit::{BlockReference, RpcError}; +#[cfg(feature = "tracing")] +use tracing::instrument; + +use crate::{ + client::WResolveAuthArgs, + resolver::{ResolveErrorKind, Resolved, RpcResolver}, +}; + +impl RpcResolver { + /// Try to resolve a single authorization via `w_resolve_auth()` view-method + #[cfg_attr( + feature = "tracing", + instrument(level = "DEBUG", skip_all, err(level = "TRACE")) + )] + pub(super) async fn resolve_contract( + &self, + account_id: &AccountId, + rev_path: &[AccountId], + authorization: &str, + block: BlockReference, + ) -> Result { + let res = self + .client + .view_function( + account_id, + "w_resolve_auth", + &serde_json::to_vec(&WResolveAuthArgs { + rev_path, + authorization, + }) + .expect("JSON: serialization failed"), + block, + // TODO: "pre-init" if we have StateInit for this AccountId + // self.state_inits.get(&account_id), + ) + .await + .map_err::(|err| match err { + RpcError::AccountNotFound(_) | RpcError::ContractNotDeployed(_) => { + ContractError::NoResolve.into() + } + RpcError::ContractPanic { message } + | RpcError::ContractExecution { message, .. } => { + ContractError::Panic(message).into() + } + RpcError::FunctionCall { panic, .. } => { + ContractError::Panic(panic.unwrap_or_else(|| "contract panic".to_string())) + .into() + } + _ => err.into(), + })?; + + Ok(Resolved { + res: res.json()?, + block_hash: res.block_hash, + block_height: res.block_height, + }) + } +} + +/// Contract [`ResolveErrorKind`] +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ContractError { + #[error("account does not exist, has not been initialized yet or has no contract deployed")] + NoResolve, + + #[error("{0}")] // propagate errors directly from the contract + Panic(String), +} diff --git a/crates/signatures/nep641/src/resolver/error.rs b/crates/signatures/nep641/src/resolver/error.rs new file mode 100644 index 000000000..fd1beabc4 --- /dev/null +++ b/crates/signatures/nep641/src/resolver/error.rs @@ -0,0 +1,56 @@ +use itertools::Itertools; +use near_account_id::AccountId; + +use crate::resolver::{AccessKeyError, contract::ContractError}; + +/// An error returned by [`crate::resolver::RpcResolver`] +#[derive(Debug, thiserror::Error)] +#[error("{}: {}", .rev_path.iter().chain([.account_id]).join(" -> "), .kind)] +#[non_exhaustive] +pub struct ResolveError { + /// Account ID for which the resolution failed. + pub account_id: AccountId, + /// **REVERSED** path _starting_ from the top-level resolver. + pub rev_path: Vec, + /// The actual error occurred. + pub kind: ResolveErrorKind, +} + +/// An resolve error [kind](field@ResolveError::kind) +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ResolveErrorKind { + #[error(transparent)] + AccessKey(#[from] AccessKeyError), + + #[error(transparent)] + Contract(#[from] ContractError), + + #[error("resolved payload is invalid: expected: {}, got: {}", .expected, .payload)] + InvalidPayload { payload: String, expected: String }, + + #[error("JSON: {0}")] + JSON(#[from] serde_json::Error), + + #[error("max depth exceeded, maximum is set to: {0}")] + MaxDepthExceeded(usize), + + #[error("RPC: {0}")] + Rpc(#[from] near_kit::RpcError), + + #[error("too many sub-authorizations, maximum is set to: {0}")] + TooManySubAuthorizations(usize), +} + +impl ResolveErrorKind { + /// Annotate the error with path and current resolver ID + #[must_use] + #[inline] + pub const fn at(self, account_id: AccountId, rev_path: Vec) -> ResolveError { + ResolveError { + account_id, + rev_path, + kind: self, + } + } +} diff --git a/crates/signatures/nep641/src/resolver/mod.rs b/crates/signatures/nep641/src/resolver/mod.rs new file mode 100644 index 000000000..caedf994f --- /dev/null +++ b/crates/signatures/nep641/src/resolver/mod.rs @@ -0,0 +1,390 @@ +//! Offchain RPC resolver + +mod access_keys; +mod contract; +mod error; + +pub use self::{access_keys::*, contract::*, error::*}; + +use futures::{ + join, + stream::{FuturesUnordered, TryStreamExt}, +}; +use near_account_id::AccountId; +use near_kit::{BlockReference, CryptoHash, Finality, RpcClient, RpcError}; +#[cfg(feature = "tracing")] +use tracing::{Span, field, instrument, record_all}; + +use crate::AuthorizationResolution; + +/// RPC resolver for NEP-641 offchain authorizations. +#[derive(Debug, Clone)] +pub struct RpcResolver { + client: RpcClient, + chain_id: String, + + /// Block reference to resolve **all** authorizations against. + at_block: BlockReference, + + /// Maximum allowed total number of sub-authorizations for a single top-level one. + max_sub_auths: usize, + /// Maximum allowed depth of sub-authorization branches. + max_depth: usize, + // state_inits: HashMap, +} + +impl RpcResolver { + /// Create new verifier with given Near RPC client. + pub async fn new(client: RpcClient) -> Result { + let status = client.status().await?; + + Ok(Self { + client, + chain_id: status.chain_id, + // resolve against final block by default + at_block: BlockReference::Finality(Finality::Final), + // allow only top-level authorizations by default + max_sub_auths: 0, + max_depth: 0, + }) + } + + /// Override block reference for [resolving](crate::AuthResolver::w_resolve_auth) + /// **all** autorizations. + /// + /// **All** authorizations are resolved against the same block hash to enforce consistent + /// state between async RPC view-calls. By default, [`.resolve_auth()`](Self::resolve_auth) + /// fetches the `Final` block hash first and then resolves all authorizations against it. + /// This setting overrides it and allows to resolve authorizations against the chain state + /// from the past. + #[must_use] + #[inline] + pub fn at_block(mut self, block: impl Into) -> Self { + self.at_block = block.into(); + self + } + + // TODO: uncomment when RPC adds "pre-init" support for view-calls + // #[inline] + // pub fn with_state_init(mut self, state_init: impl Into) -> Self { + // let state_init = state_init.into(); + // self.state_inits.insert(state_init.derive_account_id(), state_init); + // self + // } + + #[allow(clippy::doc_markdown)] + /// Set an upper limit for total number of sub-authorizations for a single top-level one. + /// + /// By default, this value is set to zero, so that only top-level authorizations are allowed + /// and any sub-authorizations will fail. This is too concervative for real world use-cases, + /// but used as a sane default to prevent from DoS attacks. + /// + /// Note that this doesn't change the [maximum depth](Self::with_max_depth) and it should be + /// configured separately. + #[must_use] + #[inline] + pub const fn with_max_sub_authorizations(mut self, n: usize) -> Self { + self.max_sub_auths = n; + self + } + + #[allow(clippy::doc_markdown)] + /// Set an upper limit for maximum depth of sub-authorization branches. + /// + /// By default, this value is set to zero, so that only top-level authorizations are allowed + /// and any sub-authorizations will fail. This is too concervative for real world use-cases, + /// but used as a sane default to prevent from DoS attacks. + /// + /// Despite the implementation itself is optimized and _does not_ create a new stack frame for + /// each sub-authorization, it's still recommended to limit the maximum depth, as each pending + /// sub-authorization implies additional allocations and may lead to long resolution timings. + /// + /// # Panics + /// + /// This method panics if `max_depth` is greater than currently configured + /// [maximum sub-authorizations](Self::with_max_sub_authorizations). + #[must_use] + #[inline] + pub const fn with_max_depth(mut self, max_depth: usize) -> Self { + assert!( + max_depth <= self.max_sub_auths, + "max_depth can't be greater than max_sub_authorizations, \ + set `.with_max_sub_authorizations()` first.", + ); + + self.max_depth = max_depth; + self + } + + /// Returns chain ID of underlying RPC client + #[inline] + pub const fn chain_id(&self) -> &str { + self.chain_id.as_str() + } + + #[allow(clippy::doc_markdown)] + /// Resolve a payload from top-level authorization according to NEP-641. + /// + /// This method recursively resolves given top-level authorization and all returned pending + /// ones until no more authorizations are left, and returns a top-level authorized + /// [payload](field@AuthorizationResolution::payload). If at least one authorization resolution + /// fails or any [pending sub-authorization](crate::PendingAuthorization) resolves into a + /// payload that doesn't match the [expected](field@crate::PendingAuthorization::expect) one, + /// then the whole resolution procedure is immediately aborted and an error is returned. + /// + /// # Full-access keys + /// + /// For each authorization, the resolver attempts both + /// [`AccessKeyAuthorization`](crate::access_keys::AccessKeyAuthorization) resolution and the + /// [`w_resolve_auth()`](crate::AuthResolver::w_resolve_auth) view-method. A successfully + /// resolved access-key authorization is verified according to + /// [NEP-413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md), authorizes the signed + /// payload without pending sub-authorizations, and takes precedence over contract resolution. + /// + // TODO: # Not yet initialized accounts + /// # Block reference + /// + /// **All** authorizations are resolved against the same block hash to enforce consistent + /// state between async RPC view-calls. By default, this method will fetch the `Final` block + /// hash during top-level authorization resolution and resolve all pending ones against it. + /// Be aware that RPC endpoint MAY be _out-of-sync_ and lag behind the tip of the network. + /// + /// See [`.at_block()`](Self::at_block) to resolve authorizations against the chain state + /// from the past. + /// + /// # Resource limits + /// + /// By default, only top-level authorizations are allowed and any sub-authorizations will fail. + /// This is too concervative for real world use-cases, but used as a sane default to prevent + /// from DoS attacks. + /// + /// See [`.with_max_sub_authorizations()`](Self::with_max_sub_authorizations) and + /// [`.with_max_depth()`](Self::with_max_depth) to set your custom limits. + pub async fn resolve_auth( + &self, + account_id: impl Into, + authorization: impl Into, + ) -> Result { + // resolve top-level authorization first + let ResolvedAuthorization { + mut account_id, + mut rev_path, // REVERSED path + res: + Resolved { + res: + AuthorizationResolution { + payload, // resolved top-level payload + mut pending, + }, + block_hash, // resolved block hash + .. + }, + #[cfg(feature = "tracing")] + mut span, + } = self + .resolve_single( + account_id.into(), + vec![], // path is empty for top-level authorization + authorization.into(), + None, // no expected payload for top-level authorization + self.at_block.clone(), + #[cfg(feature = "tracing")] + Span::current(), + ) + .await?; + + // keep track of total number of pending sub-authorizations to be resolved + let mut sub_count: usize = 0; + // a pool of futures to resolve all pending sub-authorizations concurrently + let mut in_flight = FuturesUnordered::new(); + + loop { + // check if max depth limit will be exceeded for pending sub-authorizations, if any + if !pending.is_empty() && rev_path.len() >= self.max_depth { + return Err( + ResolveErrorKind::MaxDepthExceeded(self.max_depth).at(account_id, rev_path) + ); + } + + sub_count = sub_count.saturating_add(pending.len()); + // check if adding new pending sub-authorizations wouldn't exceed max pending limit + if sub_count > self.max_sub_auths { + return Err( + ResolveErrorKind::TooManySubAuthorizations(self.max_sub_auths) + .at(account_id, rev_path), + ); + } + + // append parent resolver ID to the REVERSED path for pending sub-authorizations, if any + rev_path.push(account_id); + + // add pending sub-authorizations to the in-flight pool + in_flight.extend(pending.into_iter().map(|pending| { + self.resolve_single( + pending.account_id, + rev_path.clone(), // propagate extended path to all sub-resolvers + pending.authorization, + Some(pending.expect), // check that returned payload matches the expected one + block_hash.into(), // resolve pending sub-authorizations at the same block hash + #[cfg(feature = "tracing")] + span.clone(), + ) + })); + + // wait until a pending sub-authorization resolves, if any + let Some(resolved) = in_flight.try_next().await? else { + // no more sub-authorizations left, return the top-level resolved payload + return Ok(payload); + }; + + // overwrite variables from the resolved sub-authorization + ResolvedAuthorization { + account_id, + rev_path, + res: Resolved { + res: AuthorizationResolution { pending, .. }, + .. + }, + #[cfg(feature = "tracing")] + span, + } = resolved; + } + } + + /// Resolve a single authorization and check that returned payload matches the expected one, + /// if set + #[cfg_attr(feature = "tracing", instrument( + name = "resolve_auth", + parent = parent_span, // build `resolve_auth` span tree + skip_all, + fields( + chain.id = self.chain_id, + %account_id, + depth = rev_path.len(), + // set one of finality / hash / height fields + at_block.finality = if let BlockReference::Finality(f) = block { Some(f.as_str()) } else { None }.map(field::display), + at_block.hash = if let BlockReference::Hash(h) = block { Some(h) } else { None }.map(field::display), + at_block.height = if let BlockReference::Height(h) = block { Some(h) } else { None }, + ), + err(level = "DEBUG"), + ))] + async fn resolve_single( + &self, + account_id: AccountId, + rev_path: Vec, + authorization: String, + expect: Option, + block: BlockReference, + #[cfg(feature = "tracing")] parent_span: Span, + ) -> Result { + let res = match self + .do_resolve_single( + &account_id, + &rev_path, + authorization.as_ref(), + expect, + block, + ) + .await + { + Ok(res) => res, + Err(err) => return Err(err.at(account_id, rev_path)), + }; + + Ok(ResolvedAuthorization { + #[cfg(feature = "tracing")] + span: { + let span = Span::current(); + // update `at_block.hash` and `at_block.height` with resolved block + record_all!(span, at_block.hash = %res.block_hash, at_block.height = res.block_height); + tracing::debug!( + payload = res.res.payload, + sub_authorizations = res.res.pending.len(), + "authorization resolved" + ); + span + }, + account_id, + rev_path, + res, + }) + } + + /// Resolve a single authorization and check that returned payload matches the expected one, + /// if set + async fn do_resolve_single( + &self, + account_id: &AccountId, + rev_path: &[AccountId], + authorization: &str, + expect: Option, + block: BlockReference, + ) -> Result { + // try to resolve via both FullAccessKey and `w_resolve_auth()` contract view-method + let res = match join!( + self.resolve_access_key(account_id, rev_path, authorization, block.clone()), + self.resolve_contract(account_id, rev_path, authorization, block.clone()), + // TODO: add optional support for fallback to Intents verifier contract as a resolver + ) { + // if both failed, but access key authorization at least deserialized successfully, + // then propagate the error coming from it + (res @ Err(ResolveErrorKind::AccessKey(_)), Err(_)) => res, + // successfull resolution via FullAccessKey takes precedence over the contract, since it: + // * has the same full control over the account + // * authorizes a payload without pending sub-authorizations + // * can be used as a last resort for a broken contract + (access_key, contract) => access_key.or(contract), + }?; + + // check block returned by RPC first, just in case + if match block { + BlockReference::Hash(hash) => res.block_hash != hash, + BlockReference::Height(height) => res.block_height != height, + _ => false, + } { + return Err(ResolveErrorKind::from(RpcError::InvalidResponse( + "returned block doesn't match the requested one".to_string(), + ))); + } + + // check if resolved payload matches the one expected by the parent resolver + if let Some(expected) = expect + && expected != res.res.payload + { + return Err(ResolveErrorKind::InvalidPayload { + payload: res.res.payload, + expected, + }); + } + + Ok(res) + } +} + +/// A single resolved authorization +struct ResolvedAuthorization { + /// Account ID which resolved this authorization + account_id: AccountId, + + /// **REVERSED** path at the time of resolution. + /// Empty path means it was a top-level authorization. + rev_path: Vec, + + /// Resolved authorization + res: Resolved, + + /// Span where this authorization was resolved + #[cfg(feature = "tracing")] + span: Span, +} + +struct Resolved { + /// Authorization resolution + res: AuthorizationResolution, + + /// Resolved block hash + block_hash: CryptoHash, + + /// Resolved block height + block_height: u64, +} diff --git a/crates/wallet/sdk/Cargo.toml b/crates/wallet/sdk/Cargo.toml index ee192acf3..3dd930dde 100644 --- a/crates/wallet/sdk/Cargo.toml +++ b/crates/wallet/sdk/Cargo.toml @@ -11,12 +11,12 @@ repository.workspace = true defuse-near-sender = { workspace = true, features = ["serde"] } defuse-wallet = { workspace = true, features = ["borsh", "digest", "json", "serde", "std"] } +anyhow.workspace = true async-trait.workspace = true borsh.workspace = true impl-tools.workspace = true rand.workspace = true serde = { workspace = true, features = ["derive"] } -thiserror.workspace = true trait-variant.workspace = true bs58 = { workspace = true, optional = true } @@ -52,11 +52,14 @@ tracing = [ ] [dev-dependencies] +defuse-nep641 = { workspace = true, features = ["resolver", "tracing"] } defuse-wallet-ed25519 = { workspace = true, features = ["signer"] } futures.workspace = true hex-literal.workspace = true near-kit = { workspace = true, features = ["sandbox"] } rand = { workspace = true, features = ["sys_rng"] } rstest.workspace = true +serde_json.workspace = true sha2.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } +tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/crates/wallet/sdk/src/lib.rs b/crates/wallet/sdk/src/lib.rs index 5b5d271ec..bcb187787 100644 --- a/crates/wallet/sdk/src/lib.rs +++ b/crates/wallet/sdk/src/lib.rs @@ -1,9 +1,11 @@ +//! # Wallet Contracts SDK + #[cfg(feature = "near-kit")] pub mod client; #[cfg(feature = "mpc")] pub use defuse_mpc_signer as mpc; use defuse_near_sender::{NearSender, SentTransaction}; -use defuse_wallet::actions::NearAction; +use defuse_wallet::{actions::NearAction, offchain::OffchainMessage}; mod nonces; pub mod relayer; mod signer; @@ -16,16 +18,20 @@ use std::{ borrow::Cow, collections::BTreeSet, error::Error as StdError, - mem, - sync::{Arc, Mutex}, + iter, mem, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering::Relaxed}, + }, time::Duration, }; +use anyhow::{Context, Result}; use borsh::BorshSerialize; use impl_tools::autoimpl; use rand::{make_rng, rngs::SmallRng}; #[cfg(feature = "tracing")] -use tracing::{Level, instrument, record_all}; +use tracing::{Span, instrument, record_all}; use crate::{ actions::FunctionCall, @@ -37,6 +43,11 @@ use crate::{ /// `mainnet` chain id pub const MAINNET: &str = "mainnet"; +/// Signers are recommended to set `created_at` a bit in the past, +/// so that transaction doesn't fail on-chain due to possible lag +/// in block timestamps. +const BLOCKCHAIN_LAG: Duration = Duration::from_mins(1); + /// Builder for [`Wallet`] #[must_use = "`.build()` the signer"] #[derive(Debug)] @@ -85,6 +96,13 @@ impl WalletBuilder { self } + /// Pre-enable extension with given account ID. + #[inline] + pub fn extension(mut self, account_id: impl Into) -> Self { + self.extensions.insert(account_id.into()); + self + } + /// Pre-enable extensions with given account ids. #[inline] pub fn extensions(mut self, account_ids: impl IntoIterator) -> Self { @@ -103,7 +121,7 @@ impl WalletBuilder { S: SignatureSchema, S::PublicKey: BorshSerialize, SS: WalletSigner + 'static, - SS::Error: Into>, + SS::Error: StdError + Send + Sync + 'static, { let state_init = StateInit::V1(StateInitV1 { code: code.into(), @@ -116,13 +134,13 @@ impl WalletBuilder { Wallet { account_id: state_init.derive_account_id(), - state_init, - initialized: false, + state_init: state_init.into(), + initialized: Arc::new(AtomicBool::new(false)), + as_extension_chain: Vec::new(), timeout: self.timeout, nonces: Arc::new(Mutex::new(ConcurrentNonces::new(make_rng()))), chain_id: MAINNET.to_string(), signer: Arc::new(signer), - as_extension_chain: Vec::new(), #[cfg(feature = "near-kit")] client: None, relayer: None, @@ -161,22 +179,33 @@ impl WalletBuilder { /// ``` #[autoimpl(Clone)] pub struct Wallet { - state_init: StateInit, + /// Real account ID account_id: AccountId, - initialized: bool, + /// Initialization state for real account ID + state_init: Arc, + // Whether real account ID is known to be already initialized on-chain + initialized: Arc, + /// Currently configured extension chain + as_extension_chain: Vec, + /// Current configured chain ID chain_id: ChainId, + /// Fixed timeout for [`RequestMessage`]s. timeout: Duration, + /// Semi-sequential nonces nonces: Arc>>, - as_extension_chain: Vec, + /// Signer signer: Arc>, + /// Near client #[cfg(feature = "near-kit")] client: Option, + /// Relayer relayer: Option>, + /// Account ID of MPC contract #[cfg(feature = "mpc")] mpc_contract_id: Option, } @@ -192,7 +221,7 @@ where where S::PublicKey: BorshSerialize, SS: WalletSigner + 'static, - SS::Error: Into>, + SS::Error: StdError + Send + Sync + 'static, { WalletBuilder::new().build(code, signer) } @@ -214,9 +243,9 @@ where if self.chain_id != old_chain_id { // same wallet account ID on different chain might not have been // initialized yet - self.initialized = false; + self.initialized = Arc::new(AtomicBool::new(false)); // same wallet instances on different chains keep track of their own nonces - self.reseed_nonces(); + self.nonces = Arc::new(Mutex::new(ConcurrentNonces::new(make_rng()))); #[cfg(feature = "mpc")] { @@ -248,7 +277,7 @@ where pub fn with_relayer(mut self, relayer: R) -> Self where R: WalletRelayer + 'static, - R::Error: Into>, + R::Error: StdError + Send + Sync + 'static, { self.relayer = Some(Arc::new(relayer)); self @@ -269,8 +298,8 @@ where /// [state init](Self::deterministic_state_init). #[must_use] #[inline] - pub const fn initialized(mut self) -> Self { - self.initialized = true; + pub fn as_initialized_unchecked(mut self) -> Self { + self.initialized = Arc::new(AtomicBool::new(true)); self } @@ -323,11 +352,11 @@ where assert!( account_id != *self.real_account_id() && !self.as_extension_chain.contains(&account_id), - "extension cycle detected", + "Extension cycle detected", ); self.as_extension_chain.push(account_id); - debug_assert_eq!(self.account_id(), self.as_extension_chain.last().unwrap()); + debug_assert_eq!(self.account_id(), self.as_extension_chain().last().unwrap()); self } @@ -361,13 +390,6 @@ where self } - /// Returns currently [configured](Self::with_chain_id) chain ID for [signing](Self::sign) - /// requests. - #[inline] - pub const fn chain_id(&self) -> &ChainId { - &self.chain_id - } - /// Get an _effective_ account ID which this wallet acts on behalf of. /// /// This is the last account ID from the currently configured @@ -375,7 +397,7 @@ where /// [`.real_account_id()`](Self::real_account_id) otherwise. #[inline] pub const fn account_id(&self) -> &AccountId { - if let Some(last_extension_id) = self.as_extension_chain.as_slice().last() { + if let Some(last_extension_id) = self.as_extension_chain().last() { return last_extension_id; } self.real_account_id() @@ -383,20 +405,31 @@ where /// Returns _real_ account ID of this wallet instance. /// - /// NOTE: the account on NEAR might **not** exist yet and needs to be - /// initialized first. See [`.deterministic_state_init()`](Self::deterministic_state_init) + /// **NOTE**: the account might **not** exist yet on-chain and needs to be + /// [initialized](Self::initialize) first. #[inline] pub const fn real_account_id(&self) -> &AccountId { &self.account_id } + /// Returns currenly configured [extension chain](Self::as_extension_of). + /// + /// If not empty, then the _last_ item is the [effective account ID](Self::account_id). + #[inline] + pub const fn as_extension_chain(&self) -> &[AccountId] { + self.as_extension_chain.as_slice() + } + /// Get initialization state for [real account ID](Self::real_account_id) of this wallet. /// - /// A first transaction to the wallet's [real account id](Self::real_account_id) - /// needs to include [`.deterministic_state_init()`](Wallet::deterministic_state_init) - /// action in order to initialize the contract before calling methods on it. + /// > A first transaction to the wallet's [real account id](Self::real_account_id) needs to + /// > include `DeterministicStateInit` action in order to initialize the contract before + /// > calling methods on it. + /// + /// This is handled automatically when [sending](Self::sign_and_send) signed on-chain messages. + /// See [`.initialize()`](Self::initialize) for manual initialization. #[inline] - pub const fn deterministic_state_init(&self) -> &StateInit { + pub fn deterministic_state_init(&self) -> &StateInit { &self.state_init } @@ -406,6 +439,13 @@ where self.signer.public_key() } + /// Returns currently [configured](Self::with_chain_id) chain ID for [signing](Self::sign) + /// requests. + #[inline] + pub const fn chain_id(&self) -> &ChainId { + &self.chain_id + } + /// Get `timeout`, i.e. fixed maximum validity for each nonce in signed /// requests #[inline] @@ -439,6 +479,90 @@ where .expect("relayer was not configured, use `with_relayer()` to set one") } + #[cfg(feature = "near-kit")] + /// Check if [real account ID](Self::real_account_id) is initialized on-chain. + async fn check_initialized(&self) -> Result { + use near_kit::{BlockReference, Finality, RpcError}; + + if self.initialized.load(Relaxed) { + return Ok(true); + } + + let initialized = match self + .client() + .rpc() + .view_account( + self.real_account_id(), + // check at final block, so that we're sure about it and + // offchain authorizations can be resolved, as well. + BlockReference::Finality(Finality::Final), + ) + .await + { + Ok(account) => account.has_contract(), + Err(RpcError::AccountNotFound(_)) => false, + Err(err) => return Err(err.into()), + }; + + if initialized { + self.initialized.store(true, Relaxed); + } + Ok(initialized) + } + + #[cfg(feature = "near-kit")] + /// Initialize [real account ID](Self::account_id) by sending empty [`Request`]. + /// + /// # Panics + /// + /// This method panics when called on wallet with non-empty configured + /// [extension chain](Self::as_extension_of). + pub async fn initialize(&self) -> Result<()> { + use near_kit::{ExecutionStatus, Final}; + + assert!( + self.as_extension_chain().is_empty(), + "Cannot initialize a wallet with non-empty extension chain. Use `.as_self()` and initialize the real account ID", + ); + + // sync before sending on-chain txs + if self.check_initialized().await? { + return Ok(()); + } + + // initialize real account ID by sending an empty request + let output = self + .sign_and_send(Request::new()) + .await? + .status(&self.client()) + // wait for finalization + .wait_until::() + .await?; + + let initialized = output + .receipts_outcome + .iter() + // look for a successfull receipt on real account ID with non-empty logs, as it + // should contain `signed_request` event + .any(|o| { + matches!(o.outcome.status, ExecutionStatus::SuccessValue(_)) + && !o.outcome.logs.is_empty() + && o.outcome.executor_id == *self.real_account_id() + }); + + if !initialized { + return Err(anyhow::anyhow!( + "transaction {} did not initialize the wallet {}", + output.transaction_hash(), + self.real_account_id(), + )); + } + + self.initialized.store(true, Relaxed); + + Ok(()) + } + /// Sign on-chain request to be executed on behalf of /// [effective account ID](Self::account_id). /// @@ -448,37 +572,38 @@ where /// /// NOTE: The wallet account itself might **not** be initialized yet. See /// [`.deterministic_state_init()`](Wallet::deterministic_state_init). - #[cfg_attr(feature = "tracing", instrument(level = Level::DEBUG, skip_all, fields( - msg.chain_id = &self.chain_id, - msg.signer_id = %self.account_id(), + #[cfg_attr(feature = "tracing", instrument(skip_all, fields( + account_id = %self.account_id(), + msg.chain_id = self.chain_id(), + msg.signer_id = %self.real_account_id(), msg.nonce, msg.created_at, msg.timeout_secs, - msg.hash + msg.hash, )))] - pub async fn sign( - &self, - request: impl Into, - ) -> Result<(RequestMessage, Proof), Error> { + pub async fn sign(&self, request: impl Into) -> Result<(RequestMessage, Proof)> { let msg = self.wrap_request_msg(request); #[cfg(feature = "tracing")] record_all!( - tracing::Span::current(), + Span::current(), msg.nonce, %msg.created_at, msg.timeout_secs = msg.timeout.as_secs(), msg.hash = %bs58::encode(msg.hash()).into_string(), ); - let proof = self - .signer - .sign_wallet_msg(&msg) - .await - .map_err(Error::Signer)?; + let proof = self.signer.sign_request_msg(&msg).await.context("signer")?; + + #[cfg(feature = "tracing")] + tracing::info!( + msg.request.internal.count = msg.request.internal.len(), + msg.request.external.count = msg.request.external.len(), + "on-chain message signed", + ); debug_assert!( - S::verify(&self.signer.public_key(), &msg, &proof), + S::verify_request_msg(&self.signer.public_key(), &msg, &proof), "signer produced invalid signature", ); @@ -492,18 +617,18 @@ where RequestMessage { pay_for_gas: false, // TODO: add support for External Contract Calls chain_id: self.chain_id.clone(), + // signer is the real account ID signer_id: self.real_account_id().clone(), nonce: self.nonces.lock().unwrap().next(), // Set `created_at` slightly before the actual time of signing, // so it doesn't fail on-chain if arrives too fast. - created_at: Timestamp::now() - self.optimal_lag(), + created_at: Timestamp::now() - BLOCKCHAIN_LAG.min(self.timeout() / 5), timeout: self.timeout(), // Recursively wrap request as `w_execute_extension()` FunctionCall // for each extension in the chain (starting from the last one) - request: self - .as_extension_chain - .iter() - .rfold(request.into(), |request, extension| { + request: self.as_extension_chain().iter().rfold( + request.into(), + |request, extension| { NearPromise::new(extension) .function_call( FunctionCall::name("w_execute_extension") @@ -512,17 +637,11 @@ where .args_json(WExecuteExtensionArgs::from(request)), ) .into() - }), + }, + ), } } - /// Returns an optimal lag for `created_at`, so it doesn't fail on-chain - /// if arrives too early. - #[inline] - fn optimal_lag(&self) -> Duration { - Duration::from_mins(1).min(self.timeout() / 5) - } - /// [Sign](Self::sign) the given on-chain [request](Request) to be /// executed on behalf of [effective account ID](Self::account_id) and /// relay it. @@ -531,10 +650,7 @@ where /// /// This method panics if relayer is not [configured](Self::with_relayer) /// for this wallet. - pub async fn sign_and_send( - &self, - request: impl Into, - ) -> Result { + pub async fn sign_and_send(&self, request: impl Into) -> Result { // check before signing if relayer is set let relayer = self.relayer(); @@ -542,11 +658,92 @@ where let mut req = WalletRelayRequest::new(msg, proof); - if !self.initialized { + if !self.initialized.load(Relaxed) { req = req.deterministic_state_init(self.deterministic_state_init().clone()); } - relayer.relay_wallet_msg(req).await.map_err(Error::Relayer) + relayer.relay_wallet_msg(req).await.context("relayer") + } + + /// Sign offchain payload and return an authorization blob, as per NEP-641. + /// + /// Optional `path` argument allows to specify a path from [effective account ID](Self::account_id) + /// to top-level resolver ID. Empty path means that the returned authorization is top-level itself. + #[cfg_attr(feature = "tracing", instrument(skip_all, fields( + account_id = %self.account_id(), + msg.chain_id = self.chain_id(), + msg.signer_id = %self.real_account_id(), + msg.top_level_id, + msg.depth, + msg.timestamp, + msg.hash, + )))] + pub async fn sign_offchain_msg( + &self, + payload: impl Into, + path: impl IntoIterator, + ) -> Result { + assert!( + self.initialized.load(Relaxed), + "The real wallet ID is not known to be initialized and MAY fail to resolve offchain \ + authorization due to current limitations of Near RPC. Use `.initialize()`", + ); + + let msg = OffchainMessage { + chain_id: self.chain_id().clone(), + // signer is the real account ID + signer_id: self.real_account_id().clone(), + // path to the top-level resolver + path: self + .as_extension_chain() + .iter() + .cloned() + .chain(path) + .collect(), + // Set `timestamp` slightly before the actual time of signing, + // so it doesn't fail if gets resolved too fast. + timestamp: Timestamp::now() - BLOCKCHAIN_LAG, + payload: payload.into(), + }; + #[cfg(feature = "tracing")] + record_all!( + Span::current(), + msg.top_level_id = %msg.top_level_id(), + msg.depth = msg.depth(), + %msg.timestamp, + msg.hash = %bs58::encode(msg.hash()).into_string(), + ); + + let proof = self + .signer + .sign_offchain_msg(&msg) + .await + .context("signer")?; + + #[cfg(feature = "tracing")] + tracing::info!(msg.payload, "off-chain message signed"); + + debug_assert!( + S::verify_offchain_msg(&self.signer.public_key(), &msg, &proof), + "signer produced invalid signature", + ); + + Ok(self.wrap_offchain_msg(msg, proof)) + } + + fn wrap_offchain_msg(&self, msg: OffchainMessage, proof: String) -> String { + iter::once(self.real_account_id()) + .chain(self.as_extension_chain()) + // wrap only while there is a next extension in the chain + .take(self.as_extension_chain().len()) + .cloned() + .fold( + // first authorization is via signature on real signer ID + WalletAuthorization::Signature { msg, proof }, + // wrap as extension with ID of the previous account in the chain + WalletAuthorization::as_extension_of, + ) + .into() } #[allow(clippy::doc_markdown)] @@ -683,26 +880,12 @@ impl From> for AccountId { (&wallet).into() } } -/// An error returned from [`Wallet`] methods -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum Error { - /// An error occurred during [relaying](WalletRelayer::relay_wallet_msg) - /// signed [request](RequestMessage). - #[error("relayer: {0}")] - Relayer(Box), - - /// An error occurred during [signing](WalletSigner::sign_wallet_msg) - /// wallet [request](RequestMessage). - #[error("signer: {0}")] - Signer(Box), -} impl NearSender for Wallet where S: SignatureSchema, { - type Error = Error; + type Error = anyhow::Error; #[inline] fn account_id(&self) -> Cow<'_, AccountIdRef> { diff --git a/crates/wallet/sdk/src/relayer/mod.rs b/crates/wallet/sdk/src/relayer/mod.rs index c766406f0..7d321f4ef 100644 --- a/crates/wallet/sdk/src/relayer/mod.rs +++ b/crates/wallet/sdk/src/relayer/mod.rs @@ -36,25 +36,25 @@ pub(crate) trait DynWalletRelayer: Send + Sync { async fn dyn_relay_signed_msg( &self, request: WalletRelayRequest, - ) -> Result>; + ) -> anyhow::Result; } #[async_trait] impl DynWalletRelayer for R where R: WalletRelayer, - R::Error: Into>, + R::Error: StdError + Send + Sync + 'static, { async fn dyn_relay_signed_msg( &self, request: WalletRelayRequest, - ) -> Result> { + ) -> anyhow::Result { self.relay_wallet_msg(request).await.map_err(Into::into) } } impl WalletRelayer for dyn DynWalletRelayer + '_ { - type Error = Box; + type Error = anyhow::Error; async fn relay_wallet_msg( &self, diff --git a/crates/wallet/sdk/src/relayer/near_kit.rs b/crates/wallet/sdk/src/relayer/near_kit.rs index 38f1aa80d..a5bd6bb0f 100644 --- a/crates/wallet/sdk/src/relayer/near_kit.rs +++ b/crates/wallet/sdk/src/relayer/near_kit.rs @@ -1,19 +1,12 @@ -use core::time::Duration; - use defuse_near_sender::SentTransaction; use near_kit::{Error, Included, Near}; use crate::{ - Gas, NearToken, + BLOCKCHAIN_LAG, Gas, NearToken, client::WalletContract, relayer::{WalletRelayRequest, WalletRelayer}, }; -/// Signers are recommended to set `created_at` a bit in the past, -/// so that transaction doesn't fail on-chain due to possible lag -/// in block timestamps. -const BLOCKCHAIN_LAG: Duration = Duration::from_mins(1); - // TODO: remove once https://github.com/near/nearcore/pull/15461 is on mainnet /// Only assist with at most 1yN: it's enough for a single permissioned /// action on Near: most contracts require 1yN of attached deposit to @@ -36,22 +29,18 @@ impl WalletRelayer for Near { request: WalletRelayRequest, ) -> Result { if request.msg.chain_id != self.chain_id().as_str() { - return Err(Error::InvalidTransaction( - TxError::InvalidChainId.to_string(), - )); + return Err(Error::InvalidTransaction("invalid chain_id".to_string())); } if request.msg.request.estimate_gas() > MAX_GAS { - return Err(Error::InvalidTransaction(TxError::GasLimit.to_string())); + return Err(Error::InvalidTransaction("gas limit exceeded".to_string())); } let mut tx = self.transaction(&request.msg.signer_id); if let Some(state_init) = request.deterministic_state_init { if state_init.derive_account_id() != request.msg.signer_id { - return Err(Error::InvalidTransaction( - TxError::InvalidStateInit.to_string(), - )); + return Err(Error::InvalidTransaction("invalid state_init".to_string())); } tx = tx.state_init( @@ -70,9 +59,8 @@ impl WalletRelayer for Near { tokio::time::timeout( request .msg - .time_left() - .ok_or(TxError::ExpiredOrFuture) - .map_err(|e| Error::InvalidTransaction(e.to_string()))? + .duration_left() + .ok_or_else(|| Error::InvalidTransaction("expired or from the future".to_string()))? // add more buffer for short-living requests .saturating_add(BLOCKCHAIN_LAG), tx.send() @@ -81,20 +69,8 @@ impl WalletRelayer for Near { .max_nonce_retries(u32::MAX), ) .await - .map_err(|_| Error::InvalidTransaction(TxError::ExpiredOrFuture.to_string())) + .map_err(|_| Error::InvalidTransaction("expired".to_string())) .flatten() .map(Into::into) } } - -#[derive(Debug, thiserror::Error)] -enum TxError { - #[error("invalid chain_id")] - InvalidChainId, - #[error("invalid state_init")] - InvalidStateInit, - #[error("expired or from the future")] - ExpiredOrFuture, - #[error("gas limit exceeded")] - GasLimit, -} diff --git a/crates/wallet/sdk/src/signer.rs b/crates/wallet/sdk/src/signer.rs index f0017223c..e8bc1bb79 100644 --- a/crates/wallet/sdk/src/signer.rs +++ b/crates/wallet/sdk/src/signer.rs @@ -5,7 +5,7 @@ use std::{ }; use async_trait::async_trait; -use defuse_wallet::{RequestMessage, SignatureSchema}; +use defuse_wallet::{RequestMessage, SignatureSchema, offchain::OffchainMessage}; use impl_tools::autoimpl; /// A proof for [`w_execute_signed(msg, proof)`](defuse_wallet::contract::Wallet::w_execute_signed) @@ -27,7 +27,10 @@ pub trait WalletSigner: Sync { /// Sign [`RequestMessage`] according to [`SignatureSchema`] /// and return a proof serialized to string ready to be submitted to /// [`w_execute_signed(msg, proof)`](defuse_wallet::contract::Wallet::w_execute_signed) contract method - async fn sign_wallet_msg(&self, msg: &RequestMessage) -> Result; + async fn sign_request_msg(&self, msg: &RequestMessage) -> Result; + + /// Sign [`OffchainMessage`] according to [`SignatureSchema`] and return a [proof](field@defuse_wallet::WalletAuthorization::Signature::proof). + async fn sign_offchain_msg(&self, msg: &OffchainMessage) -> Result; } #[allow(clippy::redundant_pub_crate)] @@ -35,10 +38,9 @@ pub trait WalletSigner: Sync { pub(crate) trait DynWalletSigner: Send + Sync { fn dyn_public_key(&self) -> S::PublicKey; - async fn dyn_sign_request_msg( - &self, - msg: &RequestMessage, - ) -> Result>; + async fn dyn_sign_request_msg(&self, msg: &RequestMessage) -> anyhow::Result; + + async fn dyn_sign_offchain_msg(&self, msg: &OffchainMessage) -> anyhow::Result; } #[async_trait] @@ -46,18 +48,19 @@ impl DynWalletSigner for S where SS: SignatureSchema, S: WalletSigner, - S::Error: Into>, + S::Error: StdError + Send + Sync + 'static, { #[inline] fn dyn_public_key(&self) -> SS::PublicKey { self.public_key() } - async fn dyn_sign_request_msg( - &self, - msg: &RequestMessage, - ) -> Result> { - self.sign_wallet_msg(msg).await.map_err(Into::into) + async fn dyn_sign_request_msg(&self, msg: &RequestMessage) -> anyhow::Result { + self.sign_request_msg(msg).await.map_err(Into::into) + } + + async fn dyn_sign_offchain_msg(&self, msg: &OffchainMessage) -> anyhow::Result { + self.sign_offchain_msg(msg).await.map_err(Into::into) } } @@ -65,14 +68,18 @@ impl WalletSigner for dyn DynWalletSigner + '_ where S: SignatureSchema, { - type Error = Box; + type Error = anyhow::Error; #[inline] fn public_key(&self) -> S::PublicKey { self.dyn_public_key() } - async fn sign_wallet_msg(&self, msg: &RequestMessage) -> Result { + async fn sign_request_msg(&self, msg: &RequestMessage) -> Result { self.dyn_sign_request_msg(msg).await } + + async fn sign_offchain_msg(&self, msg: &OffchainMessage) -> Result { + self.dyn_sign_offchain_msg(msg).await + } } diff --git a/crates/wallet/sdk/tests/test.rs b/crates/wallet/sdk/tests/test.rs index 2b62d8fb5..02e579c1a 100644 --- a/crates/wallet/sdk/tests/test.rs +++ b/crates/wallet/sdk/tests/test.rs @@ -2,18 +2,24 @@ use std::{env, fs, path::Path, sync::LazyLock}; +use defuse_nep641::{JsonPayload, resolver::RpcResolver}; use defuse_wallet::{NearPromise, Request, WalletOp, actions::FunctionCall}; use defuse_wallet_ed25519::{WalletEd25519, WalletEd25519Signer, crypto::ed25519::ed25519_dalek}; use defuse_wallet_sdk::{ - Gas, NearToken, + Gas, NearToken, WalletBuilder, client::{WExecuteExtensionArgs, WExecuteSignedArgs, WalletContract}, }; -use futures::try_join; +use futures::{ + StreamExt, TryStreamExt, future, + stream::{self, FuturesUnordered}, + try_join, +}; use near_kit::{CryptoHash, Final, Near, PublishMode, sandbox::SandboxConfig}; use rand::{rand_core::UnwrapErr, rngs::SysRng}; use rstest::{fixture, rstest}; use sha2::{Digest, Sha256}; use tokio::sync::OnceCell; +use tracing_subscriber::EnvFilter; type Wallet = defuse_wallet_sdk::Wallet; @@ -22,9 +28,11 @@ type Wallet = defuse_wallet_sdk::Wallet; #[awt] async fn rotate( #[future] near: Near, + #[from(wallet)] #[future] master: Wallet, + #[from(wallet)] #[future] extension: Wallet, @@ -104,6 +112,7 @@ async fn rotate( #[awt] async fn w_init( #[future] near: Near, + #[from(wallet)] #[future] extension: Wallet, @@ -173,15 +182,102 @@ async fn w_init( ); } +#[rstest] +#[case::empty("")] +#[case::test("test")] +#[case::json(JsonPayload { + domain: "Near MPC".to_string(), + action: "sign".to_string(), + msg: "Hello, Near!".to_string(), +}.into())] +#[tokio::test] +#[awt] +async fn w_resolve_auth( + #[case] payload: String, + + #[from(extension)] + #[with(3)] // extensions depth + #[future] + wallet: Wallet, + + #[with(3)] // sub-authorizations depth + #[future] + resolver: RpcResolver, +) { + let authorization = wallet + .sign_offchain_msg(&payload, None) // top-level + .await + .expect("failed to sign"); + + let resolved = resolver + .resolve_auth(wallet.account_id(), &authorization) + .await + .expect("invalid authorization"); + + println!("{authorization}\n{} -> {resolved}", wallet.account_id()); + assert_eq!(payload, resolved, "resolved invalid payload"); +} + #[fixture] #[awt] -async fn wallet(#[future] near: Near) -> Wallet { - Wallet::new( - *WALLET_ED25519_CODE_HASH, - WalletEd25519Signer(ed25519_dalek::SigningKey::generate(&mut UnwrapErr(SysRng))), - ) - .with_client(near.clone()) - .with_relayer(near) +async fn wallet( + #[default(WalletBuilder::new())] builder: WalletBuilder, + #[future] near: Near, +) -> Wallet { + builder + .build( + *WALLET_ED25519_CODE_HASH, + WalletEd25519Signer(ed25519_dalek::SigningKey::generate(&mut UnwrapErr(SysRng))), + ) + .with_client(near.clone()) + .with_relayer(near) +} + +#[fixture] +#[awt] +async fn extension( + #[default(1)] depth: usize, + + #[from(wallet)] + #[future] + ext: Wallet, + + #[future] near: Near, +) -> Wallet { + // recursively create chain of extensions + let wallets = stream::unfold(ext, |ext| async { + let w = wallet( + WalletBuilder::new().extension(&ext), + future::ready(near.clone()), + ) + .await; + Some((ext, w)) + }) + .take(depth + 1) // preserve original wallet + .collect::>() + .await; + + // initialize all wallets + wallets + .iter() + .map(Wallet::initialize) + .collect::>() + .try_collect::<()>() + .await + .unwrap(); + + // reduce as extension chain + wallets.into_iter().reduce(Wallet::as_extension_of).unwrap() +} + +#[fixture] +#[awt] +async fn resolver(#[default(0)] depth: usize, #[future] near: Near) -> RpcResolver { + RpcResolver::new(near.rpc().clone()) + .await + .expect("failed to initialize RPC resolver") + .with_max_sub_authorizations(depth) + .with_max_depth(depth) } #[fixture] @@ -192,6 +288,11 @@ async fn near() -> Near { DEPLOY .get_or_init(|| async { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .pretty() + .init(); + try_join!( near.publish(&**WALLET_ED25519_WASM, PublishMode::Immutable) .into_future(),