Skip to content

Commit

Permalink
feat: add max_payout_sats to profile (#507)
Browse files Browse the repository at this point in the history
* feat: add max_payout_sats to profile

* chore: add update-profile use case
  • Loading branch information
bodymindarts authored Mar 27, 2024
1 parent fca4d7a commit 54b93c5
Show file tree
Hide file tree
Showing 11 changed files with 215 additions and 14 deletions.

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

9 changes: 9 additions & 0 deletions proto/api/bria.proto
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ option go_package = "github.com/GaloyMoney/terraform-provider-bria/client/proto/

service BriaService {
rpc CreateProfile (CreateProfileRequest) returns (CreateProfileResponse) {}
rpc UpdateProfile (UpdateProfileRequest) returns (UpdateProfileResponse) {}
rpc ListProfiles (ListProfilesRequest) returns (ListProfilesResponse) {}
rpc CreateProfileApiKey (CreateProfileApiKeyRequest) returns (CreateProfileApiKeyResponse) {}

Expand Down Expand Up @@ -53,12 +54,20 @@ message CreateProfileRequest {

message SpendingPolicy {
repeated string allowed_payout_addresses = 1;
optional uint64 max_payout_sats = 2;
}

message CreateProfileResponse {
string id = 1;
}

message UpdateProfileRequest {
string id = 1;
optional SpendingPolicy spending_policy = 2;
}

message UpdateProfileResponse {}

message CreateProfileApiKeyRequest {
string profile_name = 1;
}
Expand Down
8 changes: 8 additions & 0 deletions src/api/server/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ impl From<SpendingPolicy> for proto::SpendingPolicy {
.into_iter()
.map(|addr| addr.to_string())
.collect(),
max_payout_sats: sp.maximum_payout.map(u64::from),
}
}
}
Expand All @@ -56,6 +57,7 @@ impl TryFrom<(proto::SpendingPolicy, bitcoin::Network)> for SpendingPolicy {
}
Ok(Self {
allowed_payout_addresses,
maximum_payout: sp.max_payout_sats.map(Satoshis::from),
})
}
}
Expand Down Expand Up @@ -642,6 +644,9 @@ impl From<ApplicationError> for tonic::Status {
ApplicationError::ProfileError(ProfileError::ProfileNameNotFound(_)) => {
tonic::Status::not_found(err.to_string())
}
ApplicationError::ProfileError(ProfileError::ProfileIdNotFound(_)) => {
tonic::Status::not_found(err.to_string())
}
ApplicationError::PayoutError(PayoutError::PayoutIdNotFound(_)) => {
tonic::Status::not_found(err.to_string())
}
Expand All @@ -663,6 +668,9 @@ impl From<ApplicationError> for tonic::Status {
ApplicationError::DestinationNotAllowed(_) => {
tonic::Status::permission_denied(err.to_string())
}
ApplicationError::PayoutExceedsMaximum(_) => {
tonic::Status::permission_denied(err.to_string())
}
ApplicationError::SigningSessionNotFoundForBatchId(_) => {
tonic::Status::not_found(err.to_string())
}
Expand Down
30 changes: 30 additions & 0 deletions src/api/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,36 @@ impl BriaService for Bria {
.await
}

#[instrument(name = "bria.update_profile", skip_all, fields(error, error.level, error.message), err)]
async fn update_profile(
&self,
request: Request<UpdateProfileRequest>,
) -> Result<Response<UpdateProfileResponse>, Status> {
crate::tracing::record_error(|| async move {
extract_tracing(&request);

let key = extract_api_token(&request)?;
let profile = self.app.authenticate(key).await?;
let request = request.into_inner();
let spending_policy = request
.spending_policy
.map(|policy| profile::SpendingPolicy::try_from((policy, self.app.network())))
.transpose()?;
self.app
.update_profile(
&profile,
request
.id
.parse()
.map_err(ApplicationError::CouldNotParseIncomingUuid)?,
spending_policy,
)
.await?;
Ok(Response::new(UpdateProfileResponse {}))
})
.await
}

#[instrument(name = "bria.list_profiles", skip_all, fields(error, error.level, error.message), err)]
async fn list_profiles(
&self,
Expand Down
4 changes: 3 additions & 1 deletion src/app/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::{
outbox::error::OutboxError,
payout::error::PayoutError,
payout_queue::error::PayoutQueueError,
primitives::{bitcoin, PayoutDestination},
primitives::{bitcoin, PayoutDestination, Satoshis},
profile::error::ProfileError,
signing_session::error::SigningSessionError,
utxo::error::UtxoError,
Expand Down Expand Up @@ -67,6 +67,8 @@ pub enum ApplicationError {
DestinationBlocked(PayoutDestination),
#[error("DestinationNotAllowed - profile is not allowed to send to '{0}'")]
DestinationNotAllowed(PayoutDestination),
#[error("PayoutExceedsMaximum - profile is not allowed to send '{0}' satoshis")]
PayoutExceedsMaximum(Satoshis),
#[error("Signing Session not found for batch id: {0}")]
SigningSessionNotFoundForBatchId(crate::primitives::BatchId),
#[error("Signing Session not found for xpub id: {0}")]
Expand Down
21 changes: 21 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ impl App {
Ok(new_profile)
}

#[instrument(name = "app.update_profile", skip(self), err)]
pub async fn update_profile(
&self,
profile: &Profile,
profile_id: ProfileId,
spending_policy: Option<SpendingPolicy>,
) -> Result<(), ApplicationError> {
let mut target_profile = self
.profiles
.find_by_id(profile.account_id, profile_id)
.await?;
target_profile.update_spending_policy(spending_policy);
let mut tx = self.pool.begin().await?;
self.profiles.update(&mut tx, target_profile).await?;
tx.commit().await?;
Ok(())
}

#[instrument(name = "app.list_profiles", skip(self), err)]
pub async fn list_profiles(&self, profile: &Profile) -> Result<Vec<Profile>, ApplicationError> {
let profiles = self.profiles.list_for_account(profile.account_id).await?;
Expand Down Expand Up @@ -822,6 +840,9 @@ impl App {
if !profile.is_destination_allowed(&destination) {
return Err(ApplicationError::DestinationNotAllowed(destination));
}
if !profile.is_amount_allowed(sats) {
return Err(ApplicationError::PayoutExceedsMaximum(sats));
}

let mut builder = NewPayout::builder(id);
builder
Expand Down
43 changes: 36 additions & 7 deletions src/profile/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub enum ProfileEvent {
SpendingPolicyUpdated {
spending_policy: SpendingPolicy,
},
SpendingPolicyRemoved {},
}

#[derive(Debug, Builder)]
Expand All @@ -24,29 +25,56 @@ pub struct Profile {
pub id: ProfileId,
pub account_id: AccountId,
pub name: String,
#[builder(default, setter(strip_option))]
#[builder(default)]
pub spending_policy: Option<SpendingPolicy>,

pub(super) events: EntityEvents<ProfileEvent>,
}

impl Profile {
pub fn update_spending_policy(&mut self, policy: Option<SpendingPolicy>) {
if self.spending_policy != policy {
self.spending_policy = policy.clone();
if let Some(policy) = policy {
self.events.push(ProfileEvent::SpendingPolicyUpdated {
spending_policy: policy,
});
} else {
self.events.push(ProfileEvent::SpendingPolicyRemoved {});
}
}
}

pub fn is_destination_allowed(&self, destination: &PayoutDestination) -> bool {
self.spending_policy
.as_ref()
.map(|sp| sp.is_destination_allowed(destination))
.unwrap_or(true)
}

pub fn is_amount_allowed(&self, sats: Satoshis) -> bool {
self.spending_policy
.as_ref()
.map(|sp| sp.is_amount_allowed(sats))
.unwrap_or(true)
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpendingPolicy {
pub allowed_payout_addresses: Vec<Address>,
pub maximum_payout: Option<Satoshis>,
}

impl SpendingPolicy {
fn is_destination_allowed(&self, destination: &PayoutDestination) -> bool {
self.allowed_payout_addresses
.contains(destination.onchain_address())
}

fn is_amount_allowed(&self, amount: Satoshis) -> bool {
self.maximum_payout.map(|max| amount <= max).unwrap_or(true)
}
}

pub struct ProfileApiKey {
Expand Down Expand Up @@ -97,19 +125,20 @@ impl TryFrom<EntityEvents<ProfileEvent>> for Profile {

fn try_from(events: EntityEvents<ProfileEvent>) -> Result<Self, Self::Error> {
let mut builder = ProfileBuilder::default();
for event in events.into_iter() {
for event in events.iter() {
match event {
ProfileEvent::Initialized { id, account_id } => {
builder = builder.id(id).account_id(account_id)
builder = builder.id(*id).account_id(*account_id)
}
ProfileEvent::NameUpdated { name } => {
builder = builder.name(name);
builder = builder.name(name.clone());
}
ProfileEvent::SpendingPolicyUpdated { spending_policy } => {
builder = builder.spending_policy(spending_policy);
builder = builder.spending_policy(Some(spending_policy.clone()));
}
ProfileEvent::SpendingPolicyRemoved {} => builder = builder.spending_policy(None),
}
}
builder.build()
builder.events(events).build()
}
}
4 changes: 4 additions & 0 deletions src/profile/error.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
use thiserror::Error;

use crate::primitives::ProfileId;

#[derive(Error, Debug)]
pub enum ProfileError {
#[error("ProfileError - Api key does not exist")]
ProfileKeyNotFound,
#[error("ProfileError - Could not find profile with name: {0}")]
ProfileNameNotFound(String),
#[error("ProfileError - Could not find profile with id: {0}")]
ProfileIdNotFound(ProfileId),
#[error("ProfileError - Sqlx: {0}")]
Sqlx(#[from] sqlx::Error),
#[error("ProfileError - EntityError: {0}")]
Expand Down
45 changes: 45 additions & 0 deletions src/profile/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,34 @@ impl Profiles {
Ok(profiles)
}

pub async fn find_by_id(
&self,
account_id: AccountId,
id: ProfileId,
) -> Result<Profile, ProfileError> {
let rows = sqlx::query!(
r#"SELECT p.id, e.sequence, e.event_type, e.event
FROM bria_profiles p
JOIN bria_profile_events e ON p.id = e.id
WHERE p.account_id = $1 AND p.id = $2
ORDER BY p.id, sequence"#,
account_id as AccountId,
id as ProfileId
)
.fetch_all(&self.pool)
.await?;

if !rows.is_empty() {
let mut events = EntityEvents::new();
for row in rows {
events.load_event(row.sequence as usize, row.event)?;
}
Ok(Profile::try_from(events)?)
} else {
Err(ProfileError::ProfileIdNotFound(id))
}
}

pub async fn find_by_name(
&self,
account_id: AccountId,
Expand Down Expand Up @@ -157,4 +185,21 @@ impl Profiles {
Err(ProfileError::ProfileKeyNotFound)
}
}

pub async fn update(
&self,
tx: &mut Transaction<'_, Postgres>,
profile: Profile,
) -> Result<(), ProfileError> {
if !profile.events.is_dirty() {
return Ok(());
}
EntityEvents::<ProfileEvent>::persist(
"bria_profile_events",
tx,
profile.events.new_serialized_events(profile.id),
)
.await?;
Ok(())
}
}
7 changes: 1 addition & 6 deletions tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,7 @@ pub async fn create_test_account(pool: &sqlx::PgPool) -> anyhow::Result<Profile>
let app = AdminApp::new(pool.clone(), bitcoin::Network::Regtest);

let profile_key = app.create_account(name.clone()).await?;
Ok(Profile {
id: profile_key.profile_id,
account_id: profile_key.account_id,
name,
spending_policy: None,
})
Ok(Profiles::new(pool).find_by_key(&profile_key.key).await?)
}

pub async fn bitcoind_client() -> anyhow::Result<bitcoincore_rpc::Client> {
Expand Down
Loading

0 comments on commit 54b93c5

Please sign in to comment.