Skip to content

Commit 935390d

Browse files
committed
✨ Add NFT minting for asset objects
1 parent 8acc02e commit 935390d

4 files changed

Lines changed: 276 additions & 1 deletion

File tree

moderate_api/entities/asset/trust_routes.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
fetch_nft_metadata,
2121
fetch_verify_proof,
2222
get_verification_count_for_key,
23+
mint_nft_task,
2324
)
2425

2526
_logger = logging.getLogger(__name__)
@@ -38,6 +39,11 @@ class AssetObjectProofCreationResponse(BaseModel):
3839
obj: UploadedS3Object | None
3940

4041

42+
class MintNftRequest(BaseModel):
43+
object_key_or_id: str | int
44+
license: str
45+
46+
4147
async def _find_enforce_s3obj(
4248
object_key_or_id: str | int,
4349
session: AsyncSessionDep,
@@ -175,6 +181,54 @@ async def get_asset_nft_metadata(
175181
return None
176182

177183

184+
@router.post("/nft/mint", response_model=dict, tags=[_TAG])
185+
async def mint_asset_nft(
186+
*,
187+
user: UserDep,
188+
session: AsyncSessionDep,
189+
settings: SettingsDep,
190+
background_tasks: BackgroundTasks,
191+
body: MintNftRequest,
192+
):
193+
"""Initiate NFT minting for an asset object.
194+
195+
The object must already have a proof (proof_id set). Minting is restricted
196+
to the asset owner or admin. The NFT is minted asynchronously; poll
197+
GET /asset/proof/task/{task_id} for completion status.
198+
199+
Returns:
200+
dict with task_id to poll.
201+
"""
202+
if not settings.trust_service or not settings.trust_service.endpoint_url:
203+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
204+
205+
s3object = await _find_enforce_s3obj(
206+
object_key_or_id=body.object_key_or_id,
207+
session=session,
208+
user=user,
209+
public_assets_allowed=False,
210+
)
211+
212+
if not s3object.proof_id:
213+
raise HTTPException(
214+
status_code=status.HTTP_409_CONFLICT,
215+
detail="A proof must exist before minting an NFT for this object.",
216+
)
217+
218+
task_id = await init_task(session=session, username_owner=user.username)
219+
220+
background_tasks.add_task(
221+
mint_nft_task,
222+
task_id=str(task_id),
223+
s3object_key_or_id=body.object_key_or_id,
224+
requester_username=user.username,
225+
license=body.license,
226+
mint_nft_url=settings.trust_service.url_get_nfts(),
227+
)
228+
229+
return {"task_id": task_id}
230+
231+
178232
@router.get(
179233
"/verification-count",
180234
response_model=VerificationCountItem | None,

moderate_api/trust.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io
44
import logging
55
import pprint
6+
import re
67
import time
78
from collections import defaultdict
89
from functools import wraps
@@ -165,6 +166,98 @@ async def create_proof_task(
165166
await set_task_result(session=session, task_id=task_id, result=result)
166167

167168

169+
@_handle_task_error
170+
async def mint_nft_task(
171+
task_id: str,
172+
s3object_key_or_id: str | int,
173+
requester_username: str,
174+
license: str,
175+
mint_nft_url: str,
176+
timeout_seconds: int = _TIMEOUT_SECS_HIGH,
177+
) -> None:
178+
"""Background task to mint an NFT for an asset object via the trust service.
179+
180+
Args:
181+
task_id: Long-running task identifier for status tracking.
182+
s3object_key_or_id: The S3 object key or database ID of the object to mint.
183+
requester_username: The username of the user requesting the mint.
184+
license: The license string to embed in the NFT (e.g. "CC-BY-4.0").
185+
mint_nft_url: The trust service endpoint URL for NFT minting (POST /api/nfts).
186+
timeout_seconds: HTTP request timeout in seconds.
187+
188+
Raises:
189+
ValueError: If the object is not found, has no proof, or the owner has no DID.
190+
"""
191+
async with with_session() as session:
192+
s3obj = await find_s3object_by_key_or_id(
193+
val=s3object_key_or_id, session=session
194+
)
195+
196+
if not s3obj:
197+
raise ValueError(f"Asset object '{s3object_key_or_id}' not found")
198+
199+
if not s3obj.proof_id:
200+
raise ValueError(
201+
f"Asset object '{s3object_key_or_id}' has no proof — "
202+
"a proof must exist before minting an NFT"
203+
)
204+
205+
# Resolve the DID from the asset owner (platform compensates for the Trust
206+
# Service's unimplemented ownership check — the DID is never caller-supplied)
207+
owner_username = (
208+
s3obj.asset.username
209+
if s3obj.asset and s3obj.asset.username
210+
else requester_username
211+
)
212+
213+
user_did = await get_did_for_username(username=owner_username, session=session)
214+
215+
if not user_did:
216+
raise ValueError(
217+
f"User '{owner_username}' does not have a DID and cannot mint an NFT"
218+
)
219+
220+
# Auto-derive nftAlias: prefer explicit object name, then parent asset name,
221+
# then fall back to the raw key. Sanitise to safe characters and truncate.
222+
raw_alias = (
223+
s3obj.name or (s3obj.asset.name if s3obj.asset else None) or s3obj.key
224+
)
225+
nft_alias = re.sub(r"[^a-zA-Z0-9 _\-]", "", raw_alias).strip()[:64] or "Asset"
226+
227+
# Auto-derive nftSymbol: first 6 uppercase alphanumeric chars of asset name.
228+
# nftSymbol has no uniqueness or length constraint on the Trust Service side.
229+
raw_symbol = re.sub(
230+
r"[^a-zA-Z0-9]", "", s3obj.asset.name if s3obj.asset else ""
231+
).upper()[:6]
232+
nft_symbol = raw_symbol or "ASSET"
233+
234+
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
235+
json_payload = {
236+
"assetId": s3obj.key,
237+
"nftAlias": nft_alias,
238+
"nftSymbol": nft_symbol,
239+
"license": license,
240+
"did": user_did,
241+
}
242+
243+
_logger.info(
244+
"Calling %s with payload:\n%s",
245+
mint_nft_url,
246+
pprint.pformat(json_payload),
247+
)
248+
249+
resp = await client.post(mint_nft_url, json=json_payload)
250+
resp.raise_for_status()
251+
252+
_logger.info("NFT minted successfully for object '%s'", s3object_key_or_id)
253+
254+
await set_task_result(
255+
session=session,
256+
task_id=task_id,
257+
result={"status": "minted", "object_key": s3obj.key},
258+
)
259+
260+
168261
class ProofResponse(BaseModel):
169262
metadata_digest: str
170263

moderate_ui/src/api/assets.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,21 @@ export async function getAssetNftMetadata({
389389
return response.data ?? null;
390390
}
391391

392+
export async function mintAssetNft({
393+
objectKeyOrId,
394+
license,
395+
}: {
396+
objectKeyOrId: number | string;
397+
license: string;
398+
}): Promise<{ task_id: number }> {
399+
const url = buildApiUrl("asset", "nft", "mint");
400+
const response = await axios.post(url, {
401+
object_key_or_id: objectKeyOrId,
402+
license,
403+
});
404+
return response.data;
405+
}
406+
392407
export interface AssetObjectColumnsResponse {
393408
columns: string[];
394409
}

moderate_ui/src/pages/asset-objects/Show.tsx

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
Card,
88
Group,
99
LoadingOverlay,
10+
Modal,
1011
Stack,
1112
Tabs,
1213
Text,
14+
TextInput,
1315
ThemeIcon,
1416
Tooltip,
1517
} from "@mantine/core";
@@ -31,6 +33,7 @@ import {
3133
IconEye,
3234
IconFileCheck,
3335
IconFileText,
36+
IconHexagonPlus,
3437
IconHome,
3538
IconReportSearch,
3639
IconShieldCheck,
@@ -39,8 +42,14 @@ import {
3942
import _ from "lodash";
4043
import React, { useCallback, useMemo, useState } from "react";
4144
import { Link } from "react-router-dom";
42-
import { formatCacheTtl, updateAssetObject } from "../../api/assets";
45+
import {
46+
formatCacheTtl,
47+
updateAssetObject,
48+
mintAssetNft,
49+
} from "../../api/assets";
4350
import { Asset, AssetModel } from "../../api/types";
51+
import axios from "axios";
52+
import { buildApiUrl } from "../../api/utils";
4453
import {
4554
buildKeycloakAuthProvider,
4655
IIdentity,
@@ -127,6 +136,55 @@ export const AssetObjectShow: React.FC<IResourceComponentsProps> = () => {
127136
const { verificationCount } = useVerificationCount(assetObjectModel);
128137

129138
const [isUpdatingName, setIsUpdatingName] = useState(false);
139+
const [isMintModalOpen, setIsMintModalOpen] = useState(false);
140+
const [mintLicense, setMintLicense] = useState("");
141+
const [isMinting, setIsMinting] = useState(false);
142+
143+
const handleMintNft = useCallback(async () => {
144+
if (!assetObjectModel || !mintLicense.trim()) return;
145+
146+
setIsMinting(true);
147+
148+
try {
149+
const { task_id } = await mintAssetNft({
150+
objectKeyOrId: assetObjectModel.data.id,
151+
license: mintLicense.trim(),
152+
});
153+
154+
// Poll the existing proof task endpoint until the task finishes
155+
const poll = async (): Promise<void> => {
156+
const url = buildApiUrl("asset", "proof", "task", String(task_id));
157+
const res = await axios.get(url);
158+
if (res.data?.finished_at) {
159+
if (res.data?.error) {
160+
throw new Error(res.data.error);
161+
}
162+
return;
163+
}
164+
await new Promise((r) => setTimeout(r, 3000));
165+
return poll();
166+
};
167+
168+
await poll();
169+
170+
open?.({
171+
message: t("assetObjects.mintSuccess", "NFT minted successfully"),
172+
type: "success",
173+
});
174+
175+
setIsMintModalOpen(false);
176+
setMintLicense("");
177+
await queryResult.refetch();
178+
} catch (error) {
179+
_.partial(
180+
catchErrorAndShow,
181+
open,
182+
t("assetObjects.mintError", "NFT minting failed"),
183+
)(error);
184+
} finally {
185+
setIsMinting(false);
186+
}
187+
}, [assetObjectModel, mintLicense, open, t, queryResult]);
130188

131189
const handleNameUpdate = useCallback(
132190
async (newName: string) => {
@@ -300,6 +358,43 @@ export const AssetObjectShow: React.FC<IResourceComponentsProps> = () => {
300358
"We are checking the integrity of this dataset object. This may take a while.",
301359
)}
302360
/>
361+
<LoadingOverlayWithMessage
362+
visible={isMinting}
363+
message={t(
364+
"assetObjects.loadingMint",
365+
"Minting NFT on the blockchain. This may take a minute.",
366+
)}
367+
/>
368+
<Modal
369+
opened={isMintModalOpen}
370+
onClose={() => {
371+
if (!isMinting) {
372+
setIsMintModalOpen(false);
373+
setMintLicense("");
374+
}
375+
}}
376+
title={t("assetObjects.mintNft.title", "Mint NFT")}
377+
>
378+
<TextInput
379+
label={t("assetObjects.mintNft.license", "License")}
380+
placeholder="e.g. CC-BY-4.0"
381+
value={mintLicense}
382+
onChange={(e) => setMintLicense(e.currentTarget.value)}
383+
required
384+
mb="md"
385+
/>
386+
<Button
387+
fullWidth
388+
variant="light"
389+
color="violet"
390+
leftIcon={<IconHexagonPlus size={18} />}
391+
loading={isMinting}
392+
disabled={!mintLicense.trim()}
393+
onClick={handleMintNft}
394+
>
395+
{t("assetObjects.mintNft.submit", "Mint NFT")}
396+
</Button>
397+
</Modal>
303398
{!!result && (
304399
<IntegrityModal
305400
integrityResult={result}
@@ -464,6 +559,24 @@ export const AssetObjectShow: React.FC<IResourceComponentsProps> = () => {
464559
)}
465560
</Button>
466561
</Tooltip>
562+
563+
{assetObjectModel.data.proof_id && (
564+
<Tooltip
565+
label={t(
566+
"assetObjects.actions.mintNft",
567+
"Mint NFT for this object",
568+
)}
569+
>
570+
<Button
571+
variant="light"
572+
color="violet"
573+
leftIcon={<IconHexagonPlus size={18} />}
574+
onClick={() => setIsMintModalOpen(true)}
575+
>
576+
{t("assetObjects.actions.mintNft", "Mint NFT")}
577+
</Button>
578+
</Tooltip>
579+
)}
467580
</Group>
468581
</Group>
469582
</Stack>

0 commit comments

Comments
 (0)