Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ens): updating name attributes handler #509

Merged
merged 3 commits into from
Feb 9, 2024
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ serde_json = "1.0"
serde_piecewise_default = "0.2"
serde-aux = "3.1"
validator = { version = "0.16", features = ["derive"] }
num_enum = "0.7"

# Storage
aws-config = "0.56"
Expand Down
50 changes: 42 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,35 @@ 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 randomBioString = Array.from({ length: 24 },
() => (Math.random().toString(36)[2] || '0')).join('')
const updatedAttributes = {
bio: randomBioString,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changes to use the random value here.

};
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['bio']).toBe(updatedAttributes['bio'])
})
})
2 changes: 2 additions & 0 deletions src/database/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ pub enum DatabaseError {
BadArgument(String),
#[error("Address required: {0}")]
AddressRequired(String),
#[error("{0:?}")]
SerdeJson(#[from] serde_json::Error),
}
26 changes: 17 additions & 9 deletions src/database/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use {
crate::database::{error::DatabaseError, types, utils},
chrono::{DateTime, Utc},
sqlx::{PgPool, Postgres},
sqlx::{PgPool, Postgres, Row},
std::collections::HashMap,
tracing::{error, instrument},
};
Expand Down Expand Up @@ -68,21 +68,29 @@ pub async fn delete_name(
}

#[instrument(skip(postgres))]
pub async fn update_name(
pub async fn update_name_attributes(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactor this function name to reflect that it updates only attributes.

name: String,
attributes: HashMap<String, String>,
postgres: &PgPool,
) -> Result<sqlx::postgres::PgQueryResult, sqlx::error::Error> {
let insert_name_query = "
) -> Result<HashMap<String, String>, DatabaseError> {
let update_attributes_query = "
UPDATE names SET attributes = $2::hstore, updated_at = NOW()
WHERE name = $1
WHERE name = $1
RETURNING attributes::json
";
sqlx::query::<Postgres>(insert_name_query)
let row = sqlx::query(update_attributes_query)
.bind(&name)
// Convert JSON to String for hstore update
.bind(&utils::hashmap_to_hstore(&attributes))
.execute(postgres)
.await
.fetch_one(postgres)
.await?;
let result: serde_json::Value = row.get(0);
let updated_attributes_result: Result<HashMap<String, String>, DatabaseError> =
serde_json::from_value(result.clone()).map_err(|e| {
error!("Failed to deserialize updated attributes: {}", e);
DatabaseError::SerdeJson(e)
});

updated_attributes_result
}

#[instrument(skip(postgres))]
Expand Down
168 changes: 168 additions & 0 deletions src/handlers/profile/attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
use {
super::{
super::HANDLER_TASK_METRICS,
utils::{check_attributes, is_timestamp_within_interval},
Eip155SupportedChains,
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::{
extract::{Path, State},
response::{IntoResponse, Response},
Json,
},
hyper::StatusCode,
num_enum::TryFromPrimitive,
std::{str::FromStr, sync::Arc},
tracing::log::{error, info},
wc::future::FutureExt,
};

pub async fn handler(
state: State<Arc<AppState>>,
name: Path<String>,
Json(request_payload): Json<RegisterRequest>,
) -> Result<Response, RpcError> {
handler_internal(state, name, request_payload)
.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>,
request_payload: RegisterRequest,
) -> Result<Response, RpcError> {
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 update attributes payload: {}", e);
return Ok((
StatusCode::BAD_REQUEST,
format!("Failed to deserialize update attributes payload: {}", e),
)
.into_response());
}
};

// Check for the supported ENSIP-11 coin type
if Eip155SupportedChains::try_from_primitive(request_payload.coin_type).is_err() {
info!("Unsupported coin type {}", request_payload.coin_type);
return Ok((
StatusCode::BAD_REQUEST,
"Unsupported coin type for name attributes update",
)
.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(&Eip155SupportedChains::EthereumMainnet.into())
{
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());
}

match update_name_attributes(name.clone(), payload.attributes, &state.postgres).await {
Err(e) => {
error!("Failed to update attributes: {}", e);
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update attributes: {}", e),
)
.into_response())
}
Ok(attributes) => Ok(Json(attributes).into_response()),
}
}
19 changes: 19 additions & 0 deletions src/handlers/profile/mod.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
use {
num_enum::{IntoPrimitive, TryFromPrimitive},
once_cell::sync::Lazy,
regex::Regex,
serde::{Deserialize, Serialize},
std::collections::HashMap,
};

pub mod attributes;
pub mod lookup;
pub mod register;
pub mod reverse;
pub mod utils;

/// List of supported Ethereum chains in ENSIP-11 format
#[repr(u32)]
#[derive(Debug, Clone, PartialEq, Eq, IntoPrimitive, TryFromPrimitive)]
enum Eip155SupportedChains {
EthereumMainnet = 60,
}

pub const UNIXTIMESTAMP_SYNC_THRESHOLD: u64 = 10;

/// Attributes value max length
Expand Down Expand Up @@ -37,6 +46,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
Loading
Loading