Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
resolver = "3"
members = [
"admin-utils",
"adr36",
"auth-call",
"bitmap",
"borsh-utils",
Expand Down Expand Up @@ -45,6 +46,7 @@ rust-version = "1.86.0"

[workspace.dependencies]
defuse-admin-utils.path = "admin-utils"
defuse-adr36.path = "adr36"
defuse-auth-call.path = "auth-call"
defuse-bitmap.path = "bitmap"
defuse-borsh-utils.path = "borsh-utils"
Expand Down
17 changes: 17 additions & 0 deletions adr36/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "defuse-adr36"
edition.workspace = true
version.workspace = true
rust-version.workspace = true
repository.workspace = true

[dependencies]
defuse-crypto = { workspace = true, features = ["serde"] }
near-sdk.workspace = true

[dev-dependencies]
near-sdk = { workspace = true, features = ["unit-testing"] }
hex-literal.workspace = true

[lints]
workspace = true
189 changes: 189 additions & 0 deletions adr36/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
use defuse_crypto::serde::AsCurve;
use defuse_crypto::{Curve, Payload, Secp256k1, SignedPayload};
use near_sdk::base64::engine::{Engine, general_purpose::STANDARD};
use near_sdk::serde_json::json;
use near_sdk::{CryptoHash, env, near};

/// The signing algorithm to use. It determines the hash function to be used before signing.
/// If the chain you are using is Ethermint-like, usually your wallet sdk goes with the "ethsecp256k1",
/// otherwise "secp256k1".
/// [reference](https://github.com/chainapsis/keplr-wallet/blob/71552ca8443a3d40263cdfa1df4a7390e9c58692/packages/background/src/keyring-cosmos/service.ts#L1151)
#[near(serializers = [json])]
#[serde(rename_all = "snake_case")]
#[derive(Debug, Clone)]
pub enum Algorithm {
Secp256k1,
Ethsecp256k1,
}

impl Algorithm {
pub fn hash(&self, data: &[u8]) -> CryptoHash {
match self {
Self::Secp256k1 => env::sha256_array(data),
Self::Ethsecp256k1 => env::keccak256_array(data),
}
}
}

impl Default for Algorithm {
fn default() -> Self {
Self::Secp256k1
}
}

/// [ADR-36 Standard reference](https://github.com/cosmos/cosmos-sdk/blob/main/docs/architecture/adr-036-arbitrary-signature.md)
/// [Usage docs](https://docs.keplr.app/api/guide/sign-arbitrary#adr-36-signing-with-signamino)
#[near(serializers = [json])]
#[serde(rename_all = "snake_case")]
#[derive(Debug, Clone)]
pub struct Adr36Payload {
pub message: String,
/// The Bech32 address of the account that will sign the message.
/// Example: `cosmos1skjwj5whet0lpe65qaq4rpq03hjxlwd5c9m9s6`, where:
/// * `cosmos`: network prefix
/// * `1`: separator
/// * remainder: data + checksum
pub signer: String,
#[serde(default)]
pub algo: Algorithm,
}

impl Adr36Payload {
#[inline]
pub const fn new(message: String, signer: String, algo: Algorithm) -> Self {
Self {
message,
signer,
algo,
}
}

/// [Implementation reference](https://github.com/chainapsis/keplr-wallet/blob/59b2e18122dc2ec3b12d3005fec709e4bcc885f8/packages/cosmos/src/adr-36/amino.ts#L88)
#[inline]
pub fn prehash(&self) -> Vec<u8> {
let json = json!({
"account_number": "0",
"chain_id": "",
"fee": {
"amount": [],
"gas": "0",
},
"memo": "",
"msgs": [
{
"type": "sign/MsgSignData",
"value": {
"data": STANDARD.encode(self.message.as_bytes()),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to docs:

It's applications developers decision how Data should be treated, by treated we mean the serialization and deserialization process and the Object Data should represent.

Since we already represent self.message as String, does it make sense to avoid base64 encoding here and embed message as-is?

Copy link
Author

@kuksag kuksag Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I'm gonna say I've overlooked that, because I was referencing implementations of the most used wallets on Cosmos SDK, and they wrap a message into base64:

To comply with the standard and to have something working in practise, we can verify the signature against both b64(message) (as of right now) and plaindata.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok, if that's a convention across ecosystem - I'm ok with leaving it in current state, i.e. always encode as base64

"signer": self.signer,
},
},
],
"sequence": "0",
});
json.to_string().into_bytes()
}
}

impl Payload for Adr36Payload {
#[inline]
fn hash(&self) -> CryptoHash {
self.algo.hash(&self.prehash())
}
}

#[near(serializers = [json])]
#[derive(Debug, Clone)]
pub struct SignedAdr36Payload {
pub payload: Adr36Payload,

#[serde_as(as = "AsCurve<Secp256k1>")]
pub signature: <Secp256k1 as Curve>::Signature,
}

impl Payload for SignedAdr36Payload {
#[inline]
fn hash(&self) -> CryptoHash {
self.payload.hash()
}
}

impl SignedPayload for SignedAdr36Payload {
type PublicKey = <Secp256k1 as Curve>::PublicKey;

#[inline]
fn verify(&self) -> Option<Self::PublicKey> {
Secp256k1::verify(&self.signature, &self.payload.hash(), &())
}
Comment on lines +110 to +116
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the adr36 directory and examine the file
find . -type f -name "lib.rs" | grep -E "adr36|Adr36"

Repository: near/intents

Length of output: 74


🏁 Script executed:

# Read the file to see the context around lines 110-116 and 126-132
cat -n adr36/src/lib.rs | sed -n '100,145p'

Repository: near/intents

Length of output: 2104


🏁 Script executed:

# Check the entire file to understand the structure
wc -l adr36/src/lib.rs

Repository: near/intents

Length of output: 76


🏁 Script executed:

# Check where fix_v_in_signature is used
grep -n "fix_v_in_signature" adr36/src/lib.rs

Repository: near/intents

Length of output: 268


🏁 Script executed:

# See more of the test code to understand how it's used
cat -n adr36/src/lib.rs | sed -n '145,189p'

Repository: near/intents

Length of output: 1786


🏁 Script executed:

# Check if there are other files that might handle v normalization
rg "fix_v_in_signature|normalize.*v" --type rust

Repository: near/intents

Length of output: 1005


Normalize Ethereum-style recovery v before verification.

Tests normalize v using fix_v_in_signature() (lines 126-133) before calling verify(), but production verify() doesn't perform this normalization. Ethereum wallets emit v = 27/28, and without normalization in the production code, signatures from real wallets will fail verification even though test signatures work (because tests manually normalize before verification).

🛠️ Proposed fix
+fn normalize_v(mut sig: <Secp256k1 as Curve>::Signature) -> <Secp256k1 as Curve>::Signature {
+    // Ethereum-style signatures use v=27/28; normalize to 0/1
+    if sig[64] >= 27 {
+        sig[64] -= 27;
+    }
+    sig
+}
+
 impl SignedPayload for SignedAdr36Payload {
     type PublicKey = <Secp256k1 as Curve>::PublicKey;

     #[inline]
     fn verify(&self) -> Option<Self::PublicKey> {
-        Secp256k1::verify(&self.signature, &self.payload.hash(), &())
+        let sig = normalize_v(self.signature);
+        Secp256k1::verify(&sig, &self.payload.hash(), &())
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
impl SignedPayload for SignedAdr36Payload {
type PublicKey = <Secp256k1 as Curve>::PublicKey;
#[inline]
fn verify(&self) -> Option<Self::PublicKey> {
Secp256k1::verify(&self.signature, &self.payload.hash(), &())
}
fn normalize_v(mut sig: <Secp256k1 as Curve>::Signature) -> <Secp256k1 as Curve>::Signature {
// Ethereum-style signatures use v=27/28; normalize to 0/1
if sig[64] >= 27 {
sig[64] -= 27;
}
sig
}
impl SignedPayload for SignedAdr36Payload {
type PublicKey = <Secp256k1 as Curve>::PublicKey;
#[inline]
fn verify(&self) -> Option<Self::PublicKey> {
let sig = normalize_v(self.signature);
Secp256k1::verify(&sig, &self.payload.hash(), &())
}
}
🤖 Prompt for AI Agents
In `@adr36/src/lib.rs` around lines 110 - 116, The verify() implementation in
SignedAdr36Payload (function SignedAdr36Payload::verify) does not normalize
Ethereum-style recovery byte v (27/28), so production verification can fail for
wallet signatures; update verify() to normalize the signature by calling the
existing fix_v_in_signature(...) on self.signature (or a copy) before passing it
to Secp256k1::verify(&normalized_sig, &self.payload.hash(), &()), ensuring the
same v-normalization used in tests is applied in production.

}

#[cfg(test)]
mod tests {
use crate::{Adr36Payload, Algorithm, SignedAdr36Payload};
use defuse_crypto::{Payload, SignedPayload};
use hex_literal::hex;
use near_sdk::CryptoHash;

const fn fix_v_in_signature(mut sig: [u8; 65]) -> [u8; 65] {
if *sig.last().unwrap() >= 27 {
// Ethereum only uses uncompressed keys, with corresponding value v=27/28
// https://bitcoin.stackexchange.com/a/38909/58790
*sig.last_mut().unwrap() -= 27;
}
sig
}

/// golang cosmos-sdk [repro](https://gist.github.com/kuksag/eeb8ef3a77e6751d53db006b206925ab)
const REFERENCE_MESSAGE: &str = "Hello, ADR-036!";
const REFERENCE_SECP256K1_SIGNER: &str = "cosmos1mnyn7x24xj6vraxeeq56dfkxa009tvhgknhm04";
const REFERENCE_SHA256_HASH_MESSAGE_HEX: CryptoHash =
hex!("5ac8daed449a016684fd64bade7510b75ccd7c6eefa31b60a10eb577b37575e3");
const REFERENCE_SIGNATURE: [u8; 65] = hex!(
"043485aac9cd7de64da9548b72635c061e07b20063488c0d3affc3c843b33c0458f799ac6592b6260c5ce326be4996d95ee8cfbea4ae76b820f2a7a01ad3a5cc1b"
);
const WRONG_REFERENCE_SIGNATURE: [u8; 65] = hex!(
"c6ada709bab5a03bdbeb7e53e54ff77afbfec9e4f7b3d1b588e24f09d6f5dc305fa7cdb36c78d9f0c31859879eb930d28c890bcf9e27944e7e8808b1a53c09661c"
);
const REFERENCE_PUBKEY: [u8; 64] = hex!(
"4646ae5047316b4230d0086c8acec687f00b1cd9d1dc634f6cb358ac0a9a8ffffe77b4dd0a4bfb95851f3b7355c781dd60f8418fc8a65d14907aff47c903a559"
);

#[test]
fn test_expected_sha256_hash() {
let payload = Adr36Payload::new(
REFERENCE_MESSAGE.to_string(),
REFERENCE_SECP256K1_SIGNER.to_string(),
Algorithm::Secp256k1,
);
assert_eq!(payload.hash(), REFERENCE_SHA256_HASH_MESSAGE_HEX);
}

#[test]
fn test_reference_signature_verification_works() {
let payload = Adr36Payload::new(
REFERENCE_MESSAGE.to_string(),
REFERENCE_SECP256K1_SIGNER.to_string(),
Algorithm::Secp256k1,
);
let signature = fix_v_in_signature(REFERENCE_SIGNATURE);

assert_eq!(
SignedAdr36Payload { payload, signature }.verify(),
Some(REFERENCE_PUBKEY)
);
}

#[test]
fn test_reference_signature_verification_fails() {
let payload = Adr36Payload::new(
REFERENCE_MESSAGE.to_string(),
REFERENCE_SECP256K1_SIGNER.to_string(),
Algorithm::Secp256k1,
);
let signature = fix_v_in_signature(WRONG_REFERENCE_SIGNATURE);

assert_ne!(
SignedAdr36Payload { payload, signature }.verify(),
Some(REFERENCE_PUBKEY)
);
}
}
1 change: 1 addition & 0 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ repository.workspace = true

[dependencies]
defuse-auth-call.workspace = true
defuse-adr36.workspace = true
defuse-bitmap.workspace = true
defuse-crypto = { workspace = true, features = ["serde"] }
defuse-deadline.workspace = true
Expand Down
26 changes: 26 additions & 0 deletions core/src/payload/adr36.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use crate::payload::{DefusePayload, ExtractDefusePayload};
use defuse_adr36::{Adr36Payload, SignedAdr36Payload};
use near_sdk::{serde::de::DeserializeOwned, serde_json};

impl<T> ExtractDefusePayload<T> for SignedAdr36Payload
where
T: DeserializeOwned,
{
type Error = serde_json::Error;

#[inline]
fn extract_defuse_payload(self) -> Result<DefusePayload<T>, Self::Error> {
self.payload.extract_defuse_payload()
}
}

impl<T> ExtractDefusePayload<T> for Adr36Payload
where
T: DeserializeOwned,
{
type Error = serde_json::Error;

fn extract_defuse_payload(self) -> Result<DefusePayload<T>, Self::Error> {
serde_json::from_str(&self.message)
}
}
1 change: 1 addition & 0 deletions core/src/payload/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod adr36;
pub mod erc191;
pub mod multi;
pub mod nep413;
Expand Down
18 changes: 13 additions & 5 deletions core/src/payload/multi.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
use super::{
DefusePayload, ExtractDefusePayload, raw::SignedRawEd25519Payload,
webauthn::SignedWebAuthnPayload,
};
use defuse_adr36::SignedAdr36Payload;
use defuse_crypto::{Payload, PublicKey, SignedPayload};
use defuse_erc191::SignedErc191Payload;
use defuse_nep413::SignedNep413Payload;
Expand All @@ -7,11 +12,6 @@ use defuse_ton_connect::SignedTonConnectPayload;
use derive_more::derive::From;
use near_sdk::{CryptoHash, near, serde::de::DeserializeOwned, serde_json};

use super::{
DefusePayload, ExtractDefusePayload, raw::SignedRawEd25519Payload,
webauthn::SignedWebAuthnPayload,
};

#[near(serializers = [json])]
#[serde(tag = "standard", rename_all = "snake_case")]
#[derive(Debug, Clone, From)]
Expand Down Expand Up @@ -51,6 +51,11 @@ pub enum MultiPayload {
/// SEP-53: The standard for signing data off-chain for Stellar accounts.
/// See [SEP-53](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md)
Sep53(SignedSep53Payload),

/// ADR-36: The standard for signing data off-chain for accounts on Cosmos SDK.
/// Standard reference: https://github.com/cosmos/cosmos-sdk/blob/main/docs/architecture/adr-036-arbitrary-signature.md
/// Keplr-Wallet docs (implementation reference): https://docs.keplr.app/api/guide/sign-arbitrary#adr-36-signing-with-signamino
Adr36(SignedAdr36Payload),
}

impl Payload for MultiPayload {
Expand All @@ -68,6 +73,7 @@ impl Payload for MultiPayload {
Self::WebAuthn(payload) => payload.hash(),
Self::TonConnect(payload) => payload.hash(),
Self::Sep53(payload) => payload.hash(),
Self::Adr36(payload) => payload.hash(),
}
}
}
Expand All @@ -85,6 +91,7 @@ impl SignedPayload for MultiPayload {
Self::WebAuthn(payload) => payload.verify(),
Self::TonConnect(payload) => payload.verify().map(PublicKey::Ed25519),
Self::Sep53(payload) => payload.verify().map(PublicKey::Ed25519),
Self::Adr36(payload) => payload.verify().map(PublicKey::Secp256k1),
}
}
}
Expand All @@ -105,6 +112,7 @@ where
Self::WebAuthn(payload) => payload.extract_defuse_payload(),
Self::TonConnect(payload) => payload.extract_defuse_payload(),
Self::Sep53(payload) => payload.extract_defuse_payload(),
Self::Adr36(payload) => payload.extract_defuse_payload(),
}
}
}
Expand Down
Loading