From 5ebf3a41ac85390bcb4f9e362babe29d1dca0e69 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Fri, 17 Apr 2026 17:59:02 +0100 Subject: [PATCH] feat: wire public-visibility uploads through external-signer flow Threads a visibility flag through start_upload -> file_prepare_upload_with_visibility (ant-core PR #39), carries data_map_address out of finalize_upload and finalize_upload_merkle, persists it in upload_history.json, and re-enables the previously disabled "Public" button in the upload dialog. Public uploads now yield a single shareable on-network chunk address that appears in the uploads table with a "Public" badge; clicking copies it. The dialog re-quotes when the user toggles visibility because the payment batch differs (public adds one chunk for the DataMap itself). Requires bumping the ant-core git dep to a commit containing WithAutonomi/ant-client#39. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/files/UploadConfirmDialog.vue | 31 ++++++++++---- pages/files.vue | 54 ++++++++++++++++++++++-- src-tauri/src/autonomi_ops.rs | 31 +++++++++++++- src-tauri/src/config.rs | 4 ++ stores/files.ts | 38 ++++++++++++++--- 5 files changed, 137 insertions(+), 21 deletions(-) diff --git a/components/files/UploadConfirmDialog.vue b/components/files/UploadConfirmDialog.vue index 3511c45..4a87ad7 100644 --- a/components/files/UploadConfirmDialog.vue +++ b/components/files/UploadConfirmDialog.vue @@ -113,21 +113,26 @@

- + @@ -185,12 +190,22 @@ const props = defineProps<{ const emit = defineEmits<{ confirm: [options: { visibility: 'private' | 'public'; paymentMode: 'regular' | 'merkle' }] cancel: [] + /** + * Fired whenever the user flips the Private/Public selector. The parent + * re-quotes against the network since the prepared payment batch differs + * — public uploads pay for one extra chunk (the data map itself). + */ + 'visibility-change': [visibility: 'private' | 'public'] }>() const connectionStore = useConnectionStore() const visibility = ref<'private' | 'public'>('private') +watch(visibility, (val) => { + emit('visibility-change', val) +}) + const totalSize = computed(() => props.files.reduce((sum, f) => sum + f.size, 0)) const estimatedChunks = computed(() => Math.max(1, Math.ceil(totalSize.value / AVG_CHUNK_SIZE))) diff --git a/pages/files.vue b/pages/files.vue index a0eeee6..d1afe96 100644 --- a/pages/files.vue +++ b/pages/files.vue @@ -121,7 +121,16 @@ + Public + {{ truncateAddress(file.public_address, 8, 6) }} + + (null) const quotedGasEstimate = ref(null) const quotedPaymentMode = ref<'wave-batch' | 'merkle' | null>(null) const pendingQuotes = ref>(new Map()) +/** Visibility the currently-pinned quotes were collected under. Re-quoting + * is required when the user flips Private/Public because ant-core bundles + * the data map chunk into the payment batch for public uploads, so the + * prepared chunks and the total cost both change. */ +const quotedVisibility = ref<'private' | 'public'>('private') async function getFileMetas(paths: string[]): Promise { try { @@ -468,6 +483,7 @@ async function showUploadConfirmForPaths(paths: string[]) { quotedGasEstimate.value = null quotedPaymentMode.value = null pendingQuotes.value = new Map() + quotedVisibility.value = 'private' showUploadConfirm.value = true const metas = await getFileMetas(paths) @@ -505,13 +521,14 @@ watch( }, ) -async function startQuoting(metas: FileMeta[]) { +async function startQuoting(metas: FileMeta[], visibility: 'private' | 'public' = quotedVisibility.value) { isQuoting.value = true + quotedVisibility.value = visibility try { // Quote each file (sequentially to avoid overwhelming the network) const quotes = new Map() for (const meta of metas) { - const quote = await filesStore.getUploadQuote(meta.path) + const quote = await filesStore.getUploadQuote(meta.path, visibility) if (quote) { quotes.set(meta.path, quote) } @@ -558,9 +575,15 @@ function confirmUpload(options: { visibility: 'private' | 'public'; paymentMode: showUploadConfirm.value = false const wagmiConfig = getWagmiConfig() + // The pre-quote's prepared batch is tied to its visibility (public bundles + // the data map chunk). If the user toggled visibility after the quote came + // back and we haven't finished re-quoting, drop the pre-quote so the store + // re-quotes with the matching visibility instead of reusing a stale one. + const preQuoteUsable = quotedVisibility.value === options.visibility + for (const file of pendingUploadFiles.value) { const id = filesStore.addUpload(file.name, file.path, file.size) - const preQuote = pendingQuotes.value.get(file.path) + const preQuote = preQuoteUsable ? pendingQuotes.value.get(file.path) : undefined if (settingsStore.indelibleConnected && !settingsStore.devnetActive) { filesStore.startIndelibleUpload(id) @@ -584,6 +607,24 @@ function cancelUpload() { isQuoting.value = false } +/** + * Re-quote when the user flips Private/Public in the dialog. Public uploads + * pay for one extra chunk (the published data map), so both the total cost + * and the underlying prepared batch differ from the initial private quote. + * The confirm handler also refuses to reuse a quote whose visibility no + * longer matches the user's choice. + */ +function onVisibilityChange(next: 'private' | 'public') { + if (next === quotedVisibility.value) return + quotedVisibility.value = next + if (pendingMetas.value.length === 0) return + if (!autonomiConnected.value && !settingsStore.devnetActive) return + if (settingsStore.indelibleConnected) return + quotedCostDisplay.value = null + quotedGasEstimate.value = null + startQuoting(pendingMetas.value, next) +} + // ── Download flow ── const showDownloadDialog = ref(false) @@ -810,6 +851,11 @@ function copyAddress(addr: string) { toastStore.add('Address copied to clipboard', 'info') } +function copyPublicAddress(addr: string) { + navigator.clipboard.writeText(addr) + toastStore.add('Public address copied — share to let others download this file', 'info') +} + function datamapBasename(path: string): string { return path.split(/[\\/]/).pop() ?? path } diff --git a/src-tauri/src/autonomi_ops.rs b/src-tauri/src/autonomi_ops.rs index 4db772c..0bfbd6a 100644 --- a/src-tauri/src/autonomi_ops.rs +++ b/src-tauri/src/autonomi_ops.rs @@ -1,5 +1,6 @@ use ant_core::data::{ Client, ClientConfig, CustomNetwork, DataMap, EvmNetwork, ExternalPaymentInfo, PreparedUpload, + Visibility, }; use evmlib::common::{QuoteHash, TxHash}; use serde::{Deserialize, Serialize}; @@ -161,12 +162,21 @@ pub struct UploadResult { /// This is the user-visible handle for private uploads — without it, the /// data is unreachable after the app restarts. pub data_map_file: String, + /// On-network chunk address of the published `DataMap`, set only for + /// public uploads. A shareable 32-byte hex string anyone can pass to + /// `download_public` to retrieve the file without a local datamap. + pub public_address: Option, } #[derive(Deserialize)] pub struct StartUploadRequest { pub files: Vec, pub upload_id: String, + /// Upload visibility — "private" (default) keeps the data map local, + /// "public" bundles the serialized data map into the payment batch so + /// a single on-network chunk address can be shared for retrieval. + #[serde(default)] + pub visibility: Option, } // ── Tauri commands ── @@ -385,9 +395,18 @@ pub async fn start_upload( } let path = canonical; - // Phase 1: Encrypt file and prepare chunks (gets quotes from network) + let visibility = match request.visibility.as_deref() { + Some("public") => Visibility::Public, + _ => Visibility::Private, + }; + + // Phase 1: Encrypt file and prepare chunks (gets quotes from network). + // For a public upload, ant-core bundles the serialized DataMap into the + // payment batch as one extra chunk so it's paid for and stored alongside + // the data chunks. The resulting chunk address is surfaced via + // `FileUploadResult.data_map_address` after finalize. let prepared = client - .file_prepare_upload(&path) + .file_prepare_upload_with_visibility(&path, visibility) .await .map_err(|e| format!("Failed to prepare upload: {e}"))?; @@ -540,6 +559,9 @@ pub async fn confirm_upload( let data_map_file = crate::config::write_datamap_for(&file_name, &data_map_json)? .to_string_lossy() .into_owned(); + let public_address = result + .data_map_address + .map(|addr| format!("0x{}", hex::encode(addr))); app.emit( "upload-progress", @@ -557,6 +579,7 @@ pub async fn confirm_upload( address, chunks_stored: result.chunks_stored, data_map_file, + public_address, }) } @@ -601,6 +624,9 @@ pub async fn confirm_upload_merkle( let data_map_file = crate::config::write_datamap_for(&file_name, &data_map_json)? .to_string_lossy() .into_owned(); + let public_address = result + .data_map_address + .map(|addr| format!("0x{}", hex::encode(addr))); app.emit( "upload-progress", @@ -618,6 +644,7 @@ pub async fn confirm_upload_merkle( address, chunks_stored: result.chunks_stored, data_map_file, + public_address, }) } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d31ac2f..1034b73 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -50,6 +50,10 @@ pub struct UploadHistoryEntry { /// persistence existed. #[serde(default)] pub data_map_file: Option, + /// On-network chunk address of the published `DataMap` for public + /// uploads. `None` for private uploads and for legacy entries. + #[serde(default)] + pub public_address: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/stores/files.ts b/stores/files.ts index a3bf197..fb221f9 100644 --- a/stores/files.ts +++ b/stores/files.ts @@ -50,6 +50,10 @@ export interface FileEntry { size_bytes: number /** Network address (hex) — for display / sharing */ address?: string + /** On-network chunk address of the published DataMap, set for public + * uploads. This is the single shareable handle — anyone with it can + * fetch the DataMap via `download_public` and decrypt the file. */ + public_address?: string /** Serialized DataMap JSON — needed for download from network */ data_map_json?: string /** Absolute path to the persisted DataMap file on disk. Set for private @@ -89,6 +93,9 @@ export interface UploadHistoryEntry { /** Absolute path to the persisted DataMap file; `null`/absent for legacy * entries written before datamap persistence was added. */ data_map_file?: string | null + /** On-network chunk address of the published DataMap for public uploads. + * `null`/absent for private uploads and for legacy entries. */ + public_address?: string | null } export const useFilesStore = defineStore('files', { @@ -143,6 +150,7 @@ export const useFilesStore = defineStore('files', { name: e.name, size_bytes: e.size_bytes, address: e.address, + public_address: e.public_address ?? undefined, cost: e.cost ?? undefined, data_map_file: e.data_map_file ?? undefined, status: 'complete', @@ -168,6 +176,7 @@ export const useFilesStore = defineStore('files', { cost: f.cost ?? null, uploaded_at: f.date, data_map_file: f.data_map_file ?? null, + public_address: f.public_address ?? null, })) try { @@ -228,8 +237,18 @@ export const useFilesStore = defineStore('files', { return id }, - /** Get a real network quote for a file. Used by the upload dialog to show real costs. */ - async getUploadQuote(path: string): Promise { + /** + * Get a real network quote for a file. Used by the upload dialog to show + * real costs. + * + * `visibility` controls whether ant-core bundles the serialized DataMap + * into the payment batch. Public quotes cost slightly more than private + * ones because the DataMap is billed as one extra chunk. A quote obtained + * with one visibility must be finalized with the same visibility — the + * prepared chunks on the backend differ — so callers re-quote if the + * user changes their selection. + */ + async getUploadQuote(path: string, visibility: 'private' | 'public' = 'private'): Promise { const uploadId = `quote-${Date.now()}-${Math.random().toString(36).slice(2)}` // Deferred so the listener can resolve independently of where the @@ -259,7 +278,7 @@ export const useFilesStore = defineStore('files', { }) await invoke('start_upload', { - request: { files: [path], upload_id: uploadId }, + request: { files: [path], upload_id: uploadId, visibility }, }) const quote = await quotePromise @@ -296,7 +315,10 @@ export const useFilesStore = defineStore('files', { let quote: UploadQuote if (preQuote) { - // Use pre-obtained quote (from dialog phase) + // Use pre-obtained quote (from dialog phase). The caller is + // responsible for ensuring its visibility matches `options.visibility` + // — `pages/files.vue` drops the pre-quote when the user changes + // visibility so we always re-quote with the matching batch. uploadId = preQuote.upload_id quote = preQuote this.updateEntry(id, { cost: preQuote.total_cost_display }) @@ -305,7 +327,7 @@ export const useFilesStore = defineStore('files', { // there is a single implementation of the listen+invoke dance. if (!entry.path) throw new Error('Upload entry has no file path') this.updateEntry(id, { status: 'quoting' }) - const fresh = await this.getUploadQuote(entry.path) + const fresh = await this.getUploadQuote(entry.path, options.visibility) if (!fresh) throw new Error('Failed to get quote from network') uploadId = fresh.upload_id quote = fresh @@ -339,7 +361,7 @@ export const useFilesStore = defineStore('files', { // can legitimately take many minutes for larger files. The CLI // (which works) also has no timeout here. Backend errors still // surface through invoke's rejection. - const result = await invoke<{ upload_id: string; data_map_json: string; address: string; chunks_stored: number; data_map_file: string }>('confirm_upload_merkle', { + const result = await invoke<{ upload_id: string; data_map_json: string; address: string; chunks_stored: number; data_map_file: string; public_address: string | null }>('confirm_upload_merkle', { uploadId, winnerPoolHash: payResult.winnerPoolHash, }) @@ -351,6 +373,7 @@ export const useFilesStore = defineStore('files', { status: 'complete', progress: 100, address: result.address, + public_address: result.public_address ?? undefined, data_map_json: result.data_map_json, data_map_file: result.data_map_file, duration, @@ -383,7 +406,7 @@ export const useFilesStore = defineStore('files', { this.updateEntry(id, { status: 'uploading', progress: 0 }) // No frontend timeout — see confirm_upload_merkle above for rationale. - const result = await invoke<{ upload_id: string; data_map_json: string; address: string; chunks_stored: number; data_map_file: string }>('confirm_upload', { + const result = await invoke<{ upload_id: string; data_map_json: string; address: string; chunks_stored: number; data_map_file: string; public_address: string | null }>('confirm_upload', { uploadId, txHashes, }) @@ -395,6 +418,7 @@ export const useFilesStore = defineStore('files', { status: 'complete', progress: 100, address: result.address, + public_address: result.public_address ?? undefined, data_map_json: result.data_map_json, data_map_file: result.data_map_file, duration,