use std::sync::Arc;
use affinidi_tdk::{
didcomm::Message,
messaging::{ATM, profiles::ATMProfile},
secrets_resolver::secrets::Secret,
};
use chrono::Utc;
use dtg_credentials::DTGCredential;
use serde_json::Value;
use uuid::Uuid;
use vta_sdk::protocols::members::{MEMBER_VMC_TYPE, MemberVmcBody};
use crate::errors::OpenVTCError;
pub async fn issue_and_send_member_vmc(
route: &Delivery<'_>,
signing_secret: &Secret,
document_signer: &Secret,
grant: &Value,
closes_request: Option<Uuid>,
) -> Result<(Uuid, Value), OpenVTCError> {
let vc = build_member_vmc(signing_secret, route.member_did, grant).await?;
let msg_id = submit_member_vmc(route, document_signer, vc.clone(), closes_request).await?;
Ok((msg_id, vc))
}
pub struct Delivery<'a> {
pub atm: &'a ATM,
pub profile: &'a Arc<ATMProfile>,
pub member_did: &'a str,
pub vtc_did: &'a str,
pub mediator_did: &'a str,
pub tsp_mediator_did: Option<&'a str>,
}
pub(crate) async fn send_document(
route: &Delivery<'_>,
document_id: String,
document: Value,
) -> Result<(), OpenVTCError> {
if let Some(tsp_mediator) = route.tsp_mediator_did {
return crate::tsp::send_trust_task(
route.atm,
route.profile,
&document,
route.vtc_did,
tsp_mediator,
)
.await;
}
let now = Utc::now().timestamp().max(0) as u64;
let msg = Message::build(
document_id,
crate::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(),
document,
)
.from(route.member_did.to_string())
.to(route.vtc_did.to_string())
.created_time(now)
.finalize();
crate::pack_and_send(
route.atm,
route.profile,
&msg,
route.member_did,
route.vtc_did,
route.mediator_did,
)
.await
}
pub async fn build_member_vmc(
signing_secret: &Secret,
member_did: &str,
grant: &Value,
) -> Result<Value, OpenVTCError> {
let valid_until = grant
.get("validUntil")
.and_then(Value::as_str)
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| t.with_timezone(&Utc));
let mut vmc = DTGCredential::new_member_vmc_for(
grant,
member_did,
crate::dtg::MEMBER_IDENTIFIER_SCOPE,
Utc::now(),
valid_until,
)
.map_err(|e| {
OpenVTCError::Config(format!(
"cannot acknowledge this community's membership credential: {e}"
))
})?
.with_id(format!("urn:uuid:{}", Uuid::new_v4()));
vmc.sign(signing_secret, None)
.await
.map_err(|e| OpenVTCError::Config(format!("sign member VMC: {e}")))?;
serde_json::to_value(&vmc)
.map_err(|e| OpenVTCError::Config(format!("serialize member VMC: {e}")))
}
pub async fn submit_member_vmc(
route: &Delivery<'_>,
signer: &Secret,
vc: Value,
closes_request: Option<Uuid>,
) -> Result<Uuid, OpenVTCError> {
let Delivery {
member_did,
vtc_did,
..
} = *route;
let body = serde_json::to_value(MemberVmcBody {
vc,
request_id: closes_request.map(|id| id.to_string()),
})
.map_err(|e| OpenVTCError::Config(format!("member vmc body serialize: {e}")))?;
let msg_id = Uuid::new_v4();
let document_id = format!("urn:uuid:{msg_id}");
let body = crate::trust_task_doc::build_signed_value(
MEMBER_VMC_TYPE,
member_did,
vtc_did,
&document_id,
body,
signer,
)
.await?;
send_document(route, document_id, body).await?;
Ok(msg_id)
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_tdk::secrets_resolver::secrets::Secret;
const MEMBER: &str = "did:example:member";
const COMMUNITY: &str = "did:example:community";
fn grant() -> Value {
serde_json::json!({
"@context": [
dtg_credentials::W3C_VC_V2_CONTEXT,
dtg_credentials::DTG_CONTEXT_V1
],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"id": "urn:uuid:0d7f4d2c-1b8e-4a55-9e1f-7c4a2b9d3e60",
"issuer": COMMUNITY,
"issuerScope": "public",
"validFrom": "2026-01-01T00:00:00Z",
"credentialStatus": {
"id": "https://community.example/status#7",
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "7"
},
"credentialSubject": { "id": MEMBER },
"proof": { "type": "DataIntegrityProof", "proofValue": "zCommunitySignature" }
})
}
async fn signed_vmc() -> (Secret, Value) {
let secret = Secret::generate_ed25519(None, None);
let vc = build_member_vmc(&secret, MEMBER, &grant())
.await
.expect("build the member VMC");
(secret, vc)
}
#[tokio::test]
async fn the_acknowledgement_digests_the_grant_as_received() {
let (_secret, vc) = signed_vmc().await;
assert_eq!(
vc["credentialSubject"]["digestMultibase"],
Value::String(dtg_credentials::digest_multibase_json(&grant()).expect("digest")),
"the digest must cover the grant the community sent"
);
let mut proofless = grant();
proofless.as_object_mut().expect("object").remove("proof");
let parsed: DTGCredential = serde_json::from_value(proofless).expect("parses");
assert_eq!(
vc["credentialSubject"]["digestMultibase"],
Value::String(parsed.digest_multibase().expect("digest")),
"the model is lossless, so digesting the parsed grant matches the wire"
);
}
#[tokio::test]
async fn a_reissued_grant_needs_a_fresh_acknowledgement() {
let (_secret, vc) = signed_vmc().await;
let mut renewed = grant();
renewed["id"] = Value::String("urn:uuid:renewed".into());
renewed["validFrom"] = Value::String("2027-01-01T00:00:00Z".into());
assert_ne!(
vc["credentialSubject"]["digestMultibase"],
Value::String(dtg_credentials::digest_multibase_json(&renewed).expect("digest"))
);
}
#[tokio::test]
async fn a_non_grant_is_refused_before_it_is_sent() {
let secret = Secret::generate_ed25519(None, None);
let not_a_grant = serde_json::json!({
"type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
"issuer": COMMUNITY,
"credentialSubject": { "id": MEMBER }
});
assert!(
build_member_vmc(&secret, MEMBER, ¬_a_grant)
.await
.is_err()
);
}
#[tokio::test]
async fn a_grant_naming_someone_else_is_refused() {
let secret = Secret::generate_ed25519(None, None);
assert!(
build_member_vmc(&secret, "did:example:someone-else", &grant())
.await
.is_err()
);
}
#[tokio::test]
async fn a_pre_v1_grant_is_refused() {
let secret = Secret::generate_ed25519(None, None);
let mut old = grant();
old["@context"][1] = Value::String(crate::dtg::fixtures::RETIRED_CONTEXT.into());
old.as_object_mut().unwrap().remove("issuerScope");
assert!(build_member_vmc(&secret, MEMBER, &old).await.is_err());
}
#[tokio::test]
async fn the_acknowledgement_declares_the_member_scope() {
let (_secret, vc) = signed_vmc().await;
assert_eq!(vc["issuerScope"], "directed");
assert_eq!(vc["@context"][1], dtg_credentials::DTG_CONTEXT_V1);
}
#[tokio::test]
async fn a_member_vmc_carries_a_top_level_id() {
let (_secret, vc) = signed_vmc().await;
let id = vc
.get("id")
.and_then(Value::as_str)
.expect("the VMC carries a top-level `id`");
assert!(
id.starts_with("urn:uuid:"),
"the id should be a urn:uuid: URN, got {id}"
);
assert_eq!(vc["credentialSubject"]["id"], COMMUNITY);
}
#[tokio::test]
async fn each_member_vmc_gets_a_fresh_id() {
let (_, first) = signed_vmc().await;
let (_, second) = signed_vmc().await;
assert_ne!(first["id"], second["id"]);
}
#[tokio::test]
async fn the_signed_vmc_verifies_with_its_id_in_place() {
let (secret, vc) = signed_vmc().await;
let parsed: DTGCredential = serde_json::from_value(vc.clone()).expect("parse back");
parsed
.verify_proof_with_public_key(secret.get_public_bytes())
.expect("the delivered credential verifies as sent");
let mut tampered = vc;
tampered["id"] = Value::String("urn:uuid:00000000-0000-0000-0000-000000000000".into());
let parsed: DTGCredential = serde_json::from_value(tampered).expect("parse back");
assert!(
parsed
.verify_proof_with_public_key(secret.get_public_bytes())
.is_err(),
"a changed id must invalidate the proof"
);
}
#[tokio::test]
async fn the_member_issues_and_the_community_is_the_subject() {
let (_secret, vc) = signed_vmc().await;
assert_eq!(vc["issuer"], MEMBER);
assert_eq!(vc["credentialSubject"]["id"], COMMUNITY);
assert!(
vc["type"]
.as_array()
.unwrap()
.iter()
.any(|t| t == "MembershipCredential")
);
}
}