Skip to content

feat: schedule automatic undelegation on AML check failure#1300

Open
Dodecahedr0x wants to merge 3 commits into
masterfrom
dode/automatic-undelegation
Open

feat: schedule automatic undelegation on AML check failure#1300
Dodecahedr0x wants to merge 3 commits into
masterfrom
dode/automatic-undelegation

Conversation

@Dodecahedr0x

@Dodecahedr0x Dodecahedr0x commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Automatically schedules the undelegation of AML-flagged accounts. Currently, if an account fails the AML check, it is just not cloned, meaning it stays owned by the DLP on mainnet but can't be used in the ER. This PR automatically undelegates it.

Breaking Changes

  • None

Summary by CodeRabbit

Release Notes

  • New Features

    • Added automatic undelegation scheduling when AML rejection occurs on delegated accounts.
    • Enhanced error handling for failed undelegation scheduling after AML review.
  • Tests

    • Expanded test coverage for AML rejection and undelegation workflows.
  • Chores

    • Updated dependencies to support new functionality.

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements AML-triggered undelegation scheduling. When FetchCloner detects high-risk addresses during post-delegation action validation, it can trigger undelegation scheduling through a new UndelegationScheduler abstraction. The implementation adds a message-based API to CommittorService for scheduling undelegation intents, wires it through chainlink initialization via a CommittorUndelegationScheduler adapter in the magic validator, and includes comprehensive test coverage with a mock HTTP risk server and recording test doubles.

