Skip to content

feat/aml: locking accounts #66

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

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open

feat/aml: locking accounts #66

wants to merge 25 commits into from

Conversation

fusede
Copy link
Collaborator

@fusede fusede commented Apr 22, 2025

See docs for AccountForceLocker:

#[ext_contract(ext_force_account_locker)]
pub trait AccountForceLocker: AccessControllable {
    /// Returns whether the given`account_id` is locked
    fn is_account_locked(&self, account_id: &AccountId) -> bool;

    /// Locks given `account_id` from modifying its own state, including
    /// token balances.
    /// Returns `false` if the account was already in locked state.
    ///
    /// Attached deposit of 1yN is required for security purposes.
    ///
    /// NOTE: this still allows for force withdrawals/transfers
    fn force_lock_account(&mut self, account_id: AccountId) -> bool;
    /// Unlocks given `account_id`.
    /// Returns `false` if the account wasn't in locked state.
    ///
    /// Attached deposit of 1yN is required for security purposes.
    fn force_unlock_account(&mut self, account_id: &AccountId) -> bool;
}

@fusede fusede marked this pull request as ready for review April 22, 2025 18:10
@fusede fusede requested a review from BonelessImpl April 23, 2025 08:23
Comment on lines +52 to +55
let value = "example".to_string();
let poc = PanicOnClone::from_ref(&value);
assert!(ptr::eq(&**poc, &value));
assert_eq!(&**poc, &value);
Copy link
Contributor

Choose a reason for hiding this comment

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

No test for panic behavior?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this struct needs some unit tests... too many special cases.

AccountNotFound(AccountId),

#[error("account '{0}' is locked")]
AccountLocked(AccountId),
Copy link
Contributor

Choose a reason for hiding this comment

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

If I understand correctly what "locked" means here, I think the proper language is "frozen"... we "freeze" bank accounts.

Comment on lines +158 to +176
async fn is_account_locked(&self, account_id: &AccountIdRef) -> anyhow::Result<bool>;
async fn defuse_is_account_locked(
&self,
defuse_contract_id: &AccountId,
account_id: &AccountIdRef,
) -> anyhow::Result<bool>;

async fn force_lock_account(&self, account_id: &AccountIdRef) -> anyhow::Result<bool>;
async fn defuse_force_lock_account(
&self,
defuse_contract_id: &AccountId,
account_id: &AccountIdRef,
) -> anyhow::Result<bool>;
async fn force_unlock_account(&self, account_id: &AccountIdRef) -> anyhow::Result<bool>;
async fn defuse_force_unlock_account(
&self,
defuse_contract_id: &AccountId,
account_id: &AccountIdRef,
) -> anyhow::Result<bool>;
Copy link
Contributor

Choose a reason for hiding this comment

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

I really don't think it's necessary to duplicate these methods... just one of them is enough, and specifying the smart contracts in the tests is alright.

@@ -31,3 +32,23 @@ pub trait AccountManager {
/// NOTE: MUST attach 1 yⓃ for security purposes.
fn invalidate_nonces(&mut self, nonces: Vec<AsBase64<Nonce>>);
}

#[ext_contract(ext_force_account_locker)]
pub trait AccountForceLocker: AccessControllable {
Copy link
Contributor

Choose a reason for hiding this comment

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

Besides using the word freeze instead of lock, I think this trait's name should be something like AccountLockable. If we agree on using freeze, then it's AccountFreezable.

#[autoimpl(AsMut using self.0)]
#[near(serializers = [borsh])]
#[repr(transparent)] // needed for `transmute()` below
pub struct PanicOnClone<T: ?Sized>(T);
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps write some docs that explain the use-case of this struct, i.e., what problem is solves with a little example.

Copy link
Contributor

Choose a reason for hiding this comment

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

/// This struct is used as a tool to make it possible to derive borsh serialization in such a way where serialization can take over a reference and include it as if it's owned by the struct/enum being serialized. The reference can be taken in the function from_ref().

.amount_for(&ft),
123,
);
borsh::to_vec(&versioned).expect("unale to serialize versioned account")
Copy link
Contributor

@BonelessImpl BonelessImpl Apr 24, 2025

Choose a reason for hiding this comment

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

typo: unable

// which we want to avoid.
//
// So, since we allow locked accounts to receive incoming transfers, it means
// that `mt_transfer_call()` to locked recepients will always result in full
Copy link
Contributor

Choose a reason for hiding this comment

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

typo: recepients

Copy link
Contributor

@BonelessImpl BonelessImpl left a comment

Choose a reason for hiding this comment

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

Done with the first review round.

Cargo.lock Outdated
Copy link
Contributor

Choose a reason for hiding this comment

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

Cargo.lock is not up to date. Please run a full build then commit it again.

@@ -1,2 +1,3 @@
pub mod r#as;
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we call this something that doesn't use a literal prefix? Maybe ser_as?

}
impl<R> ReadExt for R where R: Read {}

