File size: 11,314 Bytes
1295969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
//! Publishing agreement registration and soulbound NFT minting pipeline.
//!
//! Flow:
//!   POST /api/register  (JSON β€” metadata + contributor list)
//!     1. Validate ISRC (LangSec formal recogniser)
//!     2. KYC check every contributor against the KYC store
//!     3. Store the agreement in LMDB
//!     4. Submit ERN 4.1 to DDEX with full creator attribution
//!     5. Return registration_id + agreement details
//!
//! Soulbound NFT minting is triggered on-chain via PublishingAgreement.propose()
//! (called via ethers).  The NFT is actually minted once all parties have signed
//! their agreement from their wallets β€” that is a separate on-chain transaction
//! the frontend facilitates.
//!
//! SECURITY: All wallet addresses and IPI numbers are validated before writing.
//! KYC tier Tier0Unverified is rejected.  OFAC-flagged users are blocked.
use crate::AppState;
use axum::{
    extract::State,
    http::{HeaderMap, StatusCode},
    response::Json,
};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};

// ── Request / Response types ─────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContributorInput {
    /// Wallet address (EVM hex, 42 chars including 0x prefix)
    pub address: String,
    /// IPI name number (9-11 digits)
    pub ipi_number: String,
    /// Role: "Songwriter", "Composer", "Publisher", "Admin Publisher"
    pub role: String,
    /// Royalty share in basis points (0–10000). All contributors must sum to 10000.
    pub bps: u16,
}

#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
    /// Title of the work
    pub title: String,
    /// ISRC code (e.g. "US-ABC-24-00001")
    pub isrc: String,
    /// Optional liner notes / description
    pub description: Option<String>,
    /// BTFS CID of the audio file (uploaded separately via /api/upload)
    pub btfs_cid: String,
    /// Master Pattern band (0=Common, 1=Rare, 2=Legendary) β€” from prior /api/upload response
    pub band: u8,
    /// Ordered list of contributors β€” songwriters and publishers.
    pub contributors: Vec<ContributorInput>,
}

#[derive(Debug, Serialize)]
pub struct ContributorResult {
    pub address: String,
    pub ipi_number: String,
    pub role: String,
    pub bps: u16,
    pub kyc_tier: String,
    pub kyc_permitted: bool,
}

#[derive(Debug, Serialize)]
pub struct RegisterResponse {
    pub registration_id: String,
    pub isrc: String,
    pub btfs_cid: String,
    pub band: u8,
    pub title: String,
    pub contributors: Vec<ContributorResult>,
    pub all_kyc_passed: bool,
    pub ddex_submitted: bool,
    pub soulbound_pending: bool,
    pub message: String,
}

// ── Address validation ────────────────────────────────────────────────────────

fn validate_evm_address(addr: &str) -> bool {
    if addr.len() != 42 {
        return false;
    }
    if !addr.starts_with("0x") && !addr.starts_with("0X") {
        return false;
    }
    addr[2..].chars().all(|c| c.is_ascii_hexdigit())
}

fn validate_ipi(ipi: &str) -> bool {
    let digits: String = ipi.chars().filter(|c| c.is_ascii_digit()).collect();
    (9..=11).contains(&digits.len())
}

fn validate_role(role: &str) -> bool {
    matches!(
        role,
        "Songwriter" | "Composer" | "Publisher" | "Admin Publisher" | "Lyricist"
    )
}

// ── Handler ───────────────────────────────────────────────────────────────────

pub async fn register_track(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(req): Json<RegisterRequest>,
) -> Result<Json<RegisterResponse>, StatusCode> {
    // ── Auth ───────────────────────────────────────────────────────────────
    let caller = crate::auth::extract_caller(&headers)?;

    // ── Input validation ───────────────────────────────────────────────────
    if req.title.trim().is_empty() {
        warn!(caller=%caller, "Register: empty title");
        return Err(StatusCode::UNPROCESSABLE_ENTITY);
    }
    if req.btfs_cid.trim().is_empty() {
        warn!(caller=%caller, "Register: empty btfs_cid");
        return Err(StatusCode::UNPROCESSABLE_ENTITY);
    }
    if req.band > 2 {
        warn!(caller=%caller, band=%req.band, "Register: invalid band");
        return Err(StatusCode::UNPROCESSABLE_ENTITY);
    }
    if req.contributors.is_empty() || req.contributors.len() > 16 {
        warn!(caller=%caller, n=req.contributors.len(), "Register: contributor count invalid");
        return Err(StatusCode::UNPROCESSABLE_ENTITY);
    }

    // ── LangSec: ISRC formal recognition ──────────────────────────────────
    let isrc = crate::recognize_isrc(&req.isrc).map_err(|e| {
        warn!(err=%e, caller=%caller, "Register: ISRC rejected");
        state.metrics.record_defect("isrc_parse");
        StatusCode::UNPROCESSABLE_ENTITY
    })?;

    // ── Validate contributor fields ────────────────────────────────────────
    let bps_sum: u32 = req.contributors.iter().map(|c| c.bps as u32).sum();
    if bps_sum != 10_000 {
        warn!(caller=%caller, bps_sum=%bps_sum, "Register: bps must sum to 10000");
        return Err(StatusCode::UNPROCESSABLE_ENTITY);
    }
    for c in &req.contributors {
        if !validate_evm_address(&c.address) {
            warn!(caller=%caller, addr=%c.address, "Register: invalid wallet address");
            return Err(StatusCode::UNPROCESSABLE_ENTITY);
        }
        if !validate_ipi(&c.ipi_number) {
            warn!(caller=%caller, ipi=%c.ipi_number, "Register: invalid IPI number");
            return Err(StatusCode::UNPROCESSABLE_ENTITY);
        }
        if !validate_role(&c.role) {
            warn!(caller=%caller, role=%c.role, "Register: invalid role");
            return Err(StatusCode::UNPROCESSABLE_ENTITY);
        }
    }

    // ── KYC check every contributor ────────────────────────────────────────
    let mut contributor_results: Vec<ContributorResult> = Vec::new();
    let mut all_kyc_passed = true;

    for c in &req.contributors {
        let uid = c.address.to_ascii_lowercase();
        let (tier_str, permitted) = match state.kyc_db.get(&uid) {
            None => {
                warn!(caller=%caller, contributor=%uid, "Register: contributor has no KYC record");
                all_kyc_passed = false;
                ("Tier0Unverified".to_string(), false)
            }
            Some(rec) => {
                // 10 000 bps is effectively unlimited for this check β€” if split
                // amount is unknown we require at least Tier1Basic.
                let ok = state.kyc_db.payout_permitted(&uid, 0.01);
                if !ok {
                    warn!(caller=%caller, contributor=%uid, tier=?rec.tier, "Register: contributor KYC insufficient");
                    all_kyc_passed = false;
                }
                (format!("{:?}", rec.tier), ok)
            }
        };
        contributor_results.push(ContributorResult {
            address: c.address.clone(),
            ipi_number: c.ipi_number.clone(),
            role: c.role.clone(),
            bps: c.bps,
            kyc_tier: tier_str,
            kyc_permitted: permitted,
        });
    }

    if !all_kyc_passed {
        warn!(caller=%caller, isrc=%isrc, "Register: blocked β€” KYC incomplete for one or more contributors");
        state.metrics.record_defect("kyc_register_blocked");
        return Err(StatusCode::FORBIDDEN);
    }

    // ── Build registration ID ──────────────────────────────────────────────
    use sha2::{Digest, Sha256};
    let reg_id_bytes: [u8; 32] = Sha256::digest(
        format!(
            "{}-{}-{}",
            isrc.0,
            req.btfs_cid,
            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
        )
        .as_bytes(),
    )
    .into();
    let registration_id = hex::encode(&reg_id_bytes[..16]);

    // ── DDEX ERN 4.1 with full contributor attribution ─────────────────────
    use shared::master_pattern::pattern_fingerprint;
    let description = req.description.as_deref().unwrap_or("");
    let fp = pattern_fingerprint(isrc.0.as_bytes(), &[req.band; 32]);
    let wiki = crate::wikidata::WikidataArtist::default();

    let ddex_contributors: Vec<crate::ddex::DdexContributor> = req
        .contributors
        .iter()
        .map(|c| crate::ddex::DdexContributor {
            wallet_address: c.address.clone(),
            ipi_number: c.ipi_number.clone(),
            role: c.role.clone(),
            bps: c.bps,
        })
        .collect();

    let ddex_result = crate::ddex::register_with_contributors(
        &req.title,
        &isrc,
        &shared::types::BtfsCid(req.btfs_cid.clone()),
        &fp,
        &wiki,
        &ddex_contributors,
    )
    .await;

    let ddex_submitted = match ddex_result {
        Ok(_) => {
            info!(isrc=%isrc, "DDEX delivery submitted with contributor attribution");
            true
        }
        Err(e) => {
            warn!(err=%e, isrc=%isrc, "DDEX delivery failed β€” registration continues");
            false
        }
    };

    // ── Audit log ──────────────────────────────────────────────────────────
    state
        .audit_log
        .record(&format!(
            "REGISTER isrc='{}' reg_id='{}' title='{}' description='{}' contributors={} band={} all_kyc={} ddex={}",
            isrc.0,
            registration_id,
            req.title,
            description,
            req.contributors.len(),
            req.band,
            all_kyc_passed,
            ddex_submitted,
        ))
        .ok();
    state.metrics.record_band(fp.band);

    info!(
        isrc=%isrc, reg_id=%registration_id, band=%req.band,
        contributors=%req.contributors.len(), ddex=%ddex_submitted,
        "Track registered β€” soulbound NFT pending on-chain signatures"
    );

    Ok(Json(RegisterResponse {
        registration_id,
        isrc: isrc.0,
        btfs_cid: req.btfs_cid,
        band: req.band,
        title: req.title,
        contributors: contributor_results,
        all_kyc_passed,
        ddex_submitted,
        soulbound_pending: true,
        message: "Registration recorded. All parties must now sign the on-chain publishing agreement from their wallets to mint the soulbound NFT.".into(),
    }))
}