From 458d02b9f9443fe25b76a4528b56c2eff8929f9f Mon Sep 17 00:00:00 2001 From: chen Date: Wed, 18 Mar 2026 16:16:38 +0800 Subject: [PATCH 1/4] add attestation program --- .../core/Programs/AttestationProgram.kt | 187 ++++++++++++++++++ .../main/java/com/altude/gasstation/Altude.kt | 177 +++++++++++++++++ .../com/altude/gasstation/GaslessManager.kt | 140 +++++++++++++ .../gasstation/data/AttestationOption.kt | 94 +++++++++ .../gasstation/data/AttestationResponse.kt | 20 ++ 5 files changed, 618 insertions(+) create mode 100644 core/src/main/java/com/altude/core/Programs/AttestationProgram.kt create mode 100644 gasstation/src/main/java/com/altude/gasstation/data/AttestationOption.kt create mode 100644 gasstation/src/main/java/com/altude/gasstation/data/AttestationResponse.kt 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/gasstation/src/main/java/com/altude/gasstation/Altude.kt b/gasstation/src/main/java/com/altude/gasstation/Altude.kt index eee5fee..df8ae4c 100644 --- a/gasstation/src/main/java/com/altude/gasstation/Altude.kt +++ b/gasstation/src/main/java/com/altude/gasstation/Altude.kt @@ -12,6 +12,11 @@ import com.altude.gasstation.data.GetAccountInfoOption import com.altude.gasstation.data.GetHistoryData import com.altude.gasstation.data.GetHistoryOption import com.altude.gasstation.data.SendOptions +import com.altude.gasstation.data.AttestationOption +import com.altude.gasstation.data.AttestationResponse +import com.altude.gasstation.data.CreateSchemaOption +import com.altude.gasstation.data.RevokeAttestationOption +import com.altude.core.Programs.AttestationProgram import com.altude.core.helper.Mnemonic import com.altude.gasstation.data.KeyPair import com.altude.gasstation.data.SolanaKeypair @@ -244,6 +249,178 @@ object Altude { } } + // ── Solana Attestation Service (SAS) ───────────────────────────────────── + + /** + * Creates a new Schema on-chain using the Solana Attestation Service. + * + * A Schema must exist before you can create attestations. + * The schema PDA is derived from (authority wallet, name) so the same + * schema is always at the same address for a given authority + name pair. + * + * Usage: + * ```kotlin + * Altude.createSchema( + * CreateSchemaOption( + * name = "kyc-v1", + * description = "KYC level attestation", + * fieldNames = listOf("level", "country", "verifiedAt"), + * isRevocable = true + * ) + * ).onSuccess { response -> + * val schemaId = response.attestationId // store this for attest() calls + * } + * ``` + * + * @return [AttestationResponse] containing the Schema PDA in [AttestationResponse.attestationId] + */ + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun createSchema( + option: CreateSchemaOption, + signer: TransactionSigner? = null + ): Result = withContext(Dispatchers.IO) { + try { + val result = GaslessManager.createSchema(option, signer) + if (result.isFailure) return@withContext Result.failure(result.exceptionOrNull()!!) + + val signedTransaction = result.getOrThrow() + val service = SdkConfig.createService(TransactionService::class.java) + val request = SendTransactionRequest(signedTransaction) + + val res = service.sendTransaction(request).await() + val txResponse = deCodeJson(res) + + // Derive the schema PDA locally so the caller can use it immediately + // (the server just relays the tx; PDA is deterministic) + val attester = SdkConfig.currentSigner?.publicKey + val schemaId = attester?.let { + AttestationProgram.deriveSchemaAddress(it, option.name).toBase58() + } ?: "" + + Result.success( + AttestationResponse( + Status = txResponse.Status, + Message = txResponse.Message, + Signature = txResponse.Signature, + attestationId = schemaId + ) + ) + } catch (e: Throwable) { + Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) + } + } + + /** + * Creates an on-chain attestation using the Solana Attestation Service. + * + * Requires a Schema to already exist (see [createSchema]). + * Biometric authentication is triggered internally. + * + * Usage: + * ```kotlin + * Altude.attest( + * AttestationOption( + * schemaId = "schema_pubkey_base58", + * recipient = "recipient_wallet_base58", + * data = """{"level":2,"country":"US"}""".encodeToByteArray(), + * expireAt = 0L // 0 = no expiry + * ) + * ).onSuccess { response -> + * val attestationId = response.attestationId // on-chain PDA address + * val signature = response.Signature + * }.onFailure { error -> + * // handle vault / network error + * } + * ``` + * + * @return [AttestationResponse] containing the Attestation PDA in [AttestationResponse.attestationId] + */ + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun attest( + option: AttestationOption, + signer: TransactionSigner? = null + ): Result = withContext(Dispatchers.IO) { + try { + val result = GaslessManager.attest(option, signer) + if (result.isFailure) return@withContext Result.failure(result.exceptionOrNull()!!) + + val signedTransaction = result.getOrThrow() + val service = SdkConfig.createService(TransactionService::class.java) + val request = SendTransactionRequest(signedTransaction) + + val res = service.sendTransaction(request).await() + val txResponse = deCodeJson(res) + + // Derive attestation PDA deterministically + val attester = SdkConfig.currentSigner?.publicKey + val schemaPda = foundation.metaplex.solanapublickeys.PublicKey(option.schemaId) + val recipientKey = if (option.recipient.isBlank()) attester + else foundation.metaplex.solanapublickeys.PublicKey(option.recipient) + val attestationId = if (attester != null && recipientKey != null) { + AttestationProgram.deriveAttestationAddress( + schema = schemaPda, + attester = attester, + recipient = recipientKey, + nonce = option.nonce + ).toBase58() + } else "" + + Result.success( + AttestationResponse( + Status = txResponse.Status, + Message = txResponse.Message, + Signature = txResponse.Signature, + attestationId = attestationId + ) + ) + } catch (e: Throwable) { + Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) + } + } + + /** + * Revokes an existing on-chain attestation. + * + * Only the original attester can revoke, and the schema must have [isRevocable = true]. + * Biometric authentication is triggered internally. + * + * Usage: + * ```kotlin + * Altude.revokeAttestation( + * RevokeAttestationOption(attestationId = "attestation_pda_base58") + * ).onSuccess { response -> + * // attestation is now marked revoked on-chain + * } + * ``` + */ + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun revokeAttestation( + option: RevokeAttestationOption, + signer: TransactionSigner? = null + ): Result = withContext(Dispatchers.IO) { + try { + val result = GaslessManager.revokeAttestation(option, signer) + if (result.isFailure) return@withContext Result.failure(result.exceptionOrNull()!!) + + val signedTransaction = result.getOrThrow() + val service = SdkConfig.createService(TransactionService::class.java) + val request = SendTransactionRequest(signedTransaction) + + val res = service.sendTransaction(request).await() + val txResponse = deCodeJson(res) + + Result.success( + AttestationResponse( + Status = txResponse.Status, + Message = txResponse.Message, + Signature = txResponse.Signature + ) + ) + } catch (e: Throwable) { + Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) + } + } + 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..6ee8d48 100644 --- a/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt +++ b/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt @@ -2,6 +2,7 @@ package com.altude.gasstation import android.util.Base64 import com.altude.core.Programs.AssociatedTokenAccountProgram +import com.altude.core.Programs.AttestationProgram import com.altude.core.Programs.SwapHelper import com.altude.core.Programs.TokenProgram import com.altude.core.api.SwapService @@ -15,6 +16,9 @@ import com.altude.core.data.QuoteResponse import com.altude.core.data.SwapInstructionRequest import com.altude.gasstation.data.CloseAccountOption import com.altude.gasstation.data.CreateAccountOption +import com.altude.gasstation.data.AttestationOption +import com.altude.gasstation.data.CreateSchemaOption +import com.altude.gasstation.data.RevokeAttestationOption import com.altude.gasstation.data.ISendOption import com.altude.gasstation.data.SendOptions import com.altude.core.helper.Mnemonic @@ -559,4 +563,140 @@ object GaslessManager { } + // ════════════════════════════════════════════════════════════════════════ + // Solana Attestation Service (SAS) + // ════════════════════════════════════════════════════════════════════════ + + /** + * Creates a new SAS Schema on-chain. + * The schema PDA is derived deterministically from (authority, name). + * + * Returns the serialized + partially-signed transaction string (Base64). + */ + suspend fun createSchema( + option: CreateSchemaOption, + signer: TransactionSigner? = null + ): Result = withContext(Dispatchers.IO) { + return@withContext try { + val signerToUse = resolveSigner(option.account, signer) + ensureBiometricAuth(signerToUse, "create-schema") + val authority = signerToUse.publicKey + + val instruction = AttestationProgram.createSchema( + authority = authority, + feePayer = feePayerPubKey, + name = option.name, + description = option.description, + fieldNames = option.fieldNames, + isRevocable = option.isRevocable + ) + + val blockhashInfo = rpc.getLatestBlockhash(commitment = option.commitment.name) + + val tx = AltudeTransactionBuilder() + .setFeePayer(feePayerPubKey) + .setRecentBlockHash(blockhashInfo.blockhash) + .addInstruction(instruction) + .setSigners(listOf(signerToUse)) + .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)) + } + } + + /** + * Creates an on-chain attestation under the given schema. + * + * The Attestation PDA is derived from (schema, attester, recipient, nonce). + * Returns the serialized + partially-signed transaction string (Base64). + */ + suspend fun attest( + option: AttestationOption, + signer: TransactionSigner? = null + ): Result = withContext(Dispatchers.IO) { + return@withContext try { + val signerToUse = resolveSigner(option.account, signer) + ensureBiometricAuth(signerToUse, "attest") + val attester = signerToUse.publicKey + val schemaPda = PublicKey(option.schemaId) + val recipient = if (option.recipient.isBlank()) attester + else PublicKey(option.recipient) + + val instruction = AttestationProgram.createAttestation( + attester = attester, + feePayer = feePayerPubKey, + schemaPda = schemaPda, + recipient = recipient, + attestationData = option.data, + expireAt = option.expireAt, + nonce = option.nonce + ) + + val blockhashInfo = rpc.getLatestBlockhash(commitment = option.commitment.name) + + val tx = AltudeTransactionBuilder() + .setFeePayer(feePayerPubKey) + .setRecentBlockHash(blockhashInfo.blockhash) + .addInstruction(instruction) + .setSigners(listOf(signerToUse)) + .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)) + } + } + + /** + * Revokes an existing on-chain attestation. + * + * The schema must have been created with [isRevocable = true]. + * Only the original attester can revoke. + * Returns the serialized + partially-signed transaction string (Base64). + */ + suspend fun revokeAttestation( + option: RevokeAttestationOption, + signer: TransactionSigner? = null + ): Result = withContext(Dispatchers.IO) { + return@withContext try { + val signerToUse = resolveSigner(option.account, signer) + ensureBiometricAuth(signerToUse, "revoke-attestation") + val attester = signerToUse.publicKey + val attestationPda = PublicKey(option.attestationId) + + val instruction = AttestationProgram.revokeAttestation( + attester = attester, + feePayer = feePayerPubKey, + attestationPda = attestationPda + ) + + val blockhashInfo = rpc.getLatestBlockhash(commitment = option.commitment.name) + + val tx = AltudeTransactionBuilder() + .setFeePayer(feePayerPubKey) + .setRecentBlockHash(blockhashInfo.blockhash) + .addInstruction(instruction) + .setSigners(listOf(signerToUse)) + .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)) + } + } + } \ 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 = "" +) + From f130c16a2d75f6e730275859280a069d7615e644 Mon Sep 17 00:00:00 2001 From: chen Date: Wed, 18 Mar 2026 16:54:01 +0800 Subject: [PATCH 2/4] add endpoint for attestation request --- .../com/altude/core/api/TransactionService.kt | 20 +++++++++++++ .../altude/core/data/AttestationRequest.kt | 28 +++++++++++++++++++ .../main/java/com/altude/gasstation/Altude.kt | 20 ++++++------- 3 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/com/altude/core/data/AttestationRequest.kt 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/gasstation/src/main/java/com/altude/gasstation/Altude.kt b/gasstation/src/main/java/com/altude/gasstation/Altude.kt index df8ae4c..8e73adf 100644 --- a/gasstation/src/main/java/com/altude/gasstation/Altude.kt +++ b/gasstation/src/main/java/com/altude/gasstation/Altude.kt @@ -21,8 +21,11 @@ import com.altude.core.helper.Mnemonic import com.altude.gasstation.data.KeyPair import com.altude.gasstation.data.SolanaKeypair import com.altude.core.service.StorageService +import com.altude.core.data.AttestRequest import com.altude.core.data.BatchTransactionRequest +import com.altude.core.data.CreateSchemaRequest import com.altude.core.data.QuoteResponse +import com.altude.core.data.RevokeAttestationRequest import com.altude.core.data.SendTransactionRequest import com.altude.core.data.SwapTransactionRequest import com.altude.gasstation.data.GetAccountResponse @@ -285,13 +288,10 @@ object Altude { val signedTransaction = result.getOrThrow() val service = SdkConfig.createService(TransactionService::class.java) - val request = SendTransactionRequest(signedTransaction) + val request = CreateSchemaRequest(signedTransaction) - val res = service.sendTransaction(request).await() + val res = service.createSchema(request).await() val txResponse = deCodeJson(res) - - // Derive the schema PDA locally so the caller can use it immediately - // (the server just relays the tx; PDA is deterministic) val attester = SdkConfig.currentSigner?.publicKey val schemaId = attester?.let { AttestationProgram.deriveSchemaAddress(it, option.name).toBase58() @@ -346,12 +346,10 @@ object Altude { val signedTransaction = result.getOrThrow() val service = SdkConfig.createService(TransactionService::class.java) - val request = SendTransactionRequest(signedTransaction) + val request = AttestRequest(signedTransaction) - val res = service.sendTransaction(request).await() + val res = service.attest(request).await() val txResponse = deCodeJson(res) - - // Derive attestation PDA deterministically val attester = SdkConfig.currentSigner?.publicKey val schemaPda = foundation.metaplex.solanapublickeys.PublicKey(option.schemaId) val recipientKey = if (option.recipient.isBlank()) attester @@ -404,9 +402,9 @@ object Altude { val signedTransaction = result.getOrThrow() val service = SdkConfig.createService(TransactionService::class.java) - val request = SendTransactionRequest(signedTransaction) + val request = RevokeAttestationRequest(signedTransaction) - val res = service.sendTransaction(request).await() + val res = service.revokeAttestation(request).await() val txResponse = deCodeJson(res) Result.success( From 2afaa279f9d50f15f2bd2a67b5c79b6272eec027 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 19 Mar 2026 09:24:28 +0800 Subject: [PATCH 3/4] add solana attestation --- .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/deploymentTargetSelector.xml | 18 -- .idea/gradle.xml | 1 + .idea/markdown.xml | 8 + .idea/misc.xml | 3 +- .../main/java/com/altude/gasstation/Altude.kt | 174 ------------------ .../com/altude/gasstation/GaslessManager.kt | 139 -------------- provenance/build.gradle.kts | 62 +++++++ provenance/consumer-rules.pro | 3 + provenance/proguard-rules.pro | 3 + .../java/com/altude/provenance/Provenance.kt | 111 +++++++++++ .../altude/provenance/ProvenanceManager.kt | 161 ++++++++++++++++ .../provenance/data/AttestationOption.kt | 6 + .../provenance/data/AttestationResponse.kt | 20 ++ .../provenance/data/ImageHashPayload.kt | 118 ++++++++++++ .../provenance/data/ProvenanceRequests.kt | 1 + .../provenance/data/TransactionResponse.kt | 15 ++ .../interfaces/ITransactionResponse.kt | 11 ++ .../interfaces/ProvenanceService.kt | 23 +++ settings.gradle.kts | 1 + 20 files changed, 552 insertions(+), 332 deletions(-) create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/markdown.xml create mode 100644 provenance/build.gradle.kts create mode 100644 provenance/consumer-rules.pro create mode 100644 provenance/proguard-rules.pro create mode 100644 provenance/src/main/java/com/altude/provenance/Provenance.kt create mode 100644 provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt create mode 100644 provenance/src/main/java/com/altude/provenance/data/AttestationOption.kt create mode 100644 provenance/src/main/java/com/altude/provenance/data/AttestationResponse.kt create mode 100644 provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt create mode 100644 provenance/src/main/java/com/altude/provenance/data/ProvenanceRequests.kt create mode 100644 provenance/src/main/java/com/altude/provenance/data/TransactionResponse.kt create mode 100644 provenance/src/main/java/com/altude/provenance/interfaces/ITransactionResponse.kt create mode 100644 provenance/src/main/java/com/altude/provenance/interfaces/ProvenanceService.kt 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/gasstation/src/main/java/com/altude/gasstation/Altude.kt b/gasstation/src/main/java/com/altude/gasstation/Altude.kt index 8e73adf..92dc9d7 100644 --- a/gasstation/src/main/java/com/altude/gasstation/Altude.kt +++ b/gasstation/src/main/java/com/altude/gasstation/Altude.kt @@ -12,20 +12,12 @@ import com.altude.gasstation.data.GetAccountInfoOption import com.altude.gasstation.data.GetHistoryData import com.altude.gasstation.data.GetHistoryOption import com.altude.gasstation.data.SendOptions -import com.altude.gasstation.data.AttestationOption -import com.altude.gasstation.data.AttestationResponse -import com.altude.gasstation.data.CreateSchemaOption -import com.altude.gasstation.data.RevokeAttestationOption -import com.altude.core.Programs.AttestationProgram import com.altude.core.helper.Mnemonic import com.altude.gasstation.data.KeyPair import com.altude.gasstation.data.SolanaKeypair import com.altude.core.service.StorageService -import com.altude.core.data.AttestRequest import com.altude.core.data.BatchTransactionRequest -import com.altude.core.data.CreateSchemaRequest import com.altude.core.data.QuoteResponse -import com.altude.core.data.RevokeAttestationRequest import com.altude.core.data.SendTransactionRequest import com.altude.core.data.SwapTransactionRequest import com.altude.gasstation.data.GetAccountResponse @@ -252,172 +244,6 @@ object Altude { } } - // ── Solana Attestation Service (SAS) ───────────────────────────────────── - - /** - * Creates a new Schema on-chain using the Solana Attestation Service. - * - * A Schema must exist before you can create attestations. - * The schema PDA is derived from (authority wallet, name) so the same - * schema is always at the same address for a given authority + name pair. - * - * Usage: - * ```kotlin - * Altude.createSchema( - * CreateSchemaOption( - * name = "kyc-v1", - * description = "KYC level attestation", - * fieldNames = listOf("level", "country", "verifiedAt"), - * isRevocable = true - * ) - * ).onSuccess { response -> - * val schemaId = response.attestationId // store this for attest() calls - * } - * ``` - * - * @return [AttestationResponse] containing the Schema PDA in [AttestationResponse.attestationId] - */ - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun createSchema( - option: CreateSchemaOption, - signer: TransactionSigner? = null - ): Result = withContext(Dispatchers.IO) { - try { - val result = GaslessManager.createSchema(option, signer) - if (result.isFailure) return@withContext Result.failure(result.exceptionOrNull()!!) - - val signedTransaction = result.getOrThrow() - val service = SdkConfig.createService(TransactionService::class.java) - val request = CreateSchemaRequest(signedTransaction) - - val res = service.createSchema(request).await() - val txResponse = deCodeJson(res) - val attester = SdkConfig.currentSigner?.publicKey - val schemaId = attester?.let { - AttestationProgram.deriveSchemaAddress(it, option.name).toBase58() - } ?: "" - - Result.success( - AttestationResponse( - Status = txResponse.Status, - Message = txResponse.Message, - Signature = txResponse.Signature, - attestationId = schemaId - ) - ) - } catch (e: Throwable) { - Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) - } - } - - /** - * Creates an on-chain attestation using the Solana Attestation Service. - * - * Requires a Schema to already exist (see [createSchema]). - * Biometric authentication is triggered internally. - * - * Usage: - * ```kotlin - * Altude.attest( - * AttestationOption( - * schemaId = "schema_pubkey_base58", - * recipient = "recipient_wallet_base58", - * data = """{"level":2,"country":"US"}""".encodeToByteArray(), - * expireAt = 0L // 0 = no expiry - * ) - * ).onSuccess { response -> - * val attestationId = response.attestationId // on-chain PDA address - * val signature = response.Signature - * }.onFailure { error -> - * // handle vault / network error - * } - * ``` - * - * @return [AttestationResponse] containing the Attestation PDA in [AttestationResponse.attestationId] - */ - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun attest( - option: AttestationOption, - signer: TransactionSigner? = null - ): Result = withContext(Dispatchers.IO) { - try { - val result = GaslessManager.attest(option, signer) - if (result.isFailure) return@withContext Result.failure(result.exceptionOrNull()!!) - - val signedTransaction = result.getOrThrow() - val service = SdkConfig.createService(TransactionService::class.java) - val request = AttestRequest(signedTransaction) - - val res = service.attest(request).await() - val txResponse = deCodeJson(res) - val attester = SdkConfig.currentSigner?.publicKey - val schemaPda = foundation.metaplex.solanapublickeys.PublicKey(option.schemaId) - val recipientKey = if (option.recipient.isBlank()) attester - else foundation.metaplex.solanapublickeys.PublicKey(option.recipient) - val attestationId = if (attester != null && recipientKey != null) { - AttestationProgram.deriveAttestationAddress( - schema = schemaPda, - attester = attester, - recipient = recipientKey, - nonce = option.nonce - ).toBase58() - } else "" - - Result.success( - AttestationResponse( - Status = txResponse.Status, - Message = txResponse.Message, - Signature = txResponse.Signature, - attestationId = attestationId - ) - ) - } catch (e: Throwable) { - Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) - } - } - - /** - * Revokes an existing on-chain attestation. - * - * Only the original attester can revoke, and the schema must have [isRevocable = true]. - * Biometric authentication is triggered internally. - * - * Usage: - * ```kotlin - * Altude.revokeAttestation( - * RevokeAttestationOption(attestationId = "attestation_pda_base58") - * ).onSuccess { response -> - * // attestation is now marked revoked on-chain - * } - * ``` - */ - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun revokeAttestation( - option: RevokeAttestationOption, - signer: TransactionSigner? = null - ): Result = withContext(Dispatchers.IO) { - try { - val result = GaslessManager.revokeAttestation(option, signer) - if (result.isFailure) return@withContext Result.failure(result.exceptionOrNull()!!) - - val signedTransaction = result.getOrThrow() - val service = SdkConfig.createService(TransactionService::class.java) - val request = RevokeAttestationRequest(signedTransaction) - - val res = service.revokeAttestation(request).await() - val txResponse = deCodeJson(res) - - Result.success( - AttestationResponse( - Status = txResponse.Status, - Message = txResponse.Message, - Signature = txResponse.Signature - ) - ) - } catch (e: Throwable) { - Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) - } - } suspend fun generateKeyPair(): SolanaKeypair { val keypair = KeyPair.generate() diff --git a/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt b/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt index 6ee8d48..b1bc1b2 100644 --- a/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt +++ b/gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt @@ -2,7 +2,6 @@ package com.altude.gasstation import android.util.Base64 import com.altude.core.Programs.AssociatedTokenAccountProgram -import com.altude.core.Programs.AttestationProgram import com.altude.core.Programs.SwapHelper import com.altude.core.Programs.TokenProgram import com.altude.core.api.SwapService @@ -16,9 +15,6 @@ import com.altude.core.data.QuoteResponse import com.altude.core.data.SwapInstructionRequest import com.altude.gasstation.data.CloseAccountOption import com.altude.gasstation.data.CreateAccountOption -import com.altude.gasstation.data.AttestationOption -import com.altude.gasstation.data.CreateSchemaOption -import com.altude.gasstation.data.RevokeAttestationOption import com.altude.gasstation.data.ISendOption import com.altude.gasstation.data.SendOptions import com.altude.core.helper.Mnemonic @@ -563,140 +559,5 @@ object GaslessManager { } - // ════════════════════════════════════════════════════════════════════════ - // Solana Attestation Service (SAS) - // ════════════════════════════════════════════════════════════════════════ - - /** - * Creates a new SAS Schema on-chain. - * The schema PDA is derived deterministically from (authority, name). - * - * Returns the serialized + partially-signed transaction string (Base64). - */ - suspend fun createSchema( - option: CreateSchemaOption, - signer: TransactionSigner? = null - ): Result = withContext(Dispatchers.IO) { - return@withContext try { - val signerToUse = resolveSigner(option.account, signer) - ensureBiometricAuth(signerToUse, "create-schema") - val authority = signerToUse.publicKey - - val instruction = AttestationProgram.createSchema( - authority = authority, - feePayer = feePayerPubKey, - name = option.name, - description = option.description, - fieldNames = option.fieldNames, - isRevocable = option.isRevocable - ) - - val blockhashInfo = rpc.getLatestBlockhash(commitment = option.commitment.name) - - val tx = AltudeTransactionBuilder() - .setFeePayer(feePayerPubKey) - .setRecentBlockHash(blockhashInfo.blockhash) - .addInstruction(instruction) - .setSigners(listOf(signerToUse)) - .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)) - } - } - - /** - * Creates an on-chain attestation under the given schema. - * - * The Attestation PDA is derived from (schema, attester, recipient, nonce). - * Returns the serialized + partially-signed transaction string (Base64). - */ - suspend fun attest( - option: AttestationOption, - signer: TransactionSigner? = null - ): Result = withContext(Dispatchers.IO) { - return@withContext try { - val signerToUse = resolveSigner(option.account, signer) - ensureBiometricAuth(signerToUse, "attest") - val attester = signerToUse.publicKey - val schemaPda = PublicKey(option.schemaId) - val recipient = if (option.recipient.isBlank()) attester - else PublicKey(option.recipient) - - val instruction = AttestationProgram.createAttestation( - attester = attester, - feePayer = feePayerPubKey, - schemaPda = schemaPda, - recipient = recipient, - attestationData = option.data, - expireAt = option.expireAt, - nonce = option.nonce - ) - - val blockhashInfo = rpc.getLatestBlockhash(commitment = option.commitment.name) - - val tx = AltudeTransactionBuilder() - .setFeePayer(feePayerPubKey) - .setRecentBlockHash(blockhashInfo.blockhash) - .addInstruction(instruction) - .setSigners(listOf(signerToUse)) - .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)) - } - } - - /** - * Revokes an existing on-chain attestation. - * - * The schema must have been created with [isRevocable = true]. - * Only the original attester can revoke. - * Returns the serialized + partially-signed transaction string (Base64). - */ - suspend fun revokeAttestation( - option: RevokeAttestationOption, - signer: TransactionSigner? = null - ): Result = withContext(Dispatchers.IO) { - return@withContext try { - val signerToUse = resolveSigner(option.account, signer) - ensureBiometricAuth(signerToUse, "revoke-attestation") - val attester = signerToUse.publicKey - val attestationPda = PublicKey(option.attestationId) - - val instruction = AttestationProgram.revokeAttestation( - attester = attester, - feePayer = feePayerPubKey, - attestationPda = attestationPda - ) - - val blockhashInfo = rpc.getLatestBlockhash(commitment = option.commitment.name) - - val tx = AltudeTransactionBuilder() - .setFeePayer(feePayerPubKey) - .setRecentBlockHash(blockhashInfo.blockhash) - .addInstruction(instruction) - .setSigners(listOf(signerToUse)) - .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)) - } - } } \ No newline at end of file diff --git a/provenance/build.gradle.kts b/provenance/build.gradle.kts new file mode 100644 index 0000000..643262c --- /dev/null +++ b/provenance/build.gradle.kts @@ -0,0 +1,62 @@ +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") + } +} + 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..deb5295 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/Provenance.kt @@ -0,0 +1,111 @@ +package com.altude.provenance + +import com.altude.core.config.SdkConfig +import com.altude.provenance.data.ImageHashPayload +import com.altude.provenance.data.ImageHashRequest +import com.altude.provenance.data.ImageHashResponse +import com.altude.provenance.interfaces.ProvenanceService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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. + * Internally handles schema creation and attestation in a single call. + * + * Usage: + * ```kotlin + * // Option A — let the SDK compute the SHA-256 hash for you + * val payload = ImageHashPayload.from( + * imageBytes = pngByteArray, + * mime = "image/png", + * name = "photo.png" + * ) + * val result = Provenance.attestImageHash(payload) + * val attestationId = result.getOrThrow().attestationId + * + * // Option B — supply a pre-computed hash + * val payload = ImageHashPayload(hash = myHexHash, mime = "image/jpeg") + * val result = Provenance.attestImageHash(payload) + * ``` + */ +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) + + // ── Image-hash attestation ──────────────────────────────────────────────── + + /** + * Attests an image by its SHA-256 hash on-chain. + * + * Internally this call: + * 1. Builds and signs a `createSchema` transaction (idempotent — same authority + name + * always resolves to the same on-chain Schema PDA). + * 2. Builds and signs a `createAttestation` transaction with the structured + * image-hash JSON payload. + * 3. Sends both signed transactions to the backend for broadcasting. + * + * @return [ImageHashResponse] containing the transaction [ImageHashResponse.Signature] + * and the on-chain Attestation PDA in [ImageHashResponse.attestationId]. + */ + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun attestImageHash( + payload: ImageHashPayload + ): Result = withContext(Dispatchers.IO) { + try { + // 1. Build & sign createSchema tx + val schemaResult = ProvenanceManager.createSchema(payload) + if (schemaResult.isFailure) + return@withContext Result.failure(schemaResult.exceptionOrNull()!!) + + // 2. Derive the Schema PDA so we can pass it to createAttestation + val schemaPda = ProvenanceManager.deriveSchemaAddress(payload.account) + + // 3. Build & sign createAttestation tx + val attestResult = ProvenanceManager.attest(payload, schemaPda) + if (attestResult.isFailure) + return@withContext Result.failure(attestResult.exceptionOrNull()!!) + + // 4. Send both signed transactions to the backend + 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, + signedSchemaTx = schemaResult.getOrThrow(), + signedAttestationTx = attestResult.getOrThrow() + ) + + val res = service().attestImageHash(request).await() + val response = decodeJson(res) + + Result.success(response) + } catch (e: Throwable) { + Result.failure(Exception(e.message ?: e.javaClass.simpleName, e)) + } + } + + // ── 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..a828c6e --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt @@ -0,0 +1,161 @@ +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 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" + + 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]. + * Keeps [AttestationProgram] fully hidden from callers outside this class. + */ + internal suspend fun deriveSchemaAddress(account: String = ""): PublicKey { + val keypair = getKeyPair(account) + return AttestationProgram.deriveSchemaAddress( + authority = keypair.publicKey, + name = SCHEMA_NAME + ) + } + + // ── Schema ──────────────────────────────────────────────────────────────── + + /** + * Builds and signs a `createSchema` transaction for the fixed [SCHEMA_NAME] schema. + * Returns the Base64-encoded serialized transaction. + */ + suspend fun createSchema(payload: ImageHashPayload): Result = + withContext(Dispatchers.IO) { + try { + val keypair = getKeyPair(payload.account) + val authority = keypair.publicKey + val hotSigner = HotSigner(keypair) + + val instruction = AttestationProgram.createSchema( + authority = authority, + 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 = 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)) + } + } + + // ── 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/ImageHashPayload.kt b/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt new file mode 100644 index 0000000..9991481 --- /dev/null +++ b/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt @@ -0,0 +1,118 @@ +package com.altude.provenance.data + +import kotlinx.serialization.Serializable +import java.security.MessageDigest + +/** + * Structured payload for an image-hash attestation. + * + * The `hash` field is computed automatically from the raw bytes when you call + * [ImageHashPayload.from]; you never need to compute it yourself. + * + * @param type Always `"image_hash"`. + * @param hash Hex-encoded SHA-256 digest of the image bytes. + * @param mime MIME type of the image (e.g. `"image/png"`). + * @param name Optional human-readable file name (e.g. `"my-image.png"`). + * @param timestamp Unix epoch (seconds) when the attestation was created. + * @param account Attester wallet address (Base58). Blank = stored default wallet. + * @param recipient The account being attested (Base58 pubkey). Defaults to attester if blank. + * @param expireAt Unix timestamp (seconds) when the attestation expires. 0 = no expiry. + * @param commitment Finality commitment to wait for. + */ +data class ImageHashPayload( + val type: String = "image_hash", + val hash: String, + val mime: String = "image/png", + val name: String = "", + val timestamp: Long = System.currentTimeMillis() / 1000, + // SDK routing fields (not serialised in the JSON body) + val account: String = "", + val recipient: String = "", + val expireAt: Long = 0L, + val commitment: Commitment = Commitment.finalized +) { + companion object { + /** + * Convenience factory — computes the SHA-256 hash of [imageBytes] for you. + * + * ```kotlin + * val payload = ImageHashPayload.from( + * imageBytes = pngBytes, + * mime = "image/png", + * name = "my-photo.png" + * ) + * val result = Provenance.attestImageHash(payload) + * ``` + */ + fun from( + imageBytes: ByteArray, + mime: String = "image/png", + name: String = "", + account: String = "", + recipient: String = "", + expireAt: Long = 0L, + commitment: Commitment = Commitment.finalized + ): ImageHashPayload { + val hashBytes = MessageDigest.getInstance("SHA-256").digest(imageBytes) + val hashHex = hashBytes.joinToString("") { "%02x".format(it) } + return ImageHashPayload( + hash = hashHex, + mime = mime, + name = name, + timestamp = System.currentTimeMillis() / 1000, + account = account, + recipient = recipient, + expireAt = expireAt, + commitment = commitment + ) + } + } +} + +// ── Wire types (serialised to/from backend) ─────────────────────────────────── + +/** + * The JSON body sent to `POST api/provenance/attestImageHash`. + * + * Carries both the image-hash metadata and the two client-signed Solana + * transactions (createSchema + createAttestation) for the backend to broadcast. + */ +@Serializable +data class ImageHashRequest( + /** Always `"image_hash"`. */ + val type: String, + /** Hex-encoded SHA-256 of the image. */ + val hash: String, + /** MIME type (e.g. `"image/png"`). */ + val mime: String, + /** Optional file name. */ + val name: String = "", + /** Unix epoch (seconds). */ + val timestamp: Long, + /** Attester wallet (Base58). Empty = server uses the key associated with the API key. */ + val account: String = "", + /** Recipient wallet (Base58). Empty = attester self-attests. */ + val recipient: String = "", + /** Expiry unix timestamp (seconds). 0 = no expiry. */ + val expireAt: Long = 0L, + /** Base64-encoded, partially-signed `createSchema` transaction. */ + val signedSchemaTx: String, + /** Base64-encoded, partially-signed `createAttestation` transaction. */ + val signedAttestationTx: String +) + +/** + * Response from `POST api/provenance/attestImageHash`. + */ +@Serializable +data class ImageHashResponse( + /** `"success"` or `"error"`. */ + val Status: String, + /** Human-readable message. */ + val Message: String, + /** Solana transaction signature. */ + val Signature: String = "", + /** On-chain Attestation PDA (Base58). */ + val attestationId: String = "" +) + 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") From 016a94d206b6356cad416bfa01360ade23ba5313 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 19 Mar 2026 15:15:05 +0800 Subject: [PATCH 4/4] add c2pa and provenance module. --- .../com/altude/core/service/StorageService.kt | 7 + provenance/.gitignore | 1 + provenance/build.gradle.kts | 3 + .../java/com/altude/provenance/Provenance.kt | 267 ++++++++++++--- .../altude/provenance/ProvenanceManager.kt | 74 +++- .../altude/provenance/data/C2paManifest.kt | 264 ++++++++++++++ .../provenance/data/ImageHashPayload.kt | 324 ++++++++++++++---- .../altude/provenance/data/ManifestOption.kt | 62 ++++ .../altude/provenance/data/ProvenancePrefs.kt | 45 +++ 9 files changed, 920 insertions(+), 127 deletions(-) create mode 100644 provenance/.gitignore create mode 100644 provenance/src/main/java/com/altude/provenance/data/C2paManifest.kt create mode 100644 provenance/src/main/java/com/altude/provenance/data/ManifestOption.kt create mode 100644 provenance/src/main/java/com/altude/provenance/data/ProvenancePrefs.kt 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/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 index 643262c..03b6993 100644 --- a/provenance/build.gradle.kts +++ b/provenance/build.gradle.kts @@ -58,5 +58,8 @@ dependencies { 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/src/main/java/com/altude/provenance/Provenance.kt b/provenance/src/main/java/com/altude/provenance/Provenance.kt index deb5295..ef0cc4b 100644 --- a/provenance/src/main/java/com/altude/provenance/Provenance.kt +++ b/provenance/src/main/java/com/altude/provenance/Provenance.kt @@ -1,12 +1,21 @@ 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 @@ -18,22 +27,45 @@ import retrofit2.await * Public entry point for the Provenance module. * * Attests image data on-chain using the Solana Attestation Service (SAS) program. - * Internally handles schema creation and attestation in a single call. + * Schema is created once per wallet and cached — subsequent calls skip it entirely. * - * Usage: + * ── 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 - * // Option A — let the SDK compute the SHA-256 hash for you - * val payload = ImageHashPayload.from( - * imageBytes = pngByteArray, - * mime = "image/png", - * name = "photo.png" - * ) * val result = Provenance.attestImageHash(payload) - * val attestationId = result.getOrThrow().attestationId + * val id = result.getOrThrow().attestationId + * ``` * - * // Option B — supply a pre-computed hash - * val payload = ImageHashPayload(hash = myHexHash, mime = "image/jpeg") - * val result = Provenance.attestImageHash(payload) + * 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 { @@ -41,69 +73,216 @@ object Provenance { @OptIn(ExperimentalSerializationApi::class) private val json = Json { ignoreUnknownKeys = true - isLenient = true - encodeDefaults = true - explicitNulls = false + isLenient = true + encodeDefaults = true + explicitNulls = false } - private fun service(): ProvenanceService = SdkConfig.createService(ProvenanceService::class.java) + private fun service(): ProvenanceService = + SdkConfig.createService(ProvenanceService::class.java) - // ── Image-hash attestation ──────────────────────────────────────────────── + // ── Single image ────────────────────────────────────────────────────────── /** - * Attests an image by its SHA-256 hash on-chain. + * Attests a single image on-chain. * - * Internally this call: - * 1. Builds and signs a `createSchema` transaction (idempotent — same authority + name - * always resolves to the same on-chain Schema PDA). - * 2. Builds and signs a `createAttestation` transaction with the structured - * image-hash JSON payload. - * 3. Sends both signed transactions to the backend for broadcasting. + * @param payload Built via [ImageHashPayload.create]. + * @param manifestOption How to save the manifest after attestation. Default: [ManifestOption.SidecarFile]. * - * @return [ImageHashResponse] containing the transaction [ImageHashResponse.Signature] - * and the on-chain Attestation PDA in [ImageHashResponse.attestationId]. + * 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 - ): Result = withContext(Dispatchers.IO) { + payload: ImageHashPayload, + manifestOption: ManifestOption = ManifestOption.SidecarFile + ): Result = withContext(Dispatchers.IO) { try { - // 1. Build & sign createSchema tx - val schemaResult = ProvenanceManager.createSchema(payload) + // 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 the Schema PDA so we can pass it to createAttestation + // 2. Derive Schema PDA val schemaPda = ProvenanceManager.deriveSchemaAddress(payload.account) - // 3. Build & sign createAttestation tx + // 3. Sign attestation tx offline val attestResult = ProvenanceManager.attest(payload, schemaPda) if (attestResult.isFailure) return@withContext Result.failure(attestResult.exceptionOrNull()!!) - // 4. Send both signed transactions to the backend + // 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, - signedSchemaTx = schemaResult.getOrThrow(), + 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) - Result.success(response) + // 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 = diff --git a/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt b/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt index a828c6e..d2b7ae9 100644 --- a/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt +++ b/provenance/src/main/java/com/altude/provenance/ProvenanceManager.kt @@ -9,6 +9,7 @@ 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 @@ -28,6 +29,13 @@ 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) @@ -47,49 +55,65 @@ internal object ProvenanceManager { /** * 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) - return AttestationProgram.deriveSchemaAddress( - authority = keypair.publicKey, - name = SCHEMA_NAME - ) + val keypair = getKeyPair(account) + val walletKey = keypair.publicKey.toBase58() + return schemaPdaCache.getOrPut(walletKey) { + AttestationProgram.deriveSchemaAddress( + authority = keypair.publicKey, + name = SCHEMA_NAME + ) + } } - // ── Schema ──────────────────────────────────────────────────────────────── + // ── Schema — once per wallet ────────────────────────────────────────────── /** - * Builds and signs a `createSchema` transaction for the fixed [SCHEMA_NAME] schema. - * Returns the Base64-encoded serialized transaction. + * 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 createSchema(payload: ImageHashPayload): Result = + suspend fun ensureSchema(account: String, commitment: String): Result = withContext(Dispatchers.IO) { try { - val keypair = getKeyPair(payload.account) - val authority = keypair.publicKey - val hotSigner = HotSigner(keypair) + 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 = authority, + 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 = payload.commitment.name - ).blockhash - + 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 @@ -100,6 +124,18 @@ internal object ProvenanceManager { } } + /** + * 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 ─────────────────────────────────────────────────────────── /** 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 index 9991481..ef7ac11 100644 --- a/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt +++ b/provenance/src/main/java/com/altude/provenance/data/ImageHashPayload.kt @@ -1,103 +1,238 @@ package com.altude.provenance.data +import android.content.ContentResolver +import android.net.Uri import kotlinx.serialization.Serializable -import java.security.MessageDigest /** * Structured payload for an image-hash attestation. * - * The `hash` field is computed automatically from the raw bytes when you call - * [ImageHashPayload.from]; you never need to compute it yourself. + * 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) + * ``` * - * @param type Always `"image_hash"`. - * @param hash Hex-encoded SHA-256 digest of the image bytes. - * @param mime MIME type of the image (e.g. `"image/png"`). - * @param name Optional human-readable file name (e.g. `"my-image.png"`). - * @param timestamp Unix epoch (seconds) when the attestation was created. - * @param account Attester wallet address (Base58). Blank = stored default wallet. - * @param recipient The account being attested (Base58 pubkey). Defaults to attester if blank. - * @param expireAt Unix timestamp (seconds) when the attestation expires. 0 = no expiry. - * @param commitment Finality commitment to wait for. + * 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. */ -data class ImageHashPayload( - val type: String = "image_hash", +@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, - val mime: String = "image/png", - val name: String = "", - val timestamp: Long = System.currentTimeMillis() / 1000, - // SDK routing fields (not serialised in the JSON body) - val account: String = "", - val recipient: String = "", - val expireAt: Long = 0L, - val commitment: Commitment = Commitment.finalized + /** 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 + ) + } + /** - * Convenience factory — computes the SHA-256 hash of [imageBytes] for you. + * 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 - * val payload = ImageHashPayload.from( - * imageBytes = pngBytes, - * mime = "image/png", - * name = "my-photo.png" + * // 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 from( - imageBytes: ByteArray, - mime: String = "image/png", - name: String = "", - account: String = "", - recipient: String = "", - expireAt: Long = 0L, - commitment: Commitment = Commitment.finalized + 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 hashBytes = MessageDigest.getInstance("SHA-256").digest(imageBytes) - val hashHex = hashBytes.joinToString("") { "%02x".format(it) } + 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( - hash = hashHex, - mime = mime, - name = name, - timestamp = System.currentTimeMillis() / 1000, - account = account, - recipient = recipient, - expireAt = expireAt, - commitment = commitment + 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 (serialised to/from backend) ─────────────────────────────────── +// ── Wire types ──────────────────────────────────────────────────────────────── /** - * The JSON body sent to `POST api/provenance/attestImageHash`. - * - * Carries both the image-hash metadata and the two client-signed Solana - * transactions (createSchema + createAttestation) for the backend to broadcast. + * JSON body sent to `POST api/provenance/attestImageHash`. */ @Serializable data class ImageHashRequest( - /** Always `"image_hash"`. */ val type: String, - /** Hex-encoded SHA-256 of the image. */ + /** [C2paManifest.manifestHash] — stored on-chain. */ val hash: String, - /** MIME type (e.g. `"image/png"`). */ val mime: String, - /** Optional file name. */ val name: String = "", - /** Unix epoch (seconds). */ val timestamp: Long, - /** Attester wallet (Base58). Empty = server uses the key associated with the API key. */ val account: String = "", - /** Recipient wallet (Base58). Empty = attester self-attests. */ val recipient: String = "", - /** Expiry unix timestamp (seconds). 0 = no expiry. */ val expireAt: Long = 0L, - /** Base64-encoded, partially-signed `createSchema` transaction. */ - val signedSchemaTx: String, - /** Base64-encoded, partially-signed `createAttestation` transaction. */ + /** 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 ) @@ -106,13 +241,74 @@ data class ImageHashRequest( */ @Serializable data class ImageHashResponse( - /** `"success"` or `"error"`. */ val Status: String, - /** Human-readable message. */ val Message: String, - /** Solana transaction signature. */ 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") } +} +