pub struct TeeReader<R, W> {
Copy link
Contributor

@BonelessImpl BonelessImpl May 12, 2025

Choose a reason for hiding this comment

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

/// A reader that writes everything it reads to the specified writer. Can bee seen as a passthrough.

/// The struct itself does not do any kind of caching. Any apparent caching is possibly done by the inner read and writer.

@@ -48,3 +48,48 @@ pub trait MultiTokenForceWithdrawer: MultiTokenWithdrawer + AccessControllable {
msg: Option<String>,
) -> PromiseOrValue<Vec<U128>>;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

/// MultiTokenCore functions, but a version that bypasses the locked/frozen state of an account.

Copy link
Contributor

Choose a reason for hiding this comment

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

/// All functions must be #[private] or only authorized callers

/// Attached deposit of 1yN is required for security purposes.
///
/// NOTE: this still allows for force withdrawals/transfers
fn force_lock_account(&mut self, account_id: AccountId) -> bool;
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of returning a bool here (and in unlock), we should probably just panic with a message "already locked".

@@ -21,7 +22,7 @@ pub trait AccountManager {
/// i.e. this key can't be used to make any actions unless it's re-created.
///
/// NOTE: MUST attach 1 yⓃ for security purposes.
fn remove_public_key(&mut self, public_key: &PublicKey);
fn remove_public_key(&mut self, public_key: PublicKey);
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we usually prefer passing a reference?

#[inline]
pub fn as_unlocked_mut(&mut self) -> Option<&mut T> {
if self.locked {
pub const fn as_unlocked_mut(&mut self) -> Option<&mut T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps a better name for all "as_unlocked*" functions is "get*".

#[inline]
pub fn unlock(&mut self) -> Option<&mut T> {
if !self.locked {
pub const fn as_unlocked_or_mut(&mut self, force: bool) -> Option<&mut T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

get_force_mut?

#[inline]
pub const fn is_locked(&self) -> bool {
self.locked
}

#[must_use]
#[inline]
pub const fn as_locked(&self) -> Option<&T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really need this function? What's the purpose of a function that gets while asserting the lock is activated? It should be either "I don't care about the lock" or "Get only if unlocked". This feels more like technical jargon to me.

Copy link
Contributor

Choose a reason for hiding this comment

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

This applies to all "as_locked" functions.

Choose a reason for hiding this comment

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

This Lock was initially made when we were writing the very first version of defuse contract, which was implementing "escrow swap" functionality. It turned out that in some particular cases you expect the lock to be locked, because some actions might be permitted only when it's locked.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we remove these now to make the API simpler to understand? If we need these functions in the future, we can assess whether we need them and add them if we do.

Comment on lines +185 to +216
impl<T, As> BorshSerializeAs<Lock<T>> for Lock<As>
where
As: BorshSerializeAs<T>,
{
#[inline]
fn serialize_as<W>(source: &Lock<T>, writer: &mut W) -> io::Result<()>
where
W: io::Write,
{
Lock {
locked: source.locked,
value: AsWrap::<&T, &As>::new(&source.value),
}
.serialize(writer)
}
}

impl<T, As> BorshDeserializeAs<Lock<T>> for Lock<As>
where
As: BorshDeserializeAs<T>,
{
#[inline]
fn deserialize_as<R>(reader: &mut R) -> io::Result<Lock<T>>
where
R: io::Read,
{
Lock::<AsWrap<T, As>>::deserialize_reader(reader).map(|v| Lock {
locked: v.locked,
value: v.value.into_inner(),
})
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

We need some serialization tests to assert the outcome.

///
/// This is safe to assume that legacy [`Account`] doesn't start with
/// this prefix, since the first 4 bytes in legacy [`Account`] were used
/// to detote the length of `prefix: Box<[u8]>` in [`LookupMap`] for
Copy link
Contributor

Choose a reason for hiding this comment

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

"denote"?

Comment on lines +218 to +240
impl BorshDeserializeAs<Lock<Account>> for MaybeVersionedAccountEntry {
fn deserialize_as<R>(reader: &mut R) -> io::Result<Lock<Account>>
where
R: io::Read,
{
let mut buf = Vec::new();
// There will always be 4 bytes for u32:
// * either `VERSIONED_MAGIC_PREFIX`,
// * or u32 for `Account.nonces.prefix`
let prefix = u32::deserialize_reader(&mut reader.tee(&mut buf))?;

if prefix == VERSIONED_MAGIC_PREFIX {
VersionedAccountEntry::deserialize_reader(reader)
} else {
Account::deserialize_reader(
// prepend already consumed part of the reader
&mut buf.chain(reader),
)
.map(Into::into)
}
.map(Into::into)
}
}
Copy link
Contributor

@BonelessImpl BonelessImpl May 15, 2025

Choose a reason for hiding this comment

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

Since we're defining the serialization manually anyway, isn't it better to define the serialization schema without PanicOnClone? We're crossing the barrier of "everything is just derived" to "we have to customize it"... let's go all the way and serialize the enum manually, and deserialize it manually as well. We're already doing that. It would be really awesome if we could throw out PanicOnClone.


#[rstest]
#[test]
fn legacy_upgrade(random_seed: Seed) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is an upgrade test. For such complex serialization schemes, we need fixed value tests too (serialized hex values, back and forth in both cases). This helps in avoiding bugs like the Nest thing, where we need that the complicated scheme ended with something we understand.

To illustrate what I see here: Imagine a function f(x) and its inverse g(x). The test here is kind of testing f(g(x)) = 1. But we need a test for what f(x) alone does, and what g(x) alone does too, because there are trivial cases where f(g(x)) = 1 is always true, but both f and g are broken.

IMHO, this format of testing must exist in any non-standard serialization plan.

#[error("public key already exists")]
PublicKeyExists,
#[error("public key '{1}' already exists for account '{0}'")]
PublicKeyExists(AccountId, PublicKey),
Copy link
Contributor

Choose a reason for hiding this comment

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

I was hesitating, but I'll say it. I love this change. I love to always specify the parameters in errors to make debugging easier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants