diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml
new file mode 100644
index 0000000..1f2ea11
--- /dev/null
+++ b/.idea/copilot.data.migration.ask2agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 620ba8f..b268ef3 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -5,24 +5,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 171d7dc..70ff638 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -14,6 +14,7 @@
+
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index b2c751a..9ae1f4a 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,7 @@
+
-
+
diff --git a/core/src/main/java/com/altude/core/Programs/AttestationProgram.kt b/core/src/main/java/com/altude/core/Programs/AttestationProgram.kt
new file mode 100644
index 0000000..66c44c6
--- /dev/null
+++ b/core/src/main/java/com/altude/core/Programs/AttestationProgram.kt
@@ -0,0 +1,187 @@
+package com.altude.core.Programs
+
+import com.altude.core.Programs.Utility.SYSTEM_PROGRAM_ID
+import foundation.metaplex.solana.transactions.AccountMeta
+import foundation.metaplex.solana.transactions.TransactionInstruction
+import foundation.metaplex.solanapublickeys.PublicKey
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.charset.StandardCharsets
+
+/**
+ * Solana Attestation Service (SAS) Program instructions.
+ *
+ * Program ID: 22zoJMtdu5rJOmEMCqaYzDVLEqJa4FcFBGmrnEcUhbCr
+ * Reference: https://github.com/solana-attestation-service
+ *
+ * SAS uses an Anchor 8-byte discriminator derived from the instruction name.
+ * Discriminators are precomputed sha256("global:")[0..8].
+ */
+object AttestationProgram {
+
+ /** On-chain program address for the Solana Attestation Service. */
+ val PROGRAM_ID = PublicKey("22zoJMtdu5rJOmEMCqaYzDVLEqJa4FcFBGmrnEcUhbCr")
+
+ // ── Anchor discriminators (sha256("global:")[0..8] little-endian) ───
+ // Pre-computed from the SAS IDL.
+ private val DISC_CREATE_SCHEMA = byteArrayOf(102.toByte(), 82.toByte(), 205.toByte(), 109.toByte(), 56.toByte(), 206.toByte(), 253.toByte(), 189.toByte())
+ private val DISC_CREATE_ATTESTATION = byteArrayOf( 71.toByte(), 149.toByte(), 77.toByte(), 206.toByte(), 166.toByte(), 86.toByte(), 252.toByte(), 185.toByte())
+ private val DISC_REVOKE_ATTESTATION = byteArrayOf(243.toByte(), 175.toByte(), 179.toByte(), 26.toByte(), 67.toByte(), 213.toByte(), 154.toByte(), 97.toByte())
+
+ // ── PDA seeds ─────────────────────────────────────────────────────────────
+
+ /** Derives the Schema PDA: seeds = ["schema", authority, name] */
+ suspend fun deriveSchemaAddress(authority: PublicKey, name: String): PublicKey {
+ val seeds = listOf(
+ "schema".toByteArray(StandardCharsets.UTF_8),
+ authority.toByteArray(),
+ name.toByteArray(StandardCharsets.UTF_8)
+ )
+ return PublicKey.findProgramAddress(seeds, PROGRAM_ID).address
+ }
+
+ /**
+ * Derives the Attestation PDA.
+ * seeds = ["attestation", schema, attester, recipient, nonce]
+ */
+ suspend fun deriveAttestationAddress(
+ schema: PublicKey,
+ attester: PublicKey,
+ recipient: PublicKey,
+ nonce: String = ""
+ ): PublicKey {
+ val seeds = mutableListOf(
+ "attestation".toByteArray(StandardCharsets.UTF_8),
+ schema.toByteArray(),
+ attester.toByteArray(),
+ recipient.toByteArray()
+ )
+ if (nonce.isNotBlank()) seeds += nonce.toByteArray(StandardCharsets.UTF_8)
+ return PublicKey.findProgramAddress(seeds, PROGRAM_ID).address
+ }
+
+ // ── Instruction builders ──────────────────────────────────────────────────
+
+ /**
+ * Creates a `createSchema` instruction.
+ *
+ * Accounts (order matches SAS IDL):
+ * 0. schema [writable] — PDA, derived by [deriveSchemaAddress]
+ * 1. authority [writable, signer] — Schema authority
+ * 2. feePayer [writable, signer] — Rent / fee payer
+ * 3. systemProgram [read-only] — 11111…
+ */
+ suspend fun createSchema(
+ authority: PublicKey,
+ feePayer: PublicKey,
+ name: String,
+ description: String,
+ fieldNames: List,
+ isRevocable: Boolean
+ ): TransactionInstruction {
+ val schemaPda = deriveSchemaAddress(authority, name)
+
+ val nameBytes = name.toByteArray(StandardCharsets.UTF_8)
+ val descBytes = description.toByteArray(StandardCharsets.UTF_8)
+ val fieldNamesBytes = fieldNames.map { it.toByteArray(StandardCharsets.UTF_8) }
+
+ // Layout: discriminator(8) + name_len(4) + name + desc_len(4) + desc
+ // + field_count(4) + [field_len(4) + field]* + is_revocable(1)
+ val fieldSection = fieldNamesBytes.fold(ByteArray(4).also {
+ ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).putInt(fieldNamesBytes.size)
+ }) { acc, fb ->
+ val lenBuf = ByteArray(4).also { b -> ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN).putInt(fb.size) }
+ acc + lenBuf + fb
+ }
+
+ val data = DISC_CREATE_SCHEMA +
+ encode4(nameBytes.size) + nameBytes +
+ encode4(descBytes.size) + descBytes +
+ fieldSection +
+ byteArrayOf(if (isRevocable) 1 else 0)
+
+ val accounts = listOf(
+ AccountMeta(schemaPda, isSigner = false, isWritable = true),
+ AccountMeta(authority, isSigner = true, isWritable = true),
+ AccountMeta(feePayer, isSigner = true, isWritable = true),
+ AccountMeta(SYSTEM_PROGRAM_ID, isSigner = false, isWritable = false)
+ )
+ return TransactionInstruction(programId = PROGRAM_ID, keys = accounts, data = data)
+ }
+
+ /**
+ * Creates an `attest` instruction.
+ *
+ * Accounts:
+ * 0. attestation [writable] — PDA, derived by [deriveAttestationAddress]
+ * 1. schema [read-only] — Schema PDA
+ * 2. attester [writable, signer]
+ * 3. recipient [read-only]
+ * 4. feePayer [writable, signer]
+ * 5. systemProgram
+ */
+ suspend fun createAttestation(
+ attester: PublicKey,
+ feePayer: PublicKey,
+ schemaPda: PublicKey,
+ recipient: PublicKey,
+ attestationData: ByteArray,
+ expireAt: Long = 0L,
+ nonce: String = ""
+ ): TransactionInstruction {
+ val attestationPda = deriveAttestationAddress(schemaPda, attester, recipient, nonce)
+
+ // Layout: discriminator(8) + data_len(4) + data + expire_at(8) + nonce_len(4) + nonce
+ val nonceBytes = nonce.toByteArray(StandardCharsets.UTF_8)
+ val data = DISC_CREATE_ATTESTATION +
+ encode4(attestationData.size) + attestationData +
+ encode8(expireAt) +
+ encode4(nonceBytes.size) + nonceBytes
+
+ val accounts = listOf(
+ AccountMeta(attestationPda, isSigner = false, isWritable = true),
+ AccountMeta(schemaPda, isSigner = false, isWritable = false),
+ AccountMeta(attester, isSigner = true, isWritable = true),
+ AccountMeta(recipient, isSigner = false, isWritable = false),
+ AccountMeta(feePayer, isSigner = true, isWritable = true),
+ AccountMeta(SYSTEM_PROGRAM_ID, isSigner = false, isWritable = false)
+ )
+ return TransactionInstruction(programId = PROGRAM_ID, keys = accounts, data = data)
+ }
+
+ /**
+ * Creates a `revokeAttestation` instruction.
+ *
+ * Accounts:
+ * 0. attestation [writable] — The Attestation PDA to revoke
+ * 1. attester [writable, signer]
+ * 2. feePayer [writable, signer]
+ * 3. systemProgram
+ */
+ fun revokeAttestation(
+ attester: PublicKey,
+ feePayer: PublicKey,
+ attestationPda: PublicKey
+ ): TransactionInstruction {
+ val accounts = listOf(
+ AccountMeta(attestationPda, isSigner = false, isWritable = true),
+ AccountMeta(attester, isSigner = true, isWritable = true),
+ AccountMeta(feePayer, isSigner = true, isWritable = true),
+ AccountMeta(SYSTEM_PROGRAM_ID, isSigner = false, isWritable = false)
+ )
+ return TransactionInstruction(
+ programId = PROGRAM_ID,
+ keys = accounts,
+ data = DISC_REVOKE_ATTESTATION
+ )
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private fun encode4(value: Int): ByteArray =
+ ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array()
+
+ private fun encode8(value: Long): ByteArray =
+ ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(value).array()
+}
+
diff --git a/core/src/main/java/com/altude/core/api/TransactionService.kt b/core/src/main/java/com/altude/core/api/TransactionService.kt
index 1acabce..a14eca2 100644
--- a/core/src/main/java/com/altude/core/api/TransactionService.kt
+++ b/core/src/main/java/com/altude/core/api/TransactionService.kt
@@ -1,7 +1,10 @@
package com.altude.core.api
+import com.altude.core.data.AttestRequest
import com.altude.core.data.BatchTransactionRequest
+import com.altude.core.data.CreateSchemaRequest
import com.altude.core.data.MintData
+import com.altude.core.data.RevokeAttestationRequest
import com.altude.core.data.SendTransactionRequest
import com.altude.core.data.SwapTransactionRequest
import kotlinx.serialization.Contextual
@@ -111,6 +114,23 @@ interface TransactionService {
@Body body: ISendTransactionRequest
): Call
+ // ── Solana Attestation Service (SAS) ─────────────────────────────────────
+
+ @POST("api/attestation/createSchema")
+ fun createSchema(
+ @Body body: CreateSchemaRequest
+ ): Call
+
+ @POST("api/attestation/attest")
+ fun attest(
+ @Body body: AttestRequest
+ ): Call
+
+ @POST("api/attestation/revoke")
+ fun revokeAttestation(
+ @Body body: RevokeAttestationRequest
+ ): Call
+
@GET("api/transaction/config")
fun getConfig(): Call
}
diff --git a/core/src/main/java/com/altude/core/data/AttestationRequest.kt b/core/src/main/java/com/altude/core/data/AttestationRequest.kt
new file mode 100644
index 0000000..fb20fa9
--- /dev/null
+++ b/core/src/main/java/com/altude/core/data/AttestationRequest.kt
@@ -0,0 +1,28 @@
+package com.altude.core.data
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Request body for POST api/attestation/createSchema
+ */
+@Serializable
+data class CreateSchemaRequest(
+ val SignedTransaction: String
+)
+
+/**
+ * Request body for POST api/attestation/attest
+ */
+@Serializable
+data class AttestRequest(
+ val SignedTransaction: String
+)
+
+/**
+ * Request body for POST api/attestation/revoke
+ */
+@Serializable
+data class RevokeAttestationRequest(
+ val SignedTransaction: String
+)
+
diff --git a/core/src/main/java/com/altude/core/service/StorageService.kt b/core/src/main/java/com/altude/core/service/StorageService.kt
index 052793a..b53e9ac 100644
--- a/core/src/main/java/com/altude/core/service/StorageService.kt
+++ b/core/src/main/java/com/altude/core/service/StorageService.kt
@@ -48,6 +48,13 @@ object StorageService {
fun init(context : Context) {
appContext = context.applicationContext // prevents memory leaks
}
+
+ /**
+ * Exposes the application [Context] to other SDK modules (e.g. provenance).
+ * Safe to call after [init] has been invoked.
+ */
+ fun getContext(): Context = appContext
+
private val ANDROID_KEYSTORE = "AndroidKeyStore"
private fun getKeyAlias(): String = "altude_store"
diff --git a/gasstation/src/main/java/com/altude/gasstation/Altude.kt b/gasstation/src/main/java/com/altude/gasstation/Altude.kt
index eee5fee..92dc9d7 100644
--- a/gasstation/src/main/java/com/altude/gasstation/Altude.kt
+++ b/gasstation/src/main/java/com/altude/gasstation/Altude.kt
@@ -244,6 +244,7 @@ object Altude {
}
}
+
suspend fun generateKeyPair(): SolanaKeypair {
val keypair = KeyPair.generate()
return SolanaKeypair(keypair.publicKey,keypair.secretKey)
diff --git a/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt b/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt
index 77698b8..b1bc1b2 100644
--- a/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt
+++ b/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt
@@ -559,4 +559,5 @@ object GaslessManager {
}
+
}
\ No newline at end of file
diff --git a/gasstation/src/main/java/com/altude/gasstation/data/AttestationOption.kt b/gasstation/src/main/java/com/altude/gasstation/data/AttestationOption.kt
new file mode 100644
index 0000000..f71521d
--- /dev/null
+++ b/gasstation/src/main/java/com/altude/gasstation/data/AttestationOption.kt
@@ -0,0 +1,94 @@
+package com.altude.gasstation.data
+
+/**
+ * Options for creating an on-chain attestation using the
+ * Solana Attestation Service (SAS) program.
+ *
+ * Usage:
+ * ```kotlin
+ * Altude.attest(
+ * AttestationOption(
+ * schemaId = "schema_pubkey_base58",
+ * recipient = "recipient_wallet_base58",
+ * data = """{"kyc": true, "level": 2}""".encodeToByteArray()
+ * )
+ * )
+ * ```
+ *
+ * @param account Attester wallet. Leave blank to use the current signer.
+ * @param schemaId The SAS Schema account pubkey (Base58) that describes this attestation.
+ * @param recipient The account being attested (Base58 pubkey). Defaults to attester if blank.
+ * @param data Arbitrary bytes to store in the attestation (e.g. JSON-encoded claim data).
+ * Must not exceed the schema's declared max size.
+ * @param expireAt Unix timestamp (seconds) when the attestation expires. 0 = no expiry.
+ * @param nonce Optional unique nonce to allow multiple attestations per (attester, schema, recipient).
+ * @param commitment Finality commitment to wait for.
+ */
+data class AttestationOption(
+ val account: String = "",
+ val schemaId: String,
+ val recipient: String = "",
+ val data: ByteArray = ByteArray(0),
+ val expireAt: Long = 0L,
+ val nonce: String = "",
+ val commitment: Commitment = Commitment.finalized
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is AttestationOption) return false
+ return account == other.account &&
+ schemaId == other.schemaId &&
+ recipient == other.recipient &&
+ data.contentEquals(other.data) &&
+ expireAt == other.expireAt &&
+ nonce == other.nonce &&
+ commitment == other.commitment
+ }
+
+ override fun hashCode(): Int {
+ var result = account.hashCode()
+ result = 31 * result + schemaId.hashCode()
+ result = 31 * result + recipient.hashCode()
+ result = 31 * result + data.contentHashCode()
+ result = 31 * result + expireAt.hashCode()
+ result = 31 * result + nonce.hashCode()
+ result = 31 * result + commitment.hashCode()
+ return result
+ }
+}
+
+/**
+ * Options for revoking a previously created on-chain attestation.
+ *
+ * @param account Attester wallet that originally created the attestation. Blank = current signer.
+ * @param attestationId The on-chain Attestation account pubkey (Base58) to revoke.
+ * @param commitment Finality commitment to wait for.
+ */
+data class RevokeAttestationOption(
+ val account: String = "",
+ val attestationId: String,
+ val commitment: Commitment = Commitment.finalized
+)
+
+/**
+ * Options for creating a new SAS Schema.
+ *
+ * A Schema defines the structure (and optional validation rules) of attestation data.
+ * Create a schema once; reuse its pubkey in every [AttestationOption].
+ *
+ * @param account Schema authority wallet. Blank = current signer.
+ * @param name Short human-readable schema name (max 32 chars).
+ * @param description Description of what this schema attests.
+ * @param fieldNames Ordered list of field names matching the encoded data fields.
+ * @param isRevocable Whether attestations using this schema can be revoked.
+ * @param commitment Finality commitment.
+ */
+data class CreateSchemaOption(
+ val account: String = "",
+ val name: String,
+ val description: String = "",
+ val fieldNames: List = emptyList(),
+ val isRevocable: Boolean = true,
+ val commitment: Commitment = Commitment.finalized
+)
+
diff --git a/gasstation/src/main/java/com/altude/gasstation/data/AttestationResponse.kt b/gasstation/src/main/java/com/altude/gasstation/data/AttestationResponse.kt
new file mode 100644
index 0000000..21fdf25
--- /dev/null
+++ b/gasstation/src/main/java/com/altude/gasstation/data/AttestationResponse.kt
@@ -0,0 +1,20 @@
+package com.altude.gasstation.data
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Response returned after a successful attestation operation
+ * (create, revoke, or schema creation).
+ */
+@Serializable
+data class AttestationResponse(
+ /** "success" or "error" */
+ val Status: String,
+ /** Human-readable result message. */
+ val Message: String,
+ /** Transaction signature on Solana. */
+ val Signature: String,
+ /** The on-chain Attestation / Schema account pubkey (Base58). Empty for revoke. */
+ val attestationId: String = ""
+)
+
diff --git a/provenance/.gitignore b/provenance/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/provenance/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/provenance/build.gradle.kts b/provenance/build.gradle.kts
new file mode 100644
index 0000000..03b6993
--- /dev/null
+++ b/provenance/build.gradle.kts
@@ -0,0 +1,65 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ kotlin("plugin.serialization") version "2.2.0"
+}
+
+android {
+ namespace = "com.altude.provenance"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 21
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ implementation(project(":core"))
+
+ // Networking
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.gson)
+ implementation(libs.okhttp)
+ implementation(libs.okhttp.logging)
+
+ // Solana & Metaplex
+ api(libs.solana) {
+ exclude(group = "com.ditchoom")
+ exclude(group = "io.github.funkatronics", module = "kborsh")
+ }
+
+ // Serialization
+ implementation(libs.serialization.json) {
+ exclude(group = "com.ditchoom")
+ }
+
+ // JPEG XMP metadata embedding (C2PA manifest)
+ implementation("androidx.exifinterface:exifinterface:1.3.7")
+}
+
diff --git a/provenance/consumer-rules.pro b/provenance/consumer-rules.pro
new file mode 100644
index 0000000..da2873d
--- /dev/null
+++ b/provenance/consumer-rules.pro
@@ -0,0 +1,3 @@
+# Add consumer ProGuard rules here.
+-keep class com.altude.provenance.** { *; }
+
diff --git a/provenance/proguard-rules.pro b/provenance/proguard-rules.pro
new file mode 100644
index 0000000..4fa8113
--- /dev/null
+++ b/provenance/proguard-rules.pro
@@ -0,0 +1,3 @@
+# Add project specific ProGuard rules here.
+-keep class com.altude.provenance.** { *; }
+
diff --git a/provenance/src/main/java/com/altude/provenance/Provenance.kt b/provenance/src/main/java/com/altude/provenance/Provenance.kt
new file mode 100644
index 0000000..ef0cc4b
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/Provenance.kt
@@ -0,0 +1,290 @@
+package com.altude.provenance
+
+import com.altude.core.config.SdkConfig
+import com.altude.core.service.StorageService
+import com.altude.provenance.data.AttestationResult
+import com.altude.provenance.data.C2paManifest
+import com.altude.provenance.data.ImageHashPayload
+import com.altude.provenance.data.ImageHashRequest
+import com.altude.provenance.data.ImageHashResponse
+import com.altude.provenance.data.ManifestOption
+import com.altude.provenance.data.ProvenancePrefs
+import com.altude.provenance.data.ProvenanceResult
+import com.altude.provenance.interfaces.ProvenanceService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.decodeFromJsonElement
+import retrofit2.await
+
+/**
+ * Public entry point for the Provenance module.
+ *
+ * Attests image data on-chain using the Solana Attestation Service (SAS) program.
+ * Schema is created once per wallet and cached — subsequent calls skip it entirely.
+ *
+ * ── Where does filePath come from? ───────────────────────────────────────────
+ *
+ * 1. Camera capture (saves to a File you control):
+ * ```kotlin
+ * val file = File(context.filesDir, "photo.jpg")
+ * val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
+ * // pass uri to camera intent via MediaStore.EXTRA_OUTPUT
+ * // after capture:
+ * val payload = ImageHashPayload.create(filePath = file.absolutePath, account = wallet)
+ * ```
+ *
+ * 2. Gallery / document picker (returns a content:// URI):
+ * ```kotlin
+ * // in onActivityResult / ActivityResultCallback:
+ * val uri: Uri = result.data?.data ?: return
+ * val payload = ImageHashPayload.create(uri = uri, contentResolver = contentResolver, account = wallet)
+ * ```
+ *
+ * 3. File already on disk (downloads, app storage):
+ * ```kotlin
+ * val file = File(context.filesDir, "image.png")
+ * val payload = ImageHashPayload.create(filePath = file.absolutePath, account = wallet)
+ * ```
+ *
+ * Single image:
+ * ```kotlin
+ * val result = Provenance.attestImageHash(payload)
+ * val id = result.getOrThrow().attestationId
+ * ```
+ *
+ * Bulk images (1 schema tx, N attestation txs, sequential):
+ * ```kotlin
+ * viewModelScope.launch {
+ * Provenance.attestBatch(payloads).collect { item ->
+ * // item.index, item.name, item.hash, item.result
+ * }
+ * }
+ * ```
+ */
+object Provenance {
+
+ @OptIn(ExperimentalSerializationApi::class)
+ private val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ encodeDefaults = true
+ explicitNulls = false
+ }
+
+ private fun service(): ProvenanceService =
+ SdkConfig.createService(ProvenanceService::class.java)
+
+ // ── Single image ──────────────────────────────────────────────────────────
+
+ /**
+ * Attests a single image on-chain.
+ *
+ * @param payload Built via [ImageHashPayload.create].
+ * @param manifestOption How to save the manifest after attestation. Default: [ManifestOption.SidecarFile].
+ *
+ * Options:
+ * - [ManifestOption.SidecarFile] — saves `{name}.c2pa.json` in `filesDir/provenance_manifests/`
+ * - [ManifestOption.EmbedInImage] — embeds manifest into JPEG XMP or PNG tEXt chunk
+ * - [ManifestOption.Both] — saves sidecar file AND embeds in image
+ * - [ManifestOption.None] — no file saved; manifest still in [ProvenanceResult.manifest]
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ suspend fun attestImageHash(
+ payload: ImageHashPayload,
+ manifestOption: ManifestOption = ManifestOption.SidecarFile
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ // 1. Ensure schema
+ val schemaResult = ProvenanceManager.ensureSchema(
+ account = payload.account,
+ commitment = payload.commitment.name
+ )
+ if (schemaResult.isFailure)
+ return@withContext Result.failure(schemaResult.exceptionOrNull()!!)
+
+ // 2. Derive Schema PDA
+ val schemaPda = ProvenanceManager.deriveSchemaAddress(payload.account)
+
+ // 3. Sign attestation tx offline
+ val attestResult = ProvenanceManager.attest(payload, schemaPda)
+ if (attestResult.isFailure)
+ return@withContext Result.failure(attestResult.exceptionOrNull()!!)
+
+ // 4. Submit
+ val request = ImageHashRequest(
+ type = payload.type,
+ hash = payload.hash,
+ mime = payload.mime,
+ name = payload.name,
+ timestamp = payload.timestamp,
+ account = payload.account,
+ recipient = payload.recipient,
+ expireAt = payload.expireAt,
+ manifest = payload.manifest,
+ signedSchemaTx = schemaResult.getOrNull(),
+ signedAttestationTx = attestResult.getOrThrow()
+ )
+ val res = service().attestImageHash(request).await()
+ val response = decodeJson(res)
+
+ // 5. Mark schema confirmed ONLY after backend success
+ if (response.Status == "success") {
+ val walletKey = ProvenanceManager.getKeyPair(payload.account).publicKey.toBase58()
+ ProvenancePrefs.markSchemaCreated(walletKey)
+ }
+
+ // 6. Apply chosen manifest option
+ val (manifestFile, embeddedImageFile) =
+ applyManifestOption(payload.c2paManifest, manifestOption)
+
+ Result.success(ProvenanceResult(
+ response = response,
+ manifest = payload.c2paManifest,
+ manifestFile = manifestFile,
+ embeddedImageFile = embeddedImageFile
+ ))
+ } catch (e: Throwable) {
+ Result.failure(Exception(e.message ?: e.javaClass.simpleName, e))
+ }
+ }
+
+ // ── Bulk images ───────────────────────────────────────────────────────────
+
+ /**
+ * Attests a list of images sequentially — 1 schema tx (first call only per wallet),
+ * N attestation txs submitted one by one in order.
+ *
+ * @param payloads List built via [ImageHashPayload.create].
+ * @param manifestOption How to save each manifest. Default: [ManifestOption.SidecarFile].
+ *
+ * ```kotlin
+ * viewModelScope.launch {
+ * Provenance.attestBatch(payloads, ManifestOption.EmbedInImage(filePath)).collect { item ->
+ * val embedded = item.result.getOrNull()?.embeddedImageFile
+ * val sidecar = item.result.getOrNull()?.manifestFile
+ * }
+ * }
+ * ```
+ */
+ fun attestBatch(
+ payloads: List,
+ manifestOption: ManifestOption = ManifestOption.SidecarFile
+ ): Flow = flow {
+ if (payloads.isEmpty()) return@flow
+
+ val first = payloads.first()
+
+ // ── 1. Ensure schema ONCE for the whole batch ─────────────────────────
+ val schemaResult = ProvenanceManager.ensureSchema(
+ account = first.account,
+ commitment = first.commitment.name
+ )
+ if (schemaResult.isFailure) {
+ payloads.forEachIndexed { i, p ->
+ emit(AttestationResult(i, p.name, p.hash,
+ Result.failure(schemaResult.exceptionOrNull()!!)))
+ }
+ return@flow
+ }
+
+ // ── 2. Derive Schema PDA ONCE ─────────────────────────────────────────
+ val schemaPda = ProvenanceManager.deriveSchemaAddress(first.account)
+ val walletKey = ProvenanceManager.getKeyPair(first.account).publicKey.toBase58()
+
+ var schemaMarked = ProvenancePrefs.isSchemaCreated(walletKey)
+
+ // ── 3. Sequential loop — sign offline → submit → emit ─────────────────
+ payloads.forEachIndexed { index, payload ->
+ val itemResult = runCatching {
+ val attestedTx = ProvenanceManager.attest(payload, schemaPda).getOrThrow()
+
+ val request = ImageHashRequest(
+ type = payload.type,
+ hash = payload.hash,
+ mime = payload.mime,
+ name = payload.name,
+ timestamp = payload.timestamp,
+ account = payload.account,
+ recipient = payload.recipient,
+ expireAt = payload.expireAt,
+ manifest = payload.manifest,
+ signedSchemaTx = if (index == 0) schemaResult.getOrNull() else null,
+ signedAttestationTx = attestedTx
+ )
+ val res = service().attestImageHash(request).await()
+ val response = decodeJson(res)
+
+ if (!schemaMarked && index == 0 && response.Status == "success") {
+ ProvenancePrefs.markSchemaCreated(walletKey)
+ schemaMarked = true
+ }
+
+ // Apply chosen manifest option per image
+ val (manifestFile, embeddedImageFile) =
+ applyManifestOption(payload.c2paManifest, manifestOption)
+
+ ProvenanceResult(
+ response = response,
+ manifest = payload.c2paManifest,
+ manifestFile = manifestFile,
+ embeddedImageFile = embeddedImageFile
+ )
+ }
+ emit(AttestationResult(index, payload.name, payload.hash, itemResult))
+ }
+ }.flowOn(Dispatchers.IO)
+
+ // ── Session ───────────────────────────────────────────────────────────────
+
+ /**
+ * Clears the schema state (SharedPreferences + in-memory PDA cache) for [account].
+ * Call on wallet switch or logout so the next [attestImageHash] re-creates the schema.
+ */
+ suspend fun resetSession(account: String = "") =
+ ProvenanceManager.resetSchema(account)
+
+ // ── Manifest option helper ────────────────────────────────────────────────
+
+ /**
+ * Applies [option] after attestation. Returns (sidecarFile, embeddedImageFile).
+ * Errors are swallowed to null so a save failure never crashes the attestation.
+ */
+ private fun applyManifestOption(
+ manifest: C2paManifest,
+ option: ManifestOption
+ ): Pair {
+ val manifestsDir = java.io.File(StorageService.getContext().filesDir, "provenance_manifests")
+ return when (option) {
+ is ManifestOption.SidecarFile -> {
+ val file = runCatching { manifest.saveTo(manifestsDir) }.getOrNull()
+ Pair(file, null)
+ }
+ is ManifestOption.EmbedInImage -> {
+ val embedded = runCatching {
+ manifest.embedInto(java.io.File(option.sourceFilePath))
+ }.getOrNull()
+ Pair(null, embedded)
+ }
+ is ManifestOption.Both -> {
+ val sidecar = runCatching { manifest.saveTo(manifestsDir) }.getOrNull()
+ val embedded = runCatching {
+ manifest.embedInto(java.io.File(option.sourceFilePath))
+ }.getOrNull()
+ Pair(sidecar, embedded)
+ }
+ else -> Pair(null, null) // ManifestOption.None
+ }
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private inline fun decodeJson(element: JsonElement): T =
+ json.decodeFromJsonElement(element)
+}
diff --git a/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt b/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt
new file mode 100644
index 0000000..d2b7ae9
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt
@@ -0,0 +1,197 @@
+package com.altude.provenance
+
+import android.util.Base64
+import com.altude.core.Programs.AttestationProgram
+import com.altude.core.config.SdkConfig
+import com.altude.core.helper.Mnemonic
+import com.altude.core.model.AltudeTransactionBuilder
+import com.altude.core.model.HotSigner
+import com.altude.core.network.AltudeRpc
+import com.altude.core.service.StorageService
+import com.altude.provenance.data.ImageHashPayload
+import com.altude.provenance.data.ProvenancePrefs
+import foundation.metaplex.solana.transactions.SerializeConfig
+import foundation.metaplex.solanaeddsa.Keypair
+import foundation.metaplex.solanaeddsa.SolanaEddsa
+import foundation.metaplex.solanapublickeys.PublicKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Internal transaction builder for image-hash provenance operations.
+ *
+ * Builds and partially signs both the `createSchema` and `createAttestation`
+ * transactions, then returns Base64-encoded serialized transactions to be
+ * forwarded to the backend.
+ */
+internal object ProvenanceManager {
+
+ /** Fixed schema name used for all image-hash attestations. */
+ const val SCHEMA_NAME = "image_hash"
+
+ /**
+ * Session-level PDA cache: walletBase58 → Schema PDA.
+ * Avoids re-deriving the PDA on every call within the same app session.
+ * SharedPreferences ([ProvenancePrefs]) handles persistence across restarts.
+ */
+ private val schemaPdaCache = mutableMapOf()
+
+ private val rpc get() = AltudeRpc(SdkConfig.apiConfig.RpcUrl)
+ private val feePayerPubKey get() = PublicKey(SdkConfig.apiConfig.FeePayer)
+
+ // ── Keypair resolution ────────────────────────────────────────────────────
+
+ suspend fun getKeyPair(account: String = ""): Keypair {
+ val seedData = StorageService.getDecryptedSeed(account)
+ if (seedData != null) {
+ if (seedData.type == "mnemonic") return Mnemonic(seedData.mnemonic).getKeyPair()
+ return if (seedData.privateKey != null)
+ SolanaEddsa.createKeypairFromSecretKey(seedData.privateKey!!.copyOfRange(0, 32))
+ else throw Error("No seed found in storage")
+ } else throw Error("Please set seed first")
+ }
+
+ // ── PDA helpers ───────────────────────────────────────────────────────────
+
+ /**
+ * Derives the Schema PDA for the fixed [SCHEMA_NAME] and the given [account].
+ * Caches the result in [schemaPdaCache] — computed once per session per wallet.
+ * Keeps [AttestationProgram] fully hidden from callers outside this class.
+ */
+ internal suspend fun deriveSchemaAddress(account: String = ""): PublicKey {
+ val keypair = getKeyPair(account)
+ val walletKey = keypair.publicKey.toBase58()
+ return schemaPdaCache.getOrPut(walletKey) {
+ AttestationProgram.deriveSchemaAddress(
+ authority = keypair.publicKey,
+ name = SCHEMA_NAME
+ )
+ }
+ }
+
+ // ── Schema — once per wallet ──────────────────────────────────────────────
+
+ /**
+ * Builds + signs a `createSchema` tx ONLY if not yet confirmed for this wallet.
+ *
+ * What `createSchema` does:
+ * Registers a reusable template on Solana that defines what fields an image
+ * attestation contains (type, hash, mime, name, timestamp). Created ONCE per
+ * wallet ever — every image attestation then just references it. Without it,
+ * the on-chain program rejects all attestations.
+ *
+ * Flow:
+ * - [ProvenancePrefs.isSchemaCreated] == true → return `Result.success(null)` (skip)
+ * - [ProvenancePrefs.isSchemaCreated] == false → build + sign tx, return `Result.success(signedTx)`
+ *
+ * [Provenance] calls [ProvenancePrefs.markSchemaCreated] ONLY after backend confirms success.
+ *
+ * @return `Result` — `null` = schema already exists; `String` = signed tx to send.
+ */
+ suspend fun ensureSchema(account: String, commitment: String): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ val keypair = getKeyPair(account)
+ val walletKey = keypair.publicKey.toBase58()
+
+ // Already confirmed on-chain — skip building the tx entirely
+ if (ProvenancePrefs.isSchemaCreated(walletKey))
+ return@withContext Result.success(null)
+
+ val hotSigner = HotSigner(keypair)
+ val instruction = AttestationProgram.createSchema(
+ authority = keypair.publicKey,
+ feePayer = feePayerPubKey,
+ name = SCHEMA_NAME,
+ description = "Stores SHA-256 hash of images",
+ fieldNames = listOf("type", "hash", "mime", "name", "timestamp"),
+ isRevocable = true
+ )
+ val blockhash = rpc.getLatestBlockhash(commitment = commitment).blockhash
+ val tx = AltudeTransactionBuilder()
+ .setFeePayer(feePayerPubKey)
+ .setRecentBlockHash(blockhash)
+ .addInstruction(instruction)
+ .setSigners(listOf(hotSigner))
+ .build()
+ val serialized = Base64.encodeToString(
+ tx.serialize(SerializeConfig(requireAllSignatures = false)),
+ Base64.NO_WRAP
+ )
+ Result.success(serialized)
+ } catch (e: Throwable) {
+ Result.failure(Exception(e.message ?: e.javaClass.simpleName, e))
+ }
+ }
+
+ /**
+ * Clears schema state for [account] — both in-memory cache and SharedPreferences.
+ * Call via [Provenance.resetSession] on wallet switch or logout.
+ */
+ suspend fun resetSchema(account: String = "") {
+ runCatching {
+ val walletKey = getKeyPair(account).publicKey.toBase58()
+ ProvenancePrefs.reset(walletKey)
+ schemaPdaCache.remove(walletKey)
+ }
+ }
+
+ // ── Attestation ───────────────────────────────────────────────────────────
+
+ /**
+ * Builds and signs a `createAttestation` transaction for an [ImageHashPayload].
+ * The [schemaPda] must be derived beforehand via [AttestationProgram.deriveSchemaAddress].
+ * Returns the Base64-encoded serialized transaction.
+ */
+ suspend fun attest(
+ payload: ImageHashPayload,
+ schemaPda: PublicKey
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ val keypair = getKeyPair(payload.account)
+ val attester = keypair.publicKey
+ val hotSigner = HotSigner(keypair)
+ val recipient = if (payload.recipient.isBlank()) attester
+ else PublicKey(payload.recipient)
+
+ // Structured JSON payload stored in the on-chain attestation
+ val payloadJson = """
+ {
+ "type": "${payload.type}",
+ "hash": "${payload.hash}",
+ "mime": "${payload.mime}",
+ "name": "${payload.name}",
+ "timestamp": ${payload.timestamp}
+ }
+ """.trimIndent()
+
+ val instruction = AttestationProgram.createAttestation(
+ attester = attester,
+ feePayer = feePayerPubKey,
+ schemaPda = schemaPda,
+ recipient = recipient,
+ attestationData = payloadJson.toByteArray(),
+ expireAt = payload.expireAt
+ )
+
+ val blockhash = rpc.getLatestBlockhash(
+ commitment = payload.commitment.name
+ ).blockhash
+
+ val tx = AltudeTransactionBuilder()
+ .setFeePayer(feePayerPubKey)
+ .setRecentBlockHash(blockhash)
+ .addInstruction(instruction)
+ .setSigners(listOf(hotSigner))
+ .build()
+
+ val serialized = Base64.encodeToString(
+ tx.serialize(SerializeConfig(requireAllSignatures = false)),
+ Base64.NO_WRAP
+ )
+ Result.success(serialized)
+ } catch (e: Throwable) {
+ Result.failure(Exception(e.message ?: e.javaClass.simpleName, e))
+ }
+ }
+}
diff --git a/provenance/src/main/java/com/altude/provenance/data/AttestationOption.kt b/provenance/src/main/java/com/altude/provenance/data/AttestationOption.kt
new file mode 100644
index 0000000..e519f49
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/data/AttestationOption.kt
@@ -0,0 +1,6 @@
+package com.altude.provenance.data
+
+/**
+ * Commitment levels for on-chain confirmation.
+ */
+enum class Commitment { processed, confirmed, finalized }
diff --git a/provenance/src/main/java/com/altude/provenance/data/AttestationResponse.kt b/provenance/src/main/java/com/altude/provenance/data/AttestationResponse.kt
new file mode 100644
index 0000000..1b4af18
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/data/AttestationResponse.kt
@@ -0,0 +1,20 @@
+package com.altude.provenance.data
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Response returned after a successful provenance / attestation operation
+ * (create schema, attest, or revoke).
+ */
+@Serializable
+data class AttestationResponse(
+ /** "success" or "error" */
+ val Status: String,
+ /** Human-readable result message. */
+ val Message: String,
+ /** Transaction signature on Solana. */
+ val Signature: String,
+ /** The on-chain Attestation / Schema account pubkey (Base58). Empty for revoke. */
+ val attestationId: String = ""
+)
+
diff --git a/provenance/src/main/java/com/altude/provenance/data/C2paManifest.kt b/provenance/src/main/java/com/altude/provenance/data/C2paManifest.kt
new file mode 100644
index 0000000..5518e70
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/data/C2paManifest.kt
@@ -0,0 +1,264 @@
+package com.altude.provenance.data
+
+import android.graphics.BitmapFactory
+import androidx.exifinterface.media.ExifInterface
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * A lightweight C2PA-style content credential manifest (POC).
+ *
+ * Mirrors the C2PA "claim" structure:
+ * https://c2pa.org/specifications/specifications/2.0/specs/C2PA_Specification.html
+ *
+ * No external C2PA library required — uses only [MessageDigest] + kotlinx.serialization.
+ *
+ * The [manifestHash] is the value stored on-chain via `Provenance.attestImageHash`.
+ * The full [toJson] claim is sent to the backend for off-chain verification.
+ *
+ * How it works:
+ * ```
+ * imageFile bytes → SHA-256 → assetHash
+ * claim JSON (without manifestHash) → SHA-256 → manifestHash ← stored on-chain
+ * ```
+ *
+ * Usage:
+ * ```kotlin
+ * val manifest = C2paManifest.build(
+ * filePath = "/data/user/0/com.example/files/photo.png",
+ * mimeType = "image/png",
+ * producer = walletAddress
+ * )
+ * val payload = ImageHashPayload.fromManifest(manifest)
+ * val result = Provenance.attestImageHash(payload)
+ * ```
+ */
+@Serializable
+data class C2paManifest(
+ /** C2PA claim type — always "c2pa.hash.data" for this POC. */
+ val claimType: String = "c2pa.hash.data",
+ /** SHA-256 hex of the raw image file bytes. */
+ val assetHash: String,
+ /** MIME type of the asset (e.g. "image/png"). */
+ val mimeType: String,
+ /** Original filename, derived from the file path. */
+ val filename: String = "",
+ /** Who produced/captured this asset (e.g. wallet address or app name). */
+ val producer: String = "",
+ /** Software agent that created this manifest. */
+ val softwareAgent: String = "altude-provenance-sdk",
+ /** Unix epoch (seconds) when the manifest was created. */
+ val timestamp: Long = System.currentTimeMillis() / 1000,
+ /**
+ * SHA-256 hex of the canonical manifest JSON (without this field).
+ * This is the value stored on-chain — a tamper-evident hash of the full claim.
+ */
+ val manifestHash: String = ""
+) {
+ companion object {
+
+ private val canonicalJson = Json {
+ encodeDefaults = true
+ explicitNulls = false
+ }
+
+ /**
+ * Builds a [C2paManifest] from a file path.
+ *
+ * Steps:
+ * 1. Read file bytes from [filePath]
+ * 2. SHA-256 the raw bytes → [assetHash]
+ * 3. Build canonical claim JSON (without [manifestHash])
+ * 4. SHA-256 the claim JSON → [manifestHash] ← stored on-chain
+ *
+ * @param filePath Absolute path to the image file.
+ * @param mimeType MIME type e.g. `"image/png"`, `"image/jpeg"`.
+ * @param producer Wallet address or producer identifier.
+ * @param softwareAgent SDK identifier (default: `"altude-provenance-sdk"`).
+ * @throws IllegalArgumentException if the file does not exist or cannot be read.
+ */
+ fun build(
+ filePath: String,
+ mimeType: String = "image/png",
+ producer: String = "",
+ softwareAgent: String = "altude-provenance-sdk"
+ ): C2paManifest {
+ val file = File(filePath)
+ require(file.exists()) { "C2PA: file not found at $filePath" }
+ require(file.canRead()) { "C2PA: cannot read file at $filePath" }
+
+ val imageBytes = file.readBytes()
+
+ // Step 1 — hash the raw image file bytes
+ val assetHash = sha256Hex(imageBytes)
+
+ // Step 2 — build the draft claim without manifestHash
+ val draft = C2paManifest(
+ assetHash = assetHash,
+ mimeType = mimeType,
+ filename = file.name,
+ producer = producer,
+ softwareAgent = softwareAgent,
+ timestamp = System.currentTimeMillis() / 1000
+ )
+
+ // Step 3 — hash the canonical claim JSON → tamper-evident on-chain value
+ val claimJson = canonicalJson.encodeToString(draft)
+ val manifestHash = sha256Hex(claimJson.toByteArray(Charsets.UTF_8))
+
+ return draft.copy(manifestHash = manifestHash)
+ }
+
+ /**
+ * Builds a [C2paManifest] from raw image bytes (e.g. from a camera capture buffer).
+ * Use [build] when the image is already saved to disk.
+ *
+ * @param imageBytes Raw bytes of the image.
+ * @param mimeType MIME type e.g. `"image/png"`.
+ * @param filename Optional filename for the claim.
+ * @param producer Wallet address or producer identifier.
+ */
+ fun buildFromBytes(
+ imageBytes: ByteArray,
+ mimeType: String = "image/png",
+ filename: String = "",
+ producer: String = "",
+ softwareAgent: String = "altude-provenance-sdk"
+ ): C2paManifest {
+ val assetHash = sha256Hex(imageBytes)
+ val draft = C2paManifest(
+ assetHash = assetHash,
+ mimeType = mimeType,
+ filename = filename,
+ producer = producer,
+ softwareAgent = softwareAgent,
+ timestamp = System.currentTimeMillis() / 1000
+ )
+ val claimJson = canonicalJson.encodeToString(draft)
+ val manifestHash = sha256Hex(claimJson.toByteArray(Charsets.UTF_8))
+ return draft.copy(manifestHash = manifestHash)
+ }
+
+ private fun sha256Hex(bytes: ByteArray): String =
+ MessageDigest.getInstance("SHA-256")
+ .digest(bytes)
+ .joinToString("") { "%02x".format(it) }
+ }
+
+ /** Returns the canonical JSON of this manifest for backend storage / verification. */
+ fun toJson(): String = canonicalJson.encodeToString(this)
+
+ /**
+ * Saves the manifest JSON as a sidecar `.c2pa.json` file.
+ * Named `{filename}.c2pa.json` (e.g. `photo.png.c2pa.json`).
+ *
+ * @param directory Directory to write into (e.g. `context.filesDir`).
+ * @return The written [File].
+ */
+ fun saveTo(directory: File): File {
+ directory.mkdirs()
+ val stem = if (filename.isNotBlank()) filename else manifestHash.take(16)
+ val out = File(directory, "$stem.c2pa.json")
+ out.writeText(toJson(), Charsets.UTF_8)
+ return out
+ }
+
+ /**
+ * Embeds the manifest JSON directly into the image file metadata.
+ *
+ * - **JPEG** → written to XMP metadata via `ExifInterface` (`TAG_XMP`).
+ * The client only needs the image — no sidecar file required.
+ * - **PNG** → injected as a `tEXt` chunk with keyword `C2PA` before `IEND`.
+ * - **Other formats** → throws [UnsupportedOperationException]; use [saveTo] instead.
+ *
+ * The file at [imageFile] is modified in-place.
+ *
+ * ```kotlin
+ * result.manifest.embedInto(File(filePath))
+ * // imageFile now contains the C2PA manifest in its metadata
+ * ```
+ *
+ * @param imageFile The image file to embed into (must be JPEG or PNG).
+ * @return The modified [imageFile] for chaining.
+ */
+ fun embedInto(imageFile: File): File {
+ require(imageFile.exists()) { "C2PA embed: file not found at ${imageFile.absolutePath}" }
+ val json = toJson()
+ return when {
+ mimeType.contains("jpeg", ignoreCase = true) ||
+ mimeType.contains("jpg", ignoreCase = true) ||
+ imageFile.extension.lowercase() in listOf("jpg", "jpeg") -> embedJpeg(imageFile, json)
+
+ mimeType.contains("png", ignoreCase = true) ||
+ imageFile.extension.lowercase() == "png" -> embedPng(imageFile, json)
+
+ else -> throw UnsupportedOperationException(
+ "C2PA embed: unsupported format '${imageFile.extension}'. Use saveTo() for a sidecar file."
+ )
+ }
+ }
+
+ // ── Embed helpers ─────────────────────────────────────────────────────────
+
+ private fun embedJpeg(imageFile: File, json: String): File {
+ // ExifInterface writes XMP in-place — standard Adobe/C2PA approach for JPEG
+ val xmp = """
+
+
+
+
+ ${json.replace("<", "<").replace(">", ">")}
+
+
+
+
+ """.trimIndent()
+ ExifInterface(imageFile.absolutePath).apply {
+ setAttribute(ExifInterface.TAG_XMP, xmp)
+ saveAttributes()
+ }
+ return imageFile
+ }
+
+ private fun embedPng(imageFile: File, json: String): File {
+ // Inject a tEXt chunk with keyword "C2PA" before the IEND chunk
+ val original = imageFile.readBytes()
+ val keyword = "C2PA"
+ val text = json.toByteArray(Charsets.UTF_8)
+ val nullByte = byteArrayOf(0x00)
+ val chunkData = keyword.toByteArray(Charsets.UTF_8) + nullByte + text
+
+ // CRC covers chunk type + chunk data
+ val chunkType = "tEXt".toByteArray(Charsets.UTF_8)
+ val crcInput = chunkType + chunkData
+ val crc = java.util.zip.CRC32().also { it.update(crcInput) }.value
+
+ val chunk = ByteArrayOutputStream().apply {
+ write(intToBytes(chunkData.size)) // length (4 bytes, big-endian)
+ write(chunkType) // type (4 bytes)
+ write(chunkData) // data
+ write(intToBytes(crc.toInt())) // CRC (4 bytes, big-endian)
+ }.toByteArray()
+
+ // Find IEND chunk offset (last 12 bytes of a valid PNG)
+ val iendOffset = original.size - 12
+ val patched = original.copyOfRange(0, iendOffset) +
+ chunk +
+ original.copyOfRange(iendOffset, original.size)
+
+ imageFile.writeBytes(patched)
+ return imageFile
+ }
+
+ private fun intToBytes(value: Int): ByteArray = byteArrayOf(
+ (value shr 24 and 0xFF).toByte(),
+ (value shr 16 and 0xFF).toByte(),
+ (value shr 8 and 0xFF).toByte(),
+ (value and 0xFF).toByte()
+ )
+}
+
diff --git a/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt b/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt
new file mode 100644
index 0000000..ef7ac11
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt
@@ -0,0 +1,314 @@
+package com.altude.provenance.data
+
+import android.content.ContentResolver
+import android.net.Uri
+import kotlinx.serialization.Serializable
+
+/**
+ * Structured payload for an image-hash attestation.
+ *
+ * SDK users should NOT construct this directly — use the factory:
+ * ```kotlin
+ * val payload = ImageHashPayload.create(
+ * filePath = file.absolutePath,
+ * mime = "image/png",
+ * producer = walletAddress,
+ * account = walletAddress
+ * )
+ * val result = Provenance.attestImageHash(payload)
+ * ```
+ *
+ * Internally, [create] builds a [C2paManifest] from the file path — the hash
+ * and manifest JSON are computed automatically. The user never touches raw bytes
+ * or SHA-256 directly.
+ */
+@ConsistentCopyVisibility
+data class ImageHashPayload internal constructor(
+ /** Always `"image_hash"`. */
+ val type: String,
+ /**
+ * [C2paManifest.manifestHash] — SHA-256 of the canonical C2PA claim JSON.
+ * Auto-computed by [create]; never set manually.
+ */
+ val hash: String,
+ /** MIME type of the image (e.g. `"image/png"`). */
+ val mime: String,
+ /** Original filename derived from the file path. */
+ val name: String,
+ /** Full C2PA claim JSON — forwarded to backend for off-chain verification. */
+ val manifest: String,
+ /** Original [C2paManifest] object — returned to SDK user via [ProvenanceResult]. */
+ internal val c2paManifest: C2paManifest,
+ /** Unix epoch (seconds) when the manifest was created. */
+ val timestamp: Long,
+ /** Attester wallet address (Base58). Blank = stored default wallet. */
+ val account: String,
+ /** Recipient wallet (Base58). Defaults to attester if blank. */
+ val recipient: String,
+ /** Unix timestamp (seconds) when attestation expires. 0 = no expiry. */
+ val expireAt: Long,
+ /** Finality commitment to wait for. */
+ val commitment: Commitment
+) {
+ companion object {
+
+ /**
+ * Creates an [ImageHashPayload] from a file path.
+ *
+ * **Where does `filePath` come from?**
+ *
+ * 1. **Camera capture** — save output to a File you control, pass its path:
+ * ```kotlin
+ * val file = File(context.filesDir, "photo.jpg")
+ * val uri = FileProvider.getUriForFile(context, "${packageName}.provider", file)
+ * // pass uri to camera intent via MediaStore.EXTRA_OUTPUT, then after capture:
+ * val payload = ImageHashPayload.create(filePath = file.absolutePath, account = wallet)
+ * ```
+ *
+ * 2. **Gallery / document picker** — use the [Uri] overload instead:
+ * ```kotlin
+ * val payload = ImageHashPayload.create(uri = pickedUri, contentResolver = contentResolver, account = wallet)
+ * ```
+ *
+ * 3. **File already on disk** (downloads, app storage):
+ * ```kotlin
+ * val payload = ImageHashPayload.create(filePath = File(filesDir, "image.png").absolutePath, account = wallet)
+ * ```
+ *
+ * Internally builds a [C2paManifest]:
+ * 1. Reads bytes from [filePath]
+ * 2. SHA-256(bytes) → [C2paManifest.assetHash]
+ * 3. Builds canonical C2PA claim JSON
+ * 4. SHA-256(claimJson) → [C2paManifest.manifestHash] ← stored on-chain
+ *
+ * @param filePath Absolute path to the image file on device.
+ * @param mime MIME type e.g. `"image/png"`, `"image/jpeg"`.
+ * @param producer Wallet address or app name embedded in the C2PA claim.
+ * @param account Attester wallet (Base58). Blank = stored default wallet.
+ */
+ fun create(
+ filePath: String,
+ mime: String = "image/png",
+ producer: String = "",
+ account: String = "",
+ recipient: String = "",
+ expireAt: Long = 0L,
+ commitment: Commitment = Commitment.finalized
+ ): ImageHashPayload {
+ val manifest = C2paManifest.build(
+ filePath = filePath,
+ mimeType = mime,
+ producer = producer
+ )
+ return ImageHashPayload(
+ type = "image_hash",
+ hash = manifest.manifestHash,
+ mime = manifest.mimeType,
+ name = manifest.filename,
+ manifest = manifest.toJson(),
+ c2paManifest = manifest,
+ timestamp = manifest.timestamp,
+ account = account,
+ recipient = recipient,
+ expireAt = expireAt,
+ commitment = commitment
+ )
+ }
+
+ /**
+ * Creates an [ImageHashPayload] from a **gallery / document picker URI** (`content://`).
+ *
+ * Use this when the user picks an image from the gallery or files app —
+ * Android returns a `content://` URI that cannot be used as a file path directly.
+ * This overload reads the bytes via [ContentResolver] and builds the C2PA manifest.
+ *
+ * ```kotlin
+ * // In ActivityResultCallback from photo picker / ACTION_GET_CONTENT:
+ * val payload = ImageHashPayload.create(
+ * uri = pickedUri,
+ * contentResolver = contentResolver,
+ * mime = contentResolver.getType(pickedUri) ?: "image/jpeg",
+ * account = walletAddress
+ * )
+ * val result = Provenance.attestImageHash(payload)
+ * ```
+ *
+ * @param uri Content URI returned by gallery / document picker.
+ * @param contentResolver From `Activity.contentResolver` or `Context.contentResolver`.
+ * @param mime MIME type — use `contentResolver.getType(uri)` if unsure.
+ * @param producer Wallet address or app name embedded in the C2PA claim.
+ * @param account Attester wallet (Base58). Blank = stored default wallet.
+ */
+ fun create(
+ uri: Uri,
+ contentResolver: ContentResolver,
+ mime: String = "image/jpeg",
+ producer: String = "",
+ account: String = "",
+ recipient: String = "",
+ expireAt: Long = 0L,
+ commitment: Commitment = Commitment.finalized
+ ): ImageHashPayload {
+ val imageBytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ ?: throw IllegalArgumentException("C2PA: cannot open URI $uri")
+
+ // Derive a best-effort filename from the URI's last path segment
+ val filename = uri.lastPathSegment?.substringAfterLast('/') ?: "image"
+
+ val manifest = C2paManifest.buildFromBytes(
+ imageBytes = imageBytes,
+ mimeType = mime,
+ filename = filename,
+ producer = producer
+ )
+ return ImageHashPayload(
+ type = "image_hash",
+ hash = manifest.manifestHash,
+ mime = manifest.mimeType,
+ name = manifest.filename,
+ manifest = manifest.toJson(),
+ c2paManifest = manifest,
+ timestamp = manifest.timestamp,
+ account = account,
+ recipient = recipient,
+ expireAt = expireAt,
+ commitment = commitment
+ )
+ }
+
+ /**
+ * Creates a payload from an already-built [C2paManifest].
+ * Use this when you want to inspect manifest fields before attesting.
+ *
+ * ```kotlin
+ * val manifest = C2paManifest.build(filePath = file.absolutePath, producer = wallet)
+ * println("assetHash: ${manifest.assetHash}")
+ * println("manifestHash: ${manifest.manifestHash}")
+ * val payload = ImageHashPayload.fromManifest(manifest, account = wallet)
+ * ```
+ */
+ fun fromManifest(
+ manifest: C2paManifest,
+ account: String = "",
+ recipient: String = "",
+ expireAt: Long = 0L,
+ commitment: Commitment = Commitment.finalized
+ ): ImageHashPayload = ImageHashPayload(
+ type = "image_hash",
+ hash = manifest.manifestHash,
+ mime = manifest.mimeType,
+ name = manifest.filename,
+ manifest = manifest.toJson(),
+ c2paManifest = manifest,
+ timestamp = manifest.timestamp,
+ account = account,
+ recipient = recipient,
+ expireAt = expireAt,
+ commitment = commitment
+ )
+ }
+}
+
+// ── Wire types ────────────────────────────────────────────────────────────────
+
+/**
+ * JSON body sent to `POST api/provenance/attestImageHash`.
+ */
+@Serializable
+data class ImageHashRequest(
+ val type: String,
+ /** [C2paManifest.manifestHash] — stored on-chain. */
+ val hash: String,
+ val mime: String,
+ val name: String = "",
+ val timestamp: Long,
+ val account: String = "",
+ val recipient: String = "",
+ val expireAt: Long = 0L,
+ /** Full C2PA claim JSON — stored off-chain by backend for verification. */
+ val manifest: String = "",
+ /**
+ * Base64-encoded signed `createSchema` tx.
+ * `null` when schema already confirmed for this wallet — backend skips it.
+ */
+ val signedSchemaTx: String? = null,
+ /** Base64-encoded signed `createAttestation` tx. */
+ val signedAttestationTx: String
+)
+
+/**
+ * Response from `POST api/provenance/attestImageHash`.
+ */
+@Serializable
+data class ImageHashResponse(
+ val Status: String,
+ val Message: String,
+ val Signature: String = "",
+ /** On-chain Attestation PDA (Base58). */
+ val attestationId: String = ""
+)
+
+/**
+ * Returned to the SDK user after a successful attestation.
+ *
+ * Contains:
+ * - [response] — the on-chain result (attestation PDA, tx signature)
+ * - [manifest] — the C2PA manifest object (assetHash, manifestHash, filename, etc.)
+ * - [manifestFile] — sidecar `.c2pa.json` file saved on device (if [ManifestOption.SidecarFile] or [ManifestOption.Both])
+ * - [embeddedImageFile] — the image file with manifest embedded in metadata (if [ManifestOption.EmbedInImage] or [ManifestOption.Both])
+ *
+ * ```kotlin
+ * val pr = Provenance.attestImageHash(payload, ManifestOption.Both(filePath)).getOrThrow()
+ *
+ * // On-chain
+ * pr.response.attestationId // Solana Attestation PDA
+ * pr.response.Signature // tx signature
+ *
+ * // Manifest fields
+ * pr.manifest.assetHash // SHA-256 of raw image bytes
+ * pr.manifest.manifestHash // SHA-256 of claim JSON — what's on-chain
+ * pr.manifest.filename // "photo.png"
+ *
+ * // Sidecar file (ManifestOption.SidecarFile or Both)
+ * pr.manifestFile?.absolutePath // ".../provenance_manifests/photo.png.c2pa.json"
+ *
+ * // Embedded image (ManifestOption.EmbedInImage or Both)
+ * pr.embeddedImageFile?.absolutePath // the image file now carries the manifest in its metadata
+ * ```
+ */
+data class ProvenanceResult(
+ /** On-chain attestation result from the backend. */
+ val response: ImageHashResponse,
+ /** The C2PA manifest built from the image. */
+ val manifest: C2paManifest,
+ /**
+ * Sidecar `.c2pa.json` file saved on device.
+ * Non-null when [ManifestOption.SidecarFile] or [ManifestOption.Both] was used.
+ */
+ val manifestFile: java.io.File? = null,
+ /**
+ * The image file with the C2PA manifest embedded in its metadata.
+ * Non-null when [ManifestOption.EmbedInImage] or [ManifestOption.Both] was used.
+ * JPEG: embedded in XMP. PNG: embedded as a tEXt chunk.
+ */
+ val embeddedImageFile: java.io.File? = null
+)
+
+/**
+ * Per-image result emitted by [com.altude.provenance.Provenance.attestBatch].
+ *
+ * @param index Zero-based position in the original list passed to `attestBatch`.
+ * @param name Original filename — use to match back to your UI list.
+ * @param hash The [C2paManifest.manifestHash] stored on-chain.
+ * @param result Success carries [ProvenanceResult] (manifest + response); failure carries the error.
+ */
+data class AttestationResult(
+ val index: Int,
+ val name: String,
+ val hash: String,
+ val result: Result
+)
+
+
+
+
diff --git a/provenance/src/main/java/com/altude/provenance/data/ManifestOption.kt b/provenance/src/main/java/com/altude/provenance/data/ManifestOption.kt
new file mode 100644
index 0000000..a643d1c
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/data/ManifestOption.kt
@@ -0,0 +1,62 @@
+package com.altude.provenance.data
+
+/**
+ * Controls how the C2PA manifest is stored after a successful attestation.
+ *
+ * Pass as the `manifestOption` parameter to [com.altude.provenance.Provenance.attestImageHash]
+ * or [com.altude.provenance.Provenance.attestBatch].
+ *
+ * ```kotlin
+ * // Default — sidecar .c2pa.json file next to the image
+ * Provenance.attestImageHash(payload, ManifestOption.SidecarFile)
+ *
+ * // Embed the manifest directly into the image file (JPEG XMP / PNG tEXt)
+ * Provenance.attestImageHash(payload, ManifestOption.EmbedInImage(sourceFilePath))
+ *
+ * // Save sidecar AND embed in image
+ * Provenance.attestImageHash(payload, ManifestOption.Both(sourceFilePath))
+ *
+ * // Skip saving entirely (manifest still returned in ProvenanceResult.manifest)
+ * Provenance.attestImageHash(payload, ManifestOption.None)
+ * ```
+ */
+sealed class ManifestOption {
+
+ /**
+ * Save manifest as a `.c2pa.json` sidecar file in
+ * `filesDir/provenance_manifests/{filename}.c2pa.json`.
+ *
+ * The client receives the image + the sidecar file.
+ * Use this when you display/share images alongside a downloadable manifest.
+ */
+ object SidecarFile : ManifestOption()
+
+ /**
+ * Embed the manifest JSON directly into the image file:
+ * - **JPEG** → written to XMP metadata (`TAG_XMP`) via `ExifInterface`
+ * - **PNG** → written as a `tEXt` chunk with keyword `C2PA`
+ * - **Other formats** → falls back to [SidecarFile] silently
+ *
+ * The image file at [sourceFilePath] is modified in-place.
+ * The client only needs the single image file — no sidecar needed.
+ *
+ * @param sourceFilePath Absolute path to the original image file to embed into.
+ */
+ data class EmbedInImage(val sourceFilePath: String) : ManifestOption()
+
+ /**
+ * Save sidecar `.c2pa.json` file AND embed manifest into the image.
+ * Gives the client both options for verification.
+ *
+ * @param sourceFilePath Absolute path to the original image file to embed into.
+ */
+ data class Both(val sourceFilePath: String) : ManifestOption()
+
+ /**
+ * Do not save the manifest to disk.
+ * The manifest is still available in [com.altude.provenance.data.ProvenanceResult.manifest]
+ * for in-memory use.
+ */
+ object None : ManifestOption()
+}
+
diff --git a/provenance/src/main/java/com/altude/provenance/data/ProvenancePrefs.kt b/provenance/src/main/java/com/altude/provenance/data/ProvenancePrefs.kt
new file mode 100644
index 0000000..0456ee8
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/data/ProvenancePrefs.kt
@@ -0,0 +1,45 @@
+package com.altude.provenance.data
+
+import android.content.Context
+import androidx.core.content.edit
+import com.altude.core.service.StorageService
+
+/**
+ * Persists per-wallet schema-creation state across app restarts using SharedPreferences.
+ *
+ * Schema is a one-time on-chain setup per wallet — once confirmed it never needs
+ * to be created again. This class remembers that across app restarts so we never
+ * waste a transaction build or blockhash fetch for a schema that already exists.
+ *
+ * Uses [StorageService.getContext] — same context pattern as the rest of the SDK.
+ */
+internal object ProvenancePrefs {
+
+ private const val PREFS_NAME = "altude_provenance"
+ private const val SCHEMA_PREFIX = "schema_created_"
+
+ private fun prefs() = StorageService.getContext()
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ /**
+ * Returns `true` if [walletAddress] schema has already been confirmed on-chain.
+ * When true, `ensureSchema` in ProvenanceManager skips building the tx entirely.
+ */
+ fun isSchemaCreated(walletAddress: String): Boolean =
+ prefs().getBoolean("$SCHEMA_PREFIX$walletAddress", false)
+
+ /**
+ * Marks the schema as confirmed for [walletAddress].
+ * Must be called ONLY after the backend returns Status == "success".
+ */
+ fun markSchemaCreated(walletAddress: String) =
+ prefs().edit { putBoolean("$SCHEMA_PREFIX$walletAddress", true) }
+
+ /**
+ * Clears the schema flag for [walletAddress].
+ * Call on wallet switch or logout via Provenance.resetSession().
+ */
+ fun reset(walletAddress: String) =
+ prefs().edit { remove("$SCHEMA_PREFIX$walletAddress") }
+}
+
diff --git a/provenance/src/main/java/com/altude/provenance/data/ProvenanceRequests.kt b/provenance/src/main/java/com/altude/provenance/data/ProvenanceRequests.kt
new file mode 100644
index 0000000..f85dd6c
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/data/ProvenanceRequests.kt
@@ -0,0 +1 @@
+package com.altude.provenance.data
diff --git a/provenance/src/main/java/com/altude/provenance/data/TransactionResponse.kt b/provenance/src/main/java/com/altude/provenance/data/TransactionResponse.kt
new file mode 100644
index 0000000..b449368
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/data/TransactionResponse.kt
@@ -0,0 +1,15 @@
+package com.altude.provenance.data
+
+import com.altude.provenance.interfaces.ITransactionResponse
+import kotlinx.serialization.Serializable
+
+/**
+ * Generic transaction response from the Altude backend.
+ */
+@Serializable
+data class TransactionResponse(
+ override val Status: String = "",
+ override val Message: String = "",
+ override val Signature: String = ""
+) : ITransactionResponse
+
diff --git a/provenance/src/main/java/com/altude/provenance/interfaces/ITransactionResponse.kt b/provenance/src/main/java/com/altude/provenance/interfaces/ITransactionResponse.kt
new file mode 100644
index 0000000..c71e65c
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/interfaces/ITransactionResponse.kt
@@ -0,0 +1,11 @@
+package com.altude.provenance.interfaces
+
+/**
+ * Common contract for all transaction responses from the Altude backend.
+ */
+interface ITransactionResponse {
+ val Status: String
+ val Message: String
+ val Signature: String
+}
+
diff --git a/provenance/src/main/java/com/altude/provenance/interfaces/ProvenanceService.kt b/provenance/src/main/java/com/altude/provenance/interfaces/ProvenanceService.kt
new file mode 100644
index 0000000..ed48bfe
--- /dev/null
+++ b/provenance/src/main/java/com/altude/provenance/interfaces/ProvenanceService.kt
@@ -0,0 +1,23 @@
+package com.altude.provenance.interfaces
+
+import com.altude.provenance.data.ImageHashRequest
+import kotlinx.serialization.json.JsonElement
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+/**
+ * Retrofit service for the Provenance backend endpoints.
+ */
+interface ProvenanceService {
+
+ /**
+ * Submits both the signed `createSchema` and `createAttestation` transactions
+ * along with the structured image-hash metadata to the backend.
+ * The backend broadcasts both transactions and returns the attestation result.
+ */
+ @POST("api/provenance/attestImageHash")
+ fun attestImageHash(
+ @Body body: ImageHashRequest
+ ): Call
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index aa9102c..bb9a76d 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -26,3 +26,4 @@ include(":smart-account")
include(":nft")
include(":gasstation")
include(":core")
+include(":provenance")