Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .yarn/install-state.gz
Binary file not shown.
681 changes: 63 additions & 618 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ version = "25.3.1"

[workspace.dependencies.stellar-access]
git = "https://github.com/OpenZeppelin/stellar-contracts"
tag = "v0.5.1"
tag = "v0.7.0"

[workspace.dependencies.stellar-default-impl-macro]
git = "https://github.com/OpenZeppelin/stellar-contracts"
Expand All @@ -26,7 +26,7 @@ tag = "v0.3.0"

[workspace.dependencies.stellar-macros]
git = "https://github.com/OpenZeppelin/stellar-contracts"
tag = "v0.5.1"
tag = "v0.7.0"

[workspace.dependencies.stellar-non-fungible]
git = "https://github.com/OpenZeppelin/stellar-contracts"
Expand All @@ -42,7 +42,7 @@ tag = "v0.3.0"

[workspace.dependencies.stellar-tokens]
git = "https://github.com/OpenZeppelin/stellar-contracts"
tag = "v0.5.1"
tag = "v0.7.0"

[profile.release]
opt-level = "z"
Expand Down
1 change: 1 addition & 0 deletions contracts/prompt-hash/gas-baselines.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"operations": {
"constructor": { "cpu": 0, "mem": 0 },
"create_prompt": { "cpu": 0, "mem": 0 },
"create_prompt_batch": { "cpu": 0, "mem": 0 },
"set_prompt_sale_status": { "cpu": 0, "mem": 0 },
"set_prompt_max_supply": { "cpu": 0, "mem": 0 },
"update_prompt_price": { "cpu": 0, "mem": 0 },
Expand Down
94 changes: 91 additions & 3 deletions contracts/prompt-hash/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ use super::events::Events;
use super::storage::Storage;
use super::types::{
Bundle, BundlePurchase, ClassificationOverride, DataKey, Error, ListingConfig,
PriceHistoryEntry, Prompt, PromptEncryptedPayload, PromptHashTrait, Purchase, ReferralCode,
Settlement, Split, Stake, Subscription, SubscriptionConfig, ALL_CLASSIFICATIONS,
MAX_BUNDLE_DESC_LEN, MAX_BUNDLE_ITEMS, MAX_BUNDLE_TITLE_LEN, VALID_DISCLOSURE_FLAGS,
PriceHistoryEntry, Prompt, PromptEncryptedPayload, PromptHashTrait, PromptInput, Purchase,
ReferralCode, Settlement, Split, Stake, Subscription, SubscriptionConfig,
Discount, ALL_CLASSIFICATIONS, MAX_BUNDLE_DESC_LEN, MAX_BUNDLE_ITEMS, MAX_BUNDLE_TITLE_LEN,
VALID_DISCLOSURE_FLAGS,
};
use soroban_sdk::{contract, contractimpl, token, Address, Bytes, BytesN, Env, String, Vec};
use stellar_access::ownable::{self as ownable, Ownable};
Expand Down Expand Up @@ -166,6 +167,93 @@ impl PromptHashTrait for PromptHashContract {
Ok(prompt_id)
}

fn create_prompt_batch(
env: Env,
creator: Address,
prompts: Vec<PromptInput>,
) -> Result<Vec<u128>, Error> {
creator.require_auth();
Storage::require_no_reentrancy(&env)?;
ensure(!Storage::is_paused(&env), Error::ContractIsPaused)?;
ensure(!prompts.is_empty(), Error::InvalidPrice)?;

let mut ids = Vec::new(&env);
for input in prompts.iter() {
validate_prompt_fields(
&input.image_url,
&input.title,
&input.category,
&input.preview_text,
&input.encrypted_prompt,
&input.encryption_iv,
&input.wrapped_key,
input.listing.price,
)?;

validate_token_contract(&env, &input.listing.asset)?;

if input.listing.expires_at != 0 {
ensure(
input.listing.expires_at > env.ledger().timestamp(),
Error::InvalidPrice,
)?;
}

validate_splits(&env, &input.listing.splits)?;

let classification = String::from_str(&env, "general");
let safety_flags: Vec<String> = Vec::new(&env);

let prompt_id = Storage::get_prompt_counter(&env);
let prompt = Prompt {
id: prompt_id,
creator: creator.clone(),
image_url: input.image_url.clone(),
title: input.title.clone(),
category: input.category.clone(),
preview_text: input.preview_text.clone(),
encrypted_prompt: input.encrypted_prompt.clone(),
encryption_iv: input.encryption_iv.clone(),
wrapped_key: input.wrapped_key.clone(),
content_hash: input.content_hash.clone(),
price_stroops: input.listing.price,
asset: input.listing.asset.clone(),
active: true,
sales_count: 0,
max_supply: 0,
expires_at: input.listing.expires_at,
splits: input.listing.splits.clone(),
classification,
safety_flags,
encryption_version: 1,
};

Storage::save_prompt(&env, &prompt)?;
Storage::set_encryption_version_counter(&env, prompt_id, 1);
Storage::add_prompt_to_creator(&env, &creator, prompt_id);
Storage::add_price_history_entry(
&env,
prompt_id,
&PriceHistoryEntry {
previous_price: 0,
new_price: input.listing.price,
changed_at: env.ledger().timestamp(),
seq: 1,
},
);
Events::emit_prompt_created(
&env,
prompt_id,
creator.clone(),
input.listing.price,
input.listing.asset.clone(),
);
ids.push_back(prompt_id);
}

Ok(ids)
}

fn set_prompt_sale_status(
env: Env,
creator: Address,
Expand Down
33 changes: 1 addition & 32 deletions contracts/prompt-hash/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use super::types::{
Prompt, PromptEncryptedPayload, Purchase, ReferralCode, Settlement, Stake, Subscription,
SubscriptionConfig,
};
use soroban_sdk::String;
use soroban_sdk::{token, Address, BytesN, Env, Vec};

pub const DAY_IN_LEDGERS: u32 = 17280;
Expand Down Expand Up @@ -607,38 +608,6 @@ impl Storage {
addr
}

// ─── #272: Prompt Bundles ──────────────────────────────────────────────

pub fn get_bundle_counter(env: &Env) -> u128 {
let key = DataKey::BundleCounter;
let count = env.storage().persistent().get(&key).unwrap_or(0);
if env.storage().persistent().has(&key) {
Self::extend_key_ttl(env, &key);
}
count
}

pub fn save_bundle(env: &Env, bundle: &Bundle) -> Result<(), Error> {
let key = DataKey::Bundle(bundle.id);
env.storage().persistent().set(&key, bundle);
Self::extend_key_ttl(env, &key);

let counter_key = DataKey::BundleCounter;
let next_id = bundle.id.checked_add(1).ok_or(Error::ArithmeticOverflow)?;
env.storage().persistent().set(&counter_key, &next_id);
Self::extend_key_ttl(env, &counter_key);
Ok(())
}

pub fn get_bundle(env: &Env, bundle_id: u128) -> Option<Bundle> {
let key = DataKey::Bundle(bundle_id);
let bundle = env.storage().persistent().get(&key);
if env.storage().persistent().has(&key) {
Self::extend_key_ttl(env, &key);
}
bundle
}

// ─── Promotional Pricing ──────────────────────────────────────────────

pub fn set_active_promotion(env: &Env, prompt_id: u128, promotion: &super::types::Promotion) {
Expand Down
104 changes: 103 additions & 1 deletion contracts/prompt-hash/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::contract::{PromptHashContract, PromptHashContractClient};
use crate::mock_asset::FungibleTokenContract;
use crate::storage::Storage;
use crate::types::{Error, ListingConfig, Split};
use crate::types::{Error, ListingConfig, PromptInput, Split};
extern crate std;
use soroban_sdk::{
testutils::{Address as _, Events as _, Ledger},
Expand Down Expand Up @@ -6628,3 +6628,105 @@ fn test_repeated_operations_preserve_idempotency() {
assert!(!client.get_prompt(&prompt_id).active);
}


// ═══════════════════════════════════════════════════════════════════════════════
// Batch Prompt Creation Tests
// ═══════════════════════════════════════════════════════════════════════════════

fn make_batch_input(
env: &Env,
title: &str,
price: i128,
asset: &Address,
) -> PromptInput {
PromptInput {
image_url: String::from_str(env, "https://example.com/prompt.png"),
title: String::from_str(env, title),
category: String::from_str(env, "Software Development"),
preview_text: String::from_str(env, "Generate a production-ready implementation plan."),
encrypted_prompt: String::from_str(env, "ciphertext"),
encryption_iv: String::from_str(env, "iv"),
wrapped_key: String::from_str(env, "wrapped-key"),
content_hash: hash(env, 7),
listing: ListingConfig {
price,
asset: asset.clone(),
expires_at: 0,
splits: Vec::new(env),
},
}
}

#[test]
fn test_create_prompt_batch_creates_multiple_prompts() {
let env: Env = Default::default();
let context = setup(&env);
let client = PromptHashContractClient::new(&env, &context.contract);

let creator = Address::generate(&env);
let mut inputs = Vec::<PromptInput>::new(&env);
inputs.push_back(make_batch_input(&env, "Batch Prompt A", 10_000, &context.xlm));
inputs.push_back(make_batch_input(&env, "Batch Prompt B", 20_000, &context.xlm));
inputs.push_back(make_batch_input(&env, "Batch Prompt C", 30_000, &context.xlm));

let ids = client.create_prompt_batch(&creator, &inputs);
assert_eq!(ids.len(), 3);
assert_eq!(ids.get(0).unwrap(), 0);
assert_eq!(ids.get(1).unwrap(), 1);
assert_eq!(ids.get(2).unwrap(), 2);

// Verify each prompt was stored correctly
let p0 = client.get_prompt(&0);
assert_eq!(p0.title, String::from_str(&env, "Batch Prompt A"));
assert_eq!(p0.price_stroops, 10_000);
assert!(p0.active);

let p1 = client.get_prompt(&1);
assert_eq!(p1.title, String::from_str(&env, "Batch Prompt B"));
assert_eq!(p1.price_stroops, 20_000);

let p2 = client.get_prompt(&2);
assert_eq!(p2.title, String::from_str(&env, "Batch Prompt C"));
assert_eq!(p2.price_stroops, 30_000);

// Verify creator catalog
let by_creator = client.get_prompts_by_creator(&creator);
assert_eq!(by_creator.len(), 3);
}

#[test]
fn test_create_prompt_batch_single_item() {
let env: Env = Default::default();
let context = setup(&env);
let client = PromptHashContractClient::new(&env, &context.contract);

let creator = Address::generate(&env);
let mut inputs = Vec::<PromptInput>::new(&env);
inputs.push_back(make_batch_input(&env, "Solo Batch", 5_000, &context.xlm));

let ids = client.create_prompt_batch(&creator, &inputs);
assert_eq!(ids.len(), 1);
assert_eq!(ids.get(0).unwrap(), 0);

let p = client.get_prompt(&0);
assert_eq!(p.title, String::from_str(&env, "Solo Batch"));
}

#[test]
fn test_create_prompt_batch_empty_fails() {
let env: Env = Default::default();
let context = setup(&env);
let client = PromptHashContractClient::new(&env, &context.contract);

let creator = Address::generate(&env);
let inputs = Vec::<PromptInput>::new(&env);

let result = client.try_create_prompt_batch(&creator, &inputs);
match result {
Err(Ok(Error::InvalidPrice)) => {}
other => panic!(
"expected InvalidPrice for empty batch, got {:?}",
other
),
}
}
47 changes: 44 additions & 3 deletions contracts/prompt-hash/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ pub enum DataKey {
Discount(u128),
// #192 – per-prompt price history log.
PriceHistory(u128),
PromptExpiryWarning(u128),
}

/// #192 – A single recorded price change for a prompt.
Expand All @@ -166,7 +167,6 @@ pub struct PriceHistoryEntry {
/// Monotonic per-prompt sequence number, starting at 1 for the initial
/// listing price. Used to keep history entries ordered and de-duplicated.
pub seq: u64,
PromptExpiryWarning(u128),
}

#[contracttype]
Expand Down Expand Up @@ -214,6 +214,17 @@ pub struct Promotion {
pub asset: Address,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Settlement {
pub buyer_amount: i128,
pub creator_amount: i128,
pub platform_amount: i128,
pub referrer: Option<Address>,
pub referrer_amount: i128,
pub split_amount: i128,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Purchase {
Expand Down Expand Up @@ -263,6 +274,24 @@ pub struct ListingConfig {
pub splits: Vec<Split>,
}

/// Input payload for a single prompt in a batch-creation call.
/// Mirrors the individual parameters of `create_prompt` but packaged into
/// one Soroban-transportable struct so the batch entrypoint can accept a
/// `Vec<PromptInput>` instead of repeating the full parameter list.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptInput {
pub image_url: String,
pub title: String,
pub category: String,
pub preview_text: String,
pub encrypted_prompt: String,
pub encryption_iv: String,
pub wrapped_key: String,
pub content_hash: BytesN<32>,
pub listing: ListingConfig,
}

/// Canonical taxonomy for content classification.
/// Creators attest one of these categories for each listing.
/// Uses `None` variant as default (unnamed).
Expand Down Expand Up @@ -406,6 +435,12 @@ pub trait PromptHashTrait {
listing: ListingConfig,
) -> Result<u128, Error>;

fn create_prompt_batch(
env: Env,
creator: Address,
prompts: Vec<PromptInput>,
) -> Result<Vec<u128>, Error>;

fn set_prompt_sale_status(
env: Env,
creator: Address,
Expand Down Expand Up @@ -543,12 +578,18 @@ pub trait PromptHashTrait {
fn get_fee_percentage(env: Env) -> u32;
fn get_fee_wallet(env: Env) -> Option<Address>;
fn set_referral_percentage(env: Env, new_referral_percentage: u32) -> Result<(), Error>;
fn get_referral_percentage(env: Env) -> u32;
fn register_referral_code(
fn get_referral_percentage(env: Env) -> u32; fn register_referral_code(
env: Env,
referrer: Address,
code_hash: BytesN<32>,
) -> Result<(), Error>;

fn revoke_referral_code(
env: Env,
referrer: Address,
code_hash: BytesN<32>,
) -> Result<(), Error>;

fn set_pause_status(
env: Env,
paused: bool,
Expand Down