From b4675752f572acb3e4d2341b9ddaa7edd5335109 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Wed, 26 Aug 2026 18:57:55 +0200 Subject: [PATCH] fix(vault): route the two ways out of a pending transfer --- api/openapi.bundled.json | 112 +++++++++++++ api/openapi.bundled.yaml | 80 +++++++++ api/openapi.yaml | 4 + ...lt_api_v1_dpp_{dppId}_transfer_cancel.yaml | 38 +++++ ...lt_api_v1_dpp_{dppId}_transfer_reject.yaml | 40 +++++ crates/dpp-node/tests/smoke.rs | 158 ++++++++++++++++++ .../dpp-vault/src/domain/service/transfer.rs | 156 ++++++++++++++++- crates/dpp-vault/src/handlers/transfer.rs | 60 ++++++- crates/dpp-vault/src/router.rs | 18 +- 9 files changed, 660 insertions(+), 6 deletions(-) create mode 100644 api/paths/vault_api_v1_dpp_{dppId}_transfer_cancel.yaml create mode 100644 api/paths/vault_api_v1_dpp_{dppId}_transfer_reject.yaml diff --git a/api/openapi.bundled.json b/api/openapi.bundled.json index 8cfccbe..9f614dc 100644 --- a/api/openapi.bundled.json +++ b/api/openapi.bundled.json @@ -810,6 +810,118 @@ } } }, + "/vault/api/v1/dpp/{dppId}/transfer/reject": { + "post": { + "operationId": "rejectDppTransfer", + "summary": "Reject a pending transfer of responsibility", + "description": "The incoming operator refuses the pending handover. The record becomes\nterminal and can never complete, and the passport's transfer chain is\nfree to carry a new handover.\n\nPaired with cancel, this is one of the two ways out of a handover the\ncounterparty never acted on. A chain refuses a new transfer while any\nrecord is still pending, so without these routes a passport whose\ncounterparty went silent could never be transferred again.\n", + "tags": [ + "DPP Lifecycle" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "Transfer rejected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransferRecord" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "No pending transfer to reject for this DPP.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/transfer/cancel": { + "post": { + "operationId": "cancelDppTransfer", + "summary": "Cancel a pending transfer of responsibility", + "description": "The outgoing operator withdraws the pending handover before it\ncompletes. The record becomes terminal and the passport's transfer chain\nis free to carry a new handover.\n\nValid from one state more than reject: a handover may be cancelled after\nthe acceptance step has run but before the record is completed.\n", + "tags": [ + "DPP Lifecycle" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "Transfer cancelled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransferRecord" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "No pending transfer to cancel for this DPP.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, "/vault/api/v1/dpp/{dppId}/history": { "get": { "operationId": "getDppHistory", diff --git a/api/openapi.bundled.yaml b/api/openapi.bundled.yaml index ab97555..b555d28 100644 --- a/api/openapi.bundled.yaml +++ b/api/openapi.bundled.yaml @@ -589,6 +589,86 @@ paths: $ref: '#/components/schemas/Problem' '422': $ref: '#/components/responses/ValidationError' + /vault/api/v1/dpp/{dppId}/transfer/reject: + post: + operationId: rejectDppTransfer + summary: Reject a pending transfer of responsibility + description: | + The incoming operator refuses the pending handover. The record becomes + terminal and can never complete, and the passport's transfer chain is + free to carry a new handover. + + Paired with cancel, this is one of the two ways out of a handover the + counterparty never acted on. A chain refuses a new transfer while any + record is still pending, so without these routes a passport whose + counterparty went silent could never be transferred again. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + responses: + '200': + description: Transfer rejected. + content: + application/json: + schema: + $ref: '#/components/schemas/TransferRecord' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: No pending transfer to reject for this DPP. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/dpp/{dppId}/transfer/cancel: + post: + operationId: cancelDppTransfer + summary: Cancel a pending transfer of responsibility + description: | + The outgoing operator withdraws the pending handover before it + completes. The record becomes terminal and the passport's transfer chain + is free to carry a new handover. + + Valid from one state more than reject: a handover may be cancelled after + the acceptance step has run but before the record is completed. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + responses: + '200': + description: Transfer cancelled. + content: + application/json: + schema: + $ref: '#/components/schemas/TransferRecord' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: No pending transfer to cancel for this DPP. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + $ref: '#/components/responses/ValidationError' /vault/api/v1/dpp/{dppId}/history: get: operationId: getDppHistory diff --git a/api/openapi.yaml b/api/openapi.yaml index eb392be..9f7cd01 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -98,6 +98,10 @@ paths: $ref: paths/vault_api_v1_dpp_{dppId}_transfer_initiate.yaml /vault/api/v1/dpp/{dppId}/transfer/accept: $ref: paths/vault_api_v1_dpp_{dppId}_transfer_accept.yaml + /vault/api/v1/dpp/{dppId}/transfer/reject: + $ref: paths/vault_api_v1_dpp_{dppId}_transfer_reject.yaml + /vault/api/v1/dpp/{dppId}/transfer/cancel: + $ref: paths/vault_api_v1_dpp_{dppId}_transfer_cancel.yaml /vault/api/v1/dpp/{dppId}/history: $ref: paths/vault_api_v1_dpp_{dppId}_history.yaml /vault/api/v1/dpp/{dppId}/stats: diff --git a/api/paths/vault_api_v1_dpp_{dppId}_transfer_cancel.yaml b/api/paths/vault_api_v1_dpp_{dppId}_transfer_cancel.yaml new file mode 100644 index 0000000..e0fec71 --- /dev/null +++ b/api/paths/vault_api_v1_dpp_{dppId}_transfer_cancel.yaml @@ -0,0 +1,38 @@ +post: + operationId: cancelDppTransfer + summary: Cancel a pending transfer of responsibility + description: | + The outgoing operator withdraws the pending handover before it + completes. The record becomes terminal and the passport's transfer chain + is free to carry a new handover. + + Valid from one state more than reject: a handover may be cancelled after + the acceptance step has run but before the record is completed. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: ../components/schemas/DppId.yaml + responses: + '200': + description: Transfer cancelled. + content: + application/json: + schema: + $ref: ../components/schemas/TransferRecord.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '404': + description: No pending transfer to cancel for this DPP. + content: + application/problem+json: + schema: + $ref: ../components/schemas/Problem.yaml + '422': + $ref: ../components/responses/ValidationError.yaml diff --git a/api/paths/vault_api_v1_dpp_{dppId}_transfer_reject.yaml b/api/paths/vault_api_v1_dpp_{dppId}_transfer_reject.yaml new file mode 100644 index 0000000..5a9cc74 --- /dev/null +++ b/api/paths/vault_api_v1_dpp_{dppId}_transfer_reject.yaml @@ -0,0 +1,40 @@ +post: + operationId: rejectDppTransfer + summary: Reject a pending transfer of responsibility + description: | + The incoming operator refuses the pending handover. The record becomes + terminal and can never complete, and the passport's transfer chain is + free to carry a new handover. + + Paired with cancel, this is one of the two ways out of a handover the + counterparty never acted on. A chain refuses a new transfer while any + record is still pending, so without these routes a passport whose + counterparty went silent could never be transferred again. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: ../components/schemas/DppId.yaml + responses: + '200': + description: Transfer rejected. + content: + application/json: + schema: + $ref: ../components/schemas/TransferRecord.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '404': + description: No pending transfer to reject for this DPP. + content: + application/problem+json: + schema: + $ref: ../components/schemas/Problem.yaml + '422': + $ref: ../components/responses/ValidationError.yaml diff --git a/crates/dpp-node/tests/smoke.rs b/crates/dpp-node/tests/smoke.rs index 2405fba..f7f9504 100644 --- a/crates/dpp-node/tests/smoke.rs +++ b/crates/dpp-node/tests/smoke.rs @@ -1082,6 +1082,164 @@ async fn transfer_of_responsibility_dual_signed_then_eol() { assert_eq!(eol["status"], "deactivated"); } +// A pending handover blocks every later transfer until it is ended — and +// before `reject`/`cancel` were routed, nothing could end one. +// +// `TransferChain::initiate_transfer` refuses a new handover while any record is +// `Initiated` or `Accepted`. `Accepted` is transient (accept completes in the +// same call), but `Initiated` is not: a counterparty that never acts leaves the +// record pending forever. `TransferRecord` has always carried `reject` and +// `cancel`; with no route reaching them, that passport could never be +// transferred again by any means the API offered. +// +// This walks the whole shape: block, clear by rejecting, block again, clear by +// cancelling. +#[tokio::test(flavor = "multi_thread")] +async fn a_pending_transfer_blocks_until_rejected_or_cancelled() { + let (base, _container) = start_db_and_node().await; + let token = make_jwt("00000000-0000-0000-0000-000000000067"); + let client = reqwest::Client::new(); + + let created: serde_json::Value = client + .post(format!("{base}/vault/api/v1/dpp")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "productName": "Stuck Transfer Battery", + "manufacturer": {"name": "SmokeTestCorp", "address": "Berlin, DE"}, + "materials": [], + })) + .send() + .await + .expect("create request failed") + .json() + .await + .unwrap(); + let id = created["id"].as_str().expect("id").to_owned(); + + assert_eq!( + client + .post(format!("{base}/vault/api/v1/dpp/{id}/publish")) + .bearer_auth(&token) + .json(&serde_json::json!({})) + .send() + .await + .expect("publish request failed") + .status(), + 200 + ); + + let initiate = |to_did: &'static str, to_name: &'static str| { + let client = client.clone(); + let base = base.clone(); + let id = id.clone(); + let token = token.clone(); + async move { + client + .post(format!("{base}/vault/api/v1/dpp/{id}/transfer/initiate")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "fromOperator": {"did":"did:web:acme.example","name":"Acme GmbH","role":"manufacturer","euOperatorId":null,"country":"DE"}, + "toOperator": {"did":to_did,"name":to_name,"role":"recycler","euOperatorId":null,"country":"DE"}, + "reason": "preparationForReuse" + })) + .send() + .await + .expect("initiate request failed") + } + }; + + // A first handover goes pending, awaiting a counterparty that never acts. + assert_eq!(initiate("did:web:reco.example", "ReCo").await.status(), 200); + + // The chain now refuses a second one. This is the trap: correct behaviour + // on its own, fatal when nothing can clear the first. + let blocked = initiate("did:web:other.example", "Other").await; + assert_eq!( + blocked.status(), + 422, + "a pending handover must block a second one" + ); + + // Reject — the incoming operator refuses. Terminal. + let rejected: serde_json::Value = client + .post(format!("{base}/vault/api/v1/dpp/{id}/transfer/reject")) + .bearer_auth(&token) + .send() + .await + .expect("reject request failed") + .json() + .await + .unwrap(); + assert!( + rejected["rejectedAt"].is_string(), + "reject must stamp rejectedAt: {rejected}" + ); + assert!( + rejected["completedAt"].is_null(), + "a rejected handover never completes" + ); + + // The block is gone: a new handover is accepted onto the chain. + assert_eq!( + initiate("did:web:second.example", "Second").await.status(), + 200, + "rejecting the pending handover must free the chain" + ); + + // That new one is pending in turn, so the chain blocks again. + assert_eq!( + initiate("did:web:third.example", "Third").await.status(), + 422 + ); + + // Cancel — the outgoing operator withdraws. Also terminal, and it clears + // the block by the same rule. + let cancelled: serde_json::Value = client + .post(format!("{base}/vault/api/v1/dpp/{id}/transfer/cancel")) + .bearer_auth(&token) + .send() + .await + .expect("cancel request failed") + .json() + .await + .unwrap(); + assert!( + cancelled["cancelledAt"].is_string(), + "cancel must stamp cancelledAt: {cancelled}" + ); + + assert_eq!( + initiate("did:web:fourth.example", "Fourth").await.status(), + 200, + "cancelling the pending handover must free the chain" + ); + + // With nothing pending, there is nothing left to end. + for verb in ["reject", "cancel"] { + // The fourth handover is still pending, so end it first, then confirm a + // second attempt is refused rather than silently accepted. + let first = client + .post(format!("{base}/vault/api/v1/dpp/{id}/transfer/{verb}")) + .bearer_auth(&token) + .send() + .await + .expect("terminate request failed"); + if first.status() == 200 { + let again = client + .post(format!("{base}/vault/api/v1/dpp/{id}/transfer/{verb}")) + .bearer_auth(&token) + .send() + .await + .expect("second terminate request failed"); + assert_eq!( + again.status(), + 422, + "{verb} with nothing pending must be refused" + ); + } + } +} + // --------------------------------------------------------------------------- // Metrics acceptance test — passport_publish_total counter increments // --------------------------------------------------------------------------- diff --git a/crates/dpp-vault/src/domain/service/transfer.rs b/crates/dpp-vault/src/domain/service/transfer.rs index 0b6101d..f7aa16a 100644 --- a/crates/dpp-vault/src/domain/service/transfer.rs +++ b/crates/dpp-vault/src/domain/service/transfer.rs @@ -1,5 +1,5 @@ -//! `initiate_transfer` and `accept_transfer` — dual-signed transfer of -//! responsibility between operators, persisted as a `TransferChain`. +//! The transfer-of-responsibility lifecycle — `initiate`, `accept`, `reject` +//! and `cancel` — persisted as a `TransferChain`. use chrono::Utc; use dpp_common::{event, event_codes}; @@ -8,7 +8,8 @@ use dpp_domain::{ passport::PassportId, status::PassportStatus, transfer::{ - ResponsibleOperator, TransferChain, TransferReason, TransferRecord, TransferStatus, + ResponsibleOperator, TransferChain, TransferError, TransferReason, TransferRecord, + TransferStatus, }, }; use dpp_types::{audit::AuditEntry, auth::AuthContext}; @@ -181,4 +182,153 @@ impl PassportService { Ok(record) } + + /// Reject a pending transfer: the incoming operator refuses the handover. + /// + /// Terminal — the record can never complete afterwards, and the chain is + /// free to carry a new transfer. + pub async fn reject_transfer( + &self, + id: PassportId, + auth: &AuthContext, + ) -> Result { + self.terminate_pending_transfer(id, auth, Termination::Rejected) + .await + } + + /// Cancel a pending transfer: the outgoing operator withdraws the handover + /// before it completes. + /// + /// Terminal, like [`Self::reject_transfer`], and valid from one state more: + /// core allows a cancel after the acceptance step has run but before the + /// record is completed. + pub async fn cancel_transfer( + &self, + id: PassportId, + auth: &AuthContext, + ) -> Result { + self.terminate_pending_transfer(id, auth, Termination::Cancelled) + .await + } + + /// The shared body of [`Self::reject_transfer`] and + /// [`Self::cancel_transfer`] — the two differ only in which core method + /// they call and what they are called in the audit trail. + /// + /// # Why this exists at all + /// + /// `TransferChain::initiate_transfer` refuses a new handover while any + /// record is `Initiated` or `Accepted`. Before these two paths were wired, + /// nothing could move a record out of `Initiated`: a handover the + /// counterparty never accepted blocked **every** future transfer on that + /// passport, permanently, with no route able to clear it. `TransferRecord` + /// has carried `reject`/`cancel` all along; only the way in was missing. + /// + /// # Why the selection predicate is what it is + /// + /// It matches `initiate_transfer`'s own `has_pending` check exactly — + /// whatever blocks a new transfer is precisely what these two clear. Legality + /// is not decided here: the record's own state machine refuses a `reject` + /// from anything but `Initiated`, and a `cancel` from anything terminal, so + /// this selects a candidate and lets core reject it. + /// + /// # Why no registry notification is enqueued + /// + /// A notification is queued when a handover **completes**, not when one is + /// initiated. A transfer ending here never completed, so the registry was + /// never told it was coming and is owed nothing now. A plain chain write is + /// the whole of the persistence. + async fn terminate_pending_transfer( + &self, + id: PassportId, + auth: &AuthContext, + how: Termination, + ) -> Result { + let store = self + .transfer_store + .as_ref() + .ok_or_else(|| DppError::Internal("transfer store not configured".into()))?; + let mut chain = store + .get_chain(id) + .await? + .ok_or_else(|| DppError::NotFound(format!("no transfer chain for {id}")))?; + + let idx = chain + .transfers + .iter() + .position(|t| { + matches!( + t.status(), + TransferStatus::Initiated | TransferStatus::Accepted + ) + }) + .ok_or_else(|| { + DppError::Validation(format!("no pending transfer to {}", how.verb()).into()) + })?; + + how.apply(&mut chain.transfers[idx]) + .map_err(|e| DppError::Validation(e.to_string().into()))?; + let record = chain.transfers[idx].clone(); + store.save_chain(&chain).await?; + + let entry = AuditEntry::new(&id.to_string(), "transferred", &auth.user_id, None, None) + .with_metadata(serde_json::json!({ + "event": format!("transfer.{}", how.phase()), + "transferId": record.transfer_id, + "toOperator": record.to_operator.did, + })); + self.audit.append(entry).await?; + + self.emit( + event::subjects::PASSPORT_TRANSFERRED, + serde_json::json!({ + "passportId": id.to_string(), + "phase": how.phase(), + "transferId": record.transfer_id.to_string(), + "toOperator": record.to_operator.did, + }), + ) + .await; + + Ok(record) + } +} + +/// How a pending handover was ended. +/// +/// Both outcomes are terminal and both free the chain for a new transfer; they +/// differ in who ended it and from which states core permits it. +#[derive(Clone, Copy)] +enum Termination { + /// The incoming operator refused. Core permits this only from `Initiated`. + Rejected, + /// The outgoing operator withdrew. Core permits this from `Initiated` or + /// `Accepted`. + Cancelled, +} + +impl Termination { + /// The past-tense form, used in the audit event name and the emitted phase. + fn phase(self) -> &'static str { + match self { + Self::Rejected => "rejected", + Self::Cancelled => "cancelled", + } + } + + /// The imperative form, used in the "no pending transfer to …" message. + fn verb(self) -> &'static str { + match self { + Self::Rejected => "reject", + Self::Cancelled => "cancel", + } + } + + /// Apply the outcome, letting the record's own state machine refuse it. + fn apply(self, record: &mut TransferRecord) -> Result<(), TransferError> { + match self { + Self::Rejected => record.reject(), + Self::Cancelled => record.cancel(), + } + } } diff --git a/crates/dpp-vault/src/handlers/transfer.rs b/crates/dpp-vault/src/handlers/transfer.rs index e7fa670..e3d1b34 100644 --- a/crates/dpp-vault/src/handlers/transfer.rs +++ b/crates/dpp-vault/src/handlers/transfer.rs @@ -1,5 +1,6 @@ -//! Transfer of responsibility: -//! `POST /api/v1/dpp/{dppId}/transfer/initiate` and `.../transfer/accept`. +//! Transfer of responsibility — the four lifecycle routes under +//! `POST /api/v1/dpp/{dppId}/transfer/`: `initiate`, `accept`, `reject` and +//! `cancel`. use axum::{ Json, @@ -109,6 +110,61 @@ pub async fn transfer_accept_handler( } } +/// `POST /api/v1/dpp/{dppId}/transfer/reject` — the incoming operator refuses +/// the pending handover. +/// +/// Terminal: the record can never complete afterwards, and the chain is free to +/// carry a new transfer. Paired with `transfer_cancel_handler`, this is the only +/// way out of a handover the counterparty never acted on — without it a pending +/// record blocks every later transfer on the passport for good. +pub async fn transfer_reject_handler( + State(state): State, + Extension(auth): Extension, + Path(dpp_id): Path, +) -> impl IntoResponse { + if let Some(resp) = require_write(&auth, "Rejecting a transfer") { + return resp; + } + let id = match parse_passport_id(&dpp_id) { + Ok(i) => i, + Err(e) => return e, + }; + match state.service.reject_transfer(id, &auth).await { + Ok(r) => (StatusCode::OK, Json(r)).into_response(), + Err(dpp_domain::DppError::NotFound(_)) => { + not_found_error("No transfer to reject for this DPP.") + } + Err(e @ dpp_domain::DppError::Validation(_)) => validation_error(&e.to_string()), + Err(e) => internal_error(e), + } +} + +/// `POST /api/v1/dpp/{dppId}/transfer/cancel` — the outgoing operator withdraws +/// the pending handover before it completes. +/// +/// Valid from one state more than reject: core permits a cancel after the +/// acceptance step has run but before the record is completed. +pub async fn transfer_cancel_handler( + State(state): State, + Extension(auth): Extension, + Path(dpp_id): Path, +) -> impl IntoResponse { + if let Some(resp) = require_write(&auth, "Cancelling a transfer") { + return resp; + } + let id = match parse_passport_id(&dpp_id) { + Ok(i) => i, + Err(e) => return e, + }; + match state.service.cancel_transfer(id, &auth).await { + Ok(r) => (StatusCode::OK, Json(r)).into_response(), + Err(dpp_domain::DppError::NotFound(_)) => { + not_found_error("No transfer to cancel for this DPP.") + } + Err(e @ dpp_domain::DppError::Validation(_)) => validation_error(&e.to_string()), + Err(e) => internal_error(e), + } +} /// A transfer counterparty's DID must be a `did:web` this node could actually /// resolve to a public document. /// diff --git a/crates/dpp-vault/src/router.rs b/crates/dpp-vault/src/router.rs index 6c8936b..2953cfa 100644 --- a/crates/dpp-vault/src/router.rs +++ b/crates/dpp-vault/src/router.rs @@ -51,7 +51,10 @@ use crate::{ seal::{seal_handler, seal_summary_handler}, stats::{operator_stats_handler, passport_stats_handler}, suspend::suspend_handler, - transfer::{transfer_accept_handler, transfer_initiate_handler}, + transfer::{ + transfer_accept_handler, transfer_cancel_handler, transfer_initiate_handler, + transfer_reject_handler, + }, update::update_handler, validate::validate_handler, verify_tree::verify_tree_handler, @@ -95,6 +98,19 @@ pub fn build(state: AppState) -> Router { "/dpp/{dppId}/transfer/accept", post(transfer_accept_handler), ) + // The two ways out of a pending handover. Without them a transfer the + // counterparty never acted on blocks every later transfer on the + // passport permanently — the chain refuses a new one while any record + // is still pending, and nothing else can move a record out of that + // state. + .route( + "/dpp/{dppId}/transfer/reject", + post(transfer_reject_handler), + ) + .route( + "/dpp/{dppId}/transfer/cancel", + post(transfer_cancel_handler), + ) .route("/dpp/{dppId}/history", get(history_handler)) .route("/dpp/{dppId}/verify-tree", get(verify_tree_handler)) // The qualified seal has its own route because it is stripped from