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.
335 changes: 112 additions & 223 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 = "26.1.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 @@ -176,6 +177,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 @@ -645,38 +646,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
90 changes: 89 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 @@ -6691,6 +6691,32 @@ 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_buy_returns_insufficient_balance_when_wallet_unfunded() {
let env: Env = Default::default();
Expand All @@ -6710,6 +6736,44 @@ fn test_buy_returns_insufficient_balance_when_wallet_unfunded() {
}

#[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() {
fn test_buy_supply_enforcement_is_atomic() {
let env: Env = Default::default();
let context = setup(&env);
Expand Down Expand Up @@ -6746,6 +6810,19 @@ fn test_buy_bundle_returns_insufficient_balance_when_wallet_unfunded() {
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 buyer = Address::generate(&env);

let pid = create_prompt(&env, &client, &creator, "Bundle P", 3_000, &context.xlm);
Expand Down Expand Up @@ -6819,6 +6896,17 @@ fn test_lease_returns_insufficient_balance_when_wallet_unfunded() {
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
),
}
}
let buyer = Address::generate(&env);
let prompt_id = create_prompt(&env, &client, &creator, "Lease Prompt", 10_000, &context.xlm);

Expand Down
Loading