Skip to content

Commit

Permalink
feat(ens): update attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
geekbrother committed Feb 7, 2024
1 parent 5803138 commit e871d57
Show file tree
Hide file tree
Showing 7 changed files with 249 additions and 15 deletions.
49 changes: 41 additions & 8 deletions integration/names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,19 @@ describe('Account profile names', () => {
const name = `${randomString}.connect.test`;

// Create a message to sign
const messageObject = {
const registerMessageObject = {
name,
coin_type,
attributes,
timestamp: Math.round(Date.now() / 1000)
};
const message = JSON.stringify(messageObject);
const registerMessage = JSON.stringify(registerMessageObject);

it('register with wrong signature', async () => {
// Sign the message
const signature = await wallet.signMessage('some other message');

const payload = {
message,
message: registerMessage,
signature,
coin_type,
address,
Expand Down Expand Up @@ -68,10 +67,10 @@ describe('Account profile names', () => {

it('register new name', async () => {
// Sign the message
const signature = await wallet.signMessage(message);
const signature = await wallet.signMessage(registerMessage);

const payload = {
message,
message: registerMessage,
signature,
coin_type,
address,
Expand All @@ -82,12 +81,13 @@ describe('Account profile names', () => {
)
expect(resp.status).toBe(200)
})

it('try register already registered name', async () => {
// Sign the message
const signature = await wallet.signMessage(message);
const signature = await wallet.signMessage(registerMessage);

const payload = {
message,
message: registerMessage,
signature,
coin_type,
address,
Expand All @@ -98,17 +98,20 @@ describe('Account profile names', () => {
)
expect(resp.status).toBe(400)
})

it('name forward lookup', async () => {
let resp: any = await httpClient.get(
`${baseUrl}/v1/profile/account/${name}`
)
expect(resp.status).toBe(200)
expect(resp.data.name).toBe(name)
expect(resp.data.attributes['bio']).toBe(attributes['bio'])
expect(typeof resp.data.addresses).toBe('object')
// ENSIP-11 using the 60 for the Ethereum mainnet
const first = resp.data.addresses["60"]
expect(first.address).toBe(address)
})

it('name reverse lookup', async () => {
let resp: any = await httpClient.get(
`${baseUrl}/v1/profile/reverse/${address}`
Expand All @@ -122,4 +125,34 @@ describe('Account profile names', () => {
const first_address = first_name.addresses["60"]
expect(first_address.address).toBe(address)
})

it('update name attributes', async () => {
// Prepare updated attributes payload
const updatedAttributes = {
bio: 'integration test domain updated attribute',
};
const updateAttributesMessageObject = {
attributes: updatedAttributes,
timestamp: Math.round(Date.now() / 1000)
};
const updateMessage = JSON.stringify(updateAttributesMessageObject);

// Sign the message
const signature = await wallet.signMessage(updateMessage);

const payload = {
message: updateMessage,
signature,
coin_type,
address,
};
let resp: any = await httpClient.post(
`${baseUrl}/v1/profile/account/${name}/attributes`,
payload
);

expect(resp.status).toBe(200)
expect(resp.data.name).toBe(name)
expect(resp.data.attributes['bio']).toBe(updatedAttributes['bio'])
})
})
2 changes: 1 addition & 1 deletion src/database/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub async fn delete_name(
}

#[instrument(skip(postgres))]
pub async fn update_name(
pub async fn update_name_attributes(
name: String,
attributes: HashMap<String, String>,
postgres: &PgPool,
Expand Down
180 changes: 180 additions & 0 deletions src/handlers/profile/attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
use {
super::{
super::HANDLER_TASK_METRICS,
utils::{check_attributes, is_timestamp_within_interval},
RegisterRequest,
UpdateAttributesPayload,
UNIXTIMESTAMP_SYNC_THRESHOLD,
},
crate::{
database::helpers::{get_name_and_addresses_by_name, update_name_attributes},
error::RpcError,
state::AppState,
utils::crypto::{constant_time_eq, verify_message_signature},
},
axum::{
body::Bytes,
extract::{Path, State},
response::{IntoResponse, Response},
Json,
},
hyper::StatusCode,
sqlx::Error as SqlxError,
std::{str::FromStr, sync::Arc},
tracing::log::{error, info},
wc::future::FutureExt,
};

pub async fn handler(
state: State<Arc<AppState>>,
name: Path<String>,
body: Bytes,
) -> Result<Response, RpcError> {
handler_internal(state, name, body)
.with_metrics(HANDLER_TASK_METRICS.with_name("profile_attributes_update"))
.await
}

#[tracing::instrument(skip(state))]
pub async fn handler_internal(
state: State<Arc<AppState>>,
Path(name): Path<String>,
body: Bytes,
) -> Result<Response, RpcError> {
// Check the request body format
let request_payload = match serde_json::from_slice::<RegisterRequest>(&body) {
Ok(update_request_payload) => update_request_payload,
Err(e) => {
info!("Failed to deserialize register request: {}", e);
return Ok((StatusCode::BAD_REQUEST, "").into_response());
}
};

let raw_payload = &request_payload.message;
let payload = match serde_json::from_str::<UpdateAttributesPayload>(raw_payload) {
Ok(payload) => payload,
Err(e) => {
info!("Failed to deserialize register payload: {}", e);
return Ok((StatusCode::BAD_REQUEST, "").into_response());
}
};

// Check for the supported ENSIP-11 coin type
if request_payload.coin_type != 60 {
info!("Unsupported coin type {}", request_payload.coin_type);
return Ok((
StatusCode::BAD_REQUEST,
"Only Ethereum Mainnet (60) coin type is supported for name registration",
)
.into_response());
}

// Check is name registered
let name_addresses =
match get_name_and_addresses_by_name(name.clone(), &state.postgres.clone()).await {
Ok(result) => result,
Err(_) => {
info!(
"Update attributes request for not registered name {}",
name.clone()
);
return Ok((StatusCode::BAD_REQUEST, "Name is not registered").into_response());
}
};

// Check the timestamp is within the sync threshold interval
if !is_timestamp_within_interval(payload.timestamp, UNIXTIMESTAMP_SYNC_THRESHOLD) {
return Ok((
StatusCode::BAD_REQUEST,
"Timestamp is too old or in the future",
)
.into_response());
}

let payload_owner = match ethers::types::H160::from_str(&request_payload.address) {
Ok(owner) => owner,
Err(e) => {
info!("Failed to parse H160 address: {}", e);
return Ok((StatusCode::BAD_REQUEST, "Invalid H160 address format").into_response());
}
};

// Check the signature
let sinature_check =
match verify_message_signature(raw_payload, &request_payload.signature, &payload_owner) {
Ok(sinature_check) => sinature_check,
Err(e) => {
info!("Invalid signature: {}", e);
return Ok((
StatusCode::UNAUTHORIZED,
"Invalid signature or message format",
)
.into_response());
}
};
if !sinature_check {
return Ok((StatusCode::UNAUTHORIZED, "Signature verification error").into_response());
}

// Check for the name address ownership and address from the signed payload
let name_owner = match name_addresses.addresses.get(&60) {
Some(address_entry) => match ethers::types::H160::from_str(&address_entry.address) {
Ok(owner) => owner,
Err(e) => {
info!("Failed to parse H160 address: {}", e);
return Ok((StatusCode::BAD_REQUEST, "Invalid H160 address format").into_response());
}
},
None => {
info!("Address entry not found for key 60");
return Ok((
StatusCode::BAD_REQUEST,
"Address entry not found for key 60",
)
.into_response());
}
};
if !constant_time_eq(payload_owner, name_owner) {
return Ok((
StatusCode::UNAUTHORIZED,
"Address is not the owner of the name",
)
.into_response());
}

// Check for supported attributes
if !check_attributes(
&payload.attributes,
&super::SUPPORTED_ATTRIBUTES,
super::ATTRIBUTES_VALUE_MAX_LENGTH,
) {
return Ok((
StatusCode::BAD_REQUEST,
"Unsupported attribute in
payload",
)
.into_response());
}

let update_attributes_result =
update_name_attributes(name.clone(), payload.attributes, &state.postgres).await;
if let Err(e) = update_attributes_result {
error!("Failed to update attributes: {}", e);
return Ok((StatusCode::INTERNAL_SERVER_ERROR, "").into_response());
}

// Return the registered name and addresses
match get_name_and_addresses_by_name(name, &state.postgres.clone()).await {
Ok(response) => Ok(Json(response).into_response()),
Err(e) => match e {
SqlxError::RowNotFound => {
error!("New registered name is not found in the database: {}", e);
Ok((StatusCode::INTERNAL_SERVER_ERROR, "Name is not registered").into_response())
}
_ => {
error!("Error on lookup new registered name: {}", e);
Ok((StatusCode::INTERNAL_SERVER_ERROR, "Name is not registered").into_response())
}
},
}
}
11 changes: 11 additions & 0 deletions src/handlers/profile/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use {
std::collections::HashMap,
};

pub mod attributes;
pub mod lookup;
pub mod register;
pub mod reverse;
Expand Down Expand Up @@ -37,6 +38,16 @@ pub struct RegisterPayload {
pub timestamp: u64,
}

/// Payload to update name attributes that should be serialized to JSON and
/// signed
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateAttributesPayload {
/// Attributes
pub attributes: HashMap<String, String>,
/// Unixtime
pub timestamp: u64,
}

/// Data structure representing a request to register a name
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RegisterRequest {
Expand Down
5 changes: 5 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ pub async fn bootstrap(config: Config) -> RpcResult<()> {
.route(
"/v1/profile/account",
post(handlers::profile::register::handler),
)
// Update account name attributes
.route(
"/v1/profile/account/:name/attributes",
post(handlers::profile::attributes::handler),
)
// Forward address lookup
.route(
Expand Down
12 changes: 8 additions & 4 deletions src/utils/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,18 @@ pub fn string_chain_id_to_caip2_format(chain_id: &str) -> Result<String, anyhow:
))
}

/// Compare two strings in constant time to prevent timing attacks
pub fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
/// Compare two values (either H160 or &str) in constant time to prevent timing
/// attacks
pub fn constant_time_eq(a: impl AsRef<[u8]>, b: impl AsRef<[u8]>) -> bool {
let a_bytes = a.as_ref();
let b_bytes = b.as_ref();

if a_bytes.len() != b_bytes.len() {
return false;
}

let mut result = 0;
for (byte_a, byte_b) in a.bytes().zip(b.bytes()) {
for (byte_a, byte_b) in a_bytes.iter().zip(b_bytes.iter()) {
result |= byte_a ^ byte_b;
}

Expand Down
5 changes: 3 additions & 2 deletions tests/functional/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use {
get_names_by_address_and_namespace,
insert_address,
insert_name,
update_name,
update_name_attributes,
},
types,
},
Expand Down Expand Up @@ -242,7 +242,8 @@ async fn insert_and_update_name_attributes() {
// Updating the name with new attributes
let updated_attributes: HashMap<String, String> =
HashMap::from_iter([("GitHub".to_string(), "SomeProfile".to_string())]);
let updated_result = update_name(name.clone(), updated_attributes.clone(), &pg_pool).await;
let updated_result =
update_name_attributes(name.clone(), updated_attributes.clone(), &pg_pool).await;
assert!(updated_result.is_ok(), "Updating name should succeed");

let got_update_name = get_name(name.clone(), &pg_pool).await.unwrap();
Expand Down

0 comments on commit e871d57

Please sign in to comment.