Suggested reviewers

  • thlorenz
  • bmuddha
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dode/automatic-undelegation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Dodecahedr0x Dodecahedr0x marked this pull request as ready for review June 5, 2026 15:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@magicblock-api/src/magic_validator.rs`:
- Around line 115-120: Wrap the await on self.0.schedule_undelegation(pubkey,
request.account).await in a tokio timeout (e.g.,
tokio::time::timeout(Duration::from_secs(...), ...)) so the call cannot hang
indefinitely; after awaiting the timeout, map a timeout error to the same
chainlink/committor error variant currently produced (the "committor response
channel closed" mapping) so both channel-closure and timeout produce the same
error path, and then continue to .and_then(|result| ...) as before; update
imports if necessary to include tokio::time::timeout and Duration and ensure the
timeout mapping converts into a String consistent with the existing
.map_err(|message| ...) flow.

In `@magicblock-chainlink/src/chainlink/fetch_cloner/tests.rs`:
- Around line 251-255: The mock server's blocking accept loop inside the
tokio::task::spawn_blocking worker (which calls listener.accept() in a for
0..expected_calls loop) can hang CI if fewer connections arrive; modify the
worker to enforce a hard timeout (e.g., 5s) for each accept or for the entire
loop: set the TcpListener to non-blocking (listener.set_nonblocking(true)) or
use Instant::now()/elapsed to track time, repeatedly try accept() with a short
sleep, and if the deadline is exceeded before receiving all expected_calls,
break and return an error (or panic) so the test fails fast instead of hanging
while the test awaits the worker join. Ensure the change is applied to the same
worker block that spawns the mock server and to the other similar instance
mentioned (around the other accept loop).

In `@magicblock-chainlink/tests/10_aml_undelegation.rs`:
- Around line 70-74: The mock risk-server worker can block forever on
listener.accept() inside the spawned closure (the worker) causing join() to
hang; change the worker loop to avoid blocking by calling
listener.set_nonblocking(true) (or use try_accept if available) and perform a
bounded retry loop with a short sleep, counting successful accepts until
expected_calls or until a configurable timeout elapses, then break and return an
error/result so the task completes; update the code that awaits the worker (the
join() site) to handle the early-return error case. Ensure you reference the
spawned closure (worker), listener.accept, expected_calls and the join()
awaiting the worker when making the change.

In `@magicblock-committor-service/src/committor_processor.rs`:
- Around line 302-309: The current intent_id() uses wall-clock nanoseconds which
can collide; replace it with a collision-free generator such as a monotonic
atomic counter or a UUID-based ID instead of SystemTime::now(). Specifically,
update the intent_id() function to read from a shared AtomicU64 (e.g.,
NEXT_INTENT_ID.fetch_add(1, Ordering::SeqCst)) or generate a UUID and
convert/encode it for ScheduledIntentBundle.id so each undelegation gets a
unique, monotonic or globally-unique ID; ensure the chosen generator is
initialized and accessible where intent_id() is called and that persisted
keys/status lookups use the same ID format.

In `@magicblock-committor-service/src/service.rs`:
- Around line 68-72: The ScheduleUndelegation variant embeds AccountSharedData
which causes CommittorService::try_send() to log full account bytes on
TrySendError::{Full,Closed}; change the logging to avoid dumping the account
payload by either redacting ScheduleUndelegation's Debug output or logging only
the pubkey and message type. Concretely, update the CommittorService::try_send()
error paths that currently log the CommittorMessage to instead pattern-match the
message and log a concise string for ScheduleUndelegation (e.g.,
"ScheduleUndelegation { pubkey: <pubkey> }") or implement a custom Debug/Display
for that enum variant that omits AccountSharedData; ensure references to
ScheduleUndelegation, CommittorMessage, AccountSharedData, Pubkey, and
TrySendError::Full/Closed are used to locate and change the relevant code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dd15f542-18ab-4785-a422-c9f5e2e1d1a1

📥 Commits

Reviewing files that changed from the base of the PR and between 2407d9e and 54e0905.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock
  • test-integration/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • magicblock-api/Cargo.toml
  • magicblock-api/src/magic_validator.rs
  • magicblock-chainlink/Cargo.toml
  • magicblock-chainlink/src/chainlink/errors.rs
  • magicblock-chainlink/src/chainlink/fetch_cloner/mod.rs
  • magicblock-chainlink/src/chainlink/fetch_cloner/tests.rs
  • magicblock-chainlink/src/chainlink/mod.rs
  • magicblock-chainlink/tests/10_aml_undelegation.rs
  • magicblock-chainlink/tests/utils/test_context.rs
  • magicblock-committor-service/src/committor_processor.rs
  • magicblock-committor-service/src/service.rs
  • magicblock-committor-service/src/service_ext.rs
  • magicblock-committor-service/src/stubs/changeset_committor_stub.rs
  • test-integration/test-chainlink/src/ixtest_context.rs
  • test-integration/test-chainlink/src/test_context.rs

Comment on lines +115 to +120
self.0
.schedule_undelegation(pubkey, request.account)
.await
.map_err(|err| format!("committor response channel closed: {err}"))
.and_then(|result| result.map_err(|err| err.to_string()))
.map_err(|message| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a timeout around committor scheduling await.

Line 115 awaits the committor response without a deadline. If that actor stalls, this task can hang indefinitely and back up AML-rejection handling. Bound this external call with a timeout and map timeout to the same chainlink error variant.

Suggested fix
         let pubkey = request.pubkey;
-        self.0
-            .schedule_undelegation(pubkey, request.account)
-            .await
+        tokio::time::timeout(
+            Duration::from_secs(5),
+            self.0.schedule_undelegation(pubkey, request.account),
+        )
+        .await
+            .map_err(|_| "committor schedule_undelegation timed out".to_string())?
             .map_err(|err| format!("committor response channel closed: {err}"))
             .and_then(|result| result.map_err(|err| err.to_string()))
             .map_err(|message| {
                 magicblock_chainlink::errors::ChainlinkError::FailedToScheduleUndelegationAfterAmlRejection(
                     pubkey, message,
                 )
             })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@magicblock-api/src/magic_validator.rs` around lines 115 - 120, Wrap the await
on self.0.schedule_undelegation(pubkey, request.account).await in a tokio
timeout (e.g., tokio::time::timeout(Duration::from_secs(...), ...)) so the call
cannot hang indefinitely; after awaiting the timeout, map a timeout error to the
same chainlink/committor error variant currently produced (the "committor
response channel closed" mapping) so both channel-closure and timeout produce
the same error path, and then continue to .and_then(|result| ...) as before;
update imports if necessary to include tokio::time::timeout and Duration and
ensure the timeout mapping converts into a String consistent with the existing
.map_err(|message| ...) flow.

Comment on lines +251 to +255
let worker = tokio::task::spawn_blocking(move || {
for _ in 0..expected_calls {
let (mut stream, _) =
listener.accept().expect("accept mock risk request");
stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound the mock server accept loop to prevent CI hangs.

Line 254 blocks on listener.accept() for each expected request, and Line 289 waits on that worker. If AML calls are fewer than expected_calls, the test can hang instead of failing fast.

Proposed hard-timeout pattern for the mock server worker
 impl MockRiskServer {
     async fn start(
         address_scores: Vec<(String, u64)>,
         expected_calls: usize,
     ) -> Self {
         let listener =
             TcpListener::bind("127.0.0.1:0").expect("bind mock risk server");
+        listener
+            .set_nonblocking(true)
+            .expect("set mock risk listener nonblocking");
         let addr = listener.local_addr().expect("mock risk server address");
         let score_by_address: HashMap<String, u64> =
             address_scores.into_iter().collect();

         let worker = tokio::task::spawn_blocking(move || {
-            for _ in 0..expected_calls {
-                let (mut stream, _) =
-                    listener.accept().expect("accept mock risk request");
+            let deadline = std::time::Instant::now() + Duration::from_secs(3);
+            let mut served = 0usize;
+            while served < expected_calls {
+                let (mut stream, _) = match listener.accept() {
+                    Ok(conn) => conn,
+                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+                        assert!(
+                            std::time::Instant::now() < deadline,
+                            "timed out waiting for mock risk requests: served {served}/{expected_calls}"
+                        );
+                        std::thread::sleep(Duration::from_millis(10));
+                        continue;
+                    }
+                    Err(err) => panic!("accept mock risk request: {err}"),
+                };
+                served += 1;
                 stream
                     .set_read_timeout(Some(Duration::from_secs(2)))
                     .expect("set mock risk read timeout");
                 ...
             }
         });

Also applies to: 288-290

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@magicblock-chainlink/src/chainlink/fetch_cloner/tests.rs` around lines 251 -
255, The mock server's blocking accept loop inside the
tokio::task::spawn_blocking worker (which calls listener.accept() in a for
0..expected_calls loop) can hang CI if fewer connections arrive; modify the
worker to enforce a hard timeout (e.g., 5s) for each accept or for the entire
loop: set the TcpListener to non-blocking (listener.set_nonblocking(true)) or
use Instant::now()/elapsed to track time, repeatedly try accept() with a short
sleep, and if the deadline is exceeded before receiving all expected_calls,
break and return an error (or panic) so the test fails fast instead of hanging
while the test awaits the worker join. Ensure the change is applied to the same
worker block that spawns the mock server and to the other similar instance
mentioned (around the other accept loop).

Comment on lines +70 to +74
let worker = tokio::task::spawn_blocking(move || {
for _ in 0..expected_calls {
let (mut stream, _) =
listener.accept().expect("accept mock risk request");
stream

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent indefinite blocking in the mock risk server worker.

Line 73 can block forever on accept() when the expected call count is not reached, and Line 108 then waits forever on join(). This can stall the whole test run.

Suggested fail-fast listener loop
     async fn start(
         address_scores: Vec<(String, u64)>,
         expected_calls: usize,
     ) -> Self {
         let listener =
             TcpListener::bind("127.0.0.1:0").expect("bind mock risk server");
+        listener
+            .set_nonblocking(true)
+            .expect("set mock risk listener nonblocking");
         let addr = listener.local_addr().expect("mock risk server address");
         let score_by_address: HashMap<String, u64> =
             address_scores.into_iter().collect();

         let worker = tokio::task::spawn_blocking(move || {
-            for _ in 0..expected_calls {
-                let (mut stream, _) =
-                    listener.accept().expect("accept mock risk request");
+            let deadline = std::time::Instant::now() + Duration::from_secs(3);
+            let mut served = 0usize;
+            while served < expected_calls {
+                let (mut stream, _) = match listener.accept() {
+                    Ok(conn) => conn,
+                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+                        assert!(
+                            std::time::Instant::now() < deadline,
+                            "timed out waiting for mock risk requests: served {served}/{expected_calls}"
+                        );
+                        std::thread::sleep(Duration::from_millis(10));
+                        continue;
+                    }
+                    Err(err) => panic!("accept mock risk request: {err}"),
+                };
+                served += 1;
                 ...
             }
         });

Also applies to: 107-109

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@magicblock-chainlink/tests/10_aml_undelegation.rs` around lines 70 - 74, The
mock risk-server worker can block forever on listener.accept() inside the
spawned closure (the worker) causing join() to hang; change the worker loop to
avoid blocking by calling listener.set_nonblocking(true) (or use try_accept if
available) and perform a bounded retry loop with a short sleep, counting
successful accepts until expected_calls or until a configurable timeout elapses,
then break and return an error/result so the task completes; update the code
that awaits the worker (the join() site) to handle the early-return error case.
Ensure you reference the spawned closure (worker), listener.accept,
expected_calls and the join() awaiting the worker when making the change.

Comment on lines +302 to +309
fn intent_id() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.try_into()
.unwrap_or(u64::MAX)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Generate undelegation intent IDs with a collision-free source.

intent_id() is using SystemTime::now().as_nanos() as the sole message ID. Two undelegations scheduled in the same clock tick can get the same ScheduledIntentBundle.id, and that ID is later used as the key for persisted rows / status lookup. The result is unrelated undelegations being merged under one message ID.

Use a monotonic counter or another collision-free generator here instead of raw wall-clock time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@magicblock-committor-service/src/committor_processor.rs` around lines 302 -
309, The current intent_id() uses wall-clock nanoseconds which can collide;
replace it with a collision-free generator such as a monotonic atomic counter or
a UUID-based ID instead of SystemTime::now(). Specifically, update the
intent_id() function to read from a shared AtomicU64 (e.g.,
NEXT_INTENT_ID.fetch_add(1, Ordering::SeqCst)) or generate a UUID and
convert/encode it for ScheduledIntentBundle.id so each undelegation gets a
unique, monotonic or globally-unique ID; ensure the chosen generator is
initialized and accessible where intent_id() is called and that persisted
keys/status lookups use the same ID format.

Comment on lines +68 to +72
ScheduleUndelegation {
pubkey: Pubkey,
account: AccountSharedData,
respond_to: oneshot::Sender<CommittorServiceResult<()>>,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Avoid enqueue-failure logs that dump full account payloads.

CommittorService::try_send() still logs the full CommittorMessage on TrySendError::{Full,Closed}. With AccountSharedData embedded here, a saturated/closed queue will now print raw account bytes for every failed undelegation request. Please log just the pubkey / message type, or redact this variant’s Debug output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@magicblock-committor-service/src/service.rs` around lines 68 - 72, The
ScheduleUndelegation variant embeds AccountSharedData which causes
CommittorService::try_send() to log full account bytes on
TrySendError::{Full,Closed}; change the logging to avoid dumping the account
payload by either redacting ScheduleUndelegation's Debug output or logging only
the pubkey and message type. Concretely, update the CommittorService::try_send()
error paths that currently log the CommittorMessage to instead pattern-match the
message and log a concise string for ScheduleUndelegation (e.g.,
"ScheduleUndelegation { pubkey: <pubkey> }") or implement a custom Debug/Display
for that enum variant that omits AccountSharedData; ensure references to
ScheduleUndelegation, CommittorMessage, AccountSharedData, Pubkey, and
TrySendError::Full/Closed are used to locate and change the relevant code.

@snawaz

snawaz commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

@Dodecahedr0x : can you please add some description as to when/why undelegation should be scheduled and automated.

@taco-paco taco-paco left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this PR digresses from current committor flow, and imo there's no need in this deviation.
Delegation is an account state property, therefore it has to go through builtin program and modify delegated flags.

This makes flow similar to regular Intent scheduling one, in fact you do schedule an Intent youself.

  1. Validator detect account is malicious
  2. Validator sends TX in magic-program(new entrypoint, or an old one with commit)
  3. magic-program mutates account states correctly and schedules undelegation(mark_account_as_undelegated )
  4. CommittorService picks it up and executes as in current flow

This gets rid of new schedule_undelegation functionality completely as it will seamlessly integrates into current Committor logic. All is needed is new entrypoint in magic-program and TX sender

Current state bypasses all of that flow to which it belongs. This additionally lead to an amigious state where in AccountsDB account is delegated but on Base it is not. Maybe cloner will fix this(which I'm not sure about) after some updates, but this leaves window for an attack where in meantime it could be used by user.

struct CommittorUndelegationScheduler(Arc<CommittorService>);

#[async_trait::async_trait]
impl UndelegationScheduler for CommittorUndelegationScheduler {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

magic_validator.rs is already massive, let's extract this into separate file

}
}

pub(crate) fn undelegation_intent_bundle(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This has to be created by magic-program which additionally would call mark_account_as_undelegated

}
}

fn intent_id() -> u64 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Intent ids are assigned by magic-program

@snawaz snawaz changed the title feat: schedule automatic undelegation feat: schedule automatic undelegation on AML check failure Jun 10, 2026
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