diff --git a/lazer/cardano/factura-ya/.nvmrc b/lazer/cardano/factura-ya/.nvmrc new file mode 100644 index 00000000..209e3ef4 --- /dev/null +++ b/lazer/cardano/factura-ya/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/lazer/cardano/factura-ya/README.md b/lazer/cardano/factura-ya/README.md new file mode 100644 index 00000000..ecca06f6 --- /dev/null +++ b/lazer/cardano/factura-ya/README.md @@ -0,0 +1,172 @@ +# Team Facturas Ya Pythathon Submission + +## Details + +Team Name: Facturas Ya +Submission Name: Factura Ya +Team Members: Dario Fasolino, Macarena Carabajal +Contact: dario.a.fasolino@gmail.com, macacarabajal3@gmail.com + +## Project Description + +**Factura Ya** is an on-chain invoice factoring marketplace built on Cardano. It lets Latin American SMEs tokenize their outstanding invoices as NFTs and sell them at a discount to investors, providing immediate liquidity without banks or traditional factoring intermediaries. + +### The Problem + +SMEs in Argentina wait 60-90 days to collect on invoices. Traditional factoring charges 3-5% monthly with slow approvals. Meanwhile, $24B+ in tokenized real-world assets (RWAs) exist on-chain, but none serve LATAM SMEs. + +### How It Works + +1. **SME registers an invoice** — amount (ARS), due date, debtor info +2. **Invoice is tokenized** as an NFT on Cardano, with collateral locked in escrow (10-20%) +3. **Listed on the marketplace** with a discount and due date +4. **Investor purchases** — pays ADA, SME receives funds instantly +5. **At maturity** — investor confirms payment received (collateral returned to SME) or reports non-payment (collateral transferred to investor) + +### How We Use Pyth + +Pyth is **central** to the invoice valuation pipeline: + +- **On-chain**: The `pyth_oracle.ak` module calls `pyth.get_updates()` to read the verified ADA/USD price feed (feed ID 16) from the Pyth withdraw-script redeemer. Price freshness is validated (max 60 seconds). +- **Conversion**: `usd_to_lovelace()` converts invoice values from USD to ADA using the real-time Pyth price, enabling accurate marketplace pricing. +- **Listing**: When an invoice is listed, the current Pyth price is snapshotted into the listing datum as a reference. +- **Off-chain**: `PythPriceClient` subscribes to Pyth Pro WebSocket for live price updates, feeding them into transaction construction via the official `@pythnetwork/pyth-lazer-cardano-js` SDK. + +**PreProd Policy ID**: `d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6` + +> **Note on currency**: Invoices are denominated in USD (not ARS) because Pyth's ADA/USD feed (ID 16) is live, while the ARS/USD feed (ID 2582) is "coming soon". This lets us demonstrate real oracle integration today. Switching to ARS requires only adding a second oracle call when the feed goes live. See [currency-decision.md](custom_docs/currency-decision.md) for details. + +## Architecture + +``` +Frontend (React) ←→ Indexer API (Express) + ↓ ↑ +Off-chain Tx Builders Oura Pipeline + (TypeScript) ↑ + ↓ Cardano PreProd +┌──────────────────────────────────┐ +│ Smart Contracts (Aiken) │ +│ │ +│ invoice_mint.ak - NFT minting │ +│ escrow.ak - collateral │ +│ marketplace.ak - list/buy │ +│ pyth_oracle.ak - price feed │ +│ │ +│ Pyth Oracle (ADA/USD) │ +│ (withdraw-script pattern) │ +└──────────────────────────────────┘ +``` + +## Tech Stack + +| Component | Technology | +|-----------|-----------| +| Smart Contracts | Aiken (Plutus V3) | +| Oracle | Pyth Pro (feed 16: ADA/USD) | +| On-chain SDK | pyth-network/pyth-lazer-cardano | +| Off-chain SDK | @pythnetwork/pyth-lazer-cardano-js | +| Tx Builder | Evolution SDK | +| Indexer | Oura (TxPipe) + Express | +| Frontend | React + Vite + TypeScript | +| Network | Cardano PreProd | + +## Project Structure + +``` +factura_ya/ +├── contracts/ # Aiken smart contracts +│ ├── lib/ +│ │ ├── pyth_oracle.ak # Pyth price feed consumer +│ │ ├── types.ak # Shared types +│ │ ├── pyth.ak # Vendored Pyth library +│ │ ├── pyth/message.ak # Pyth message verification +│ │ ├── parser.ak # Binary parser +│ │ └── types/ # Integer types (u8..u64, i16, i64) +│ └── validators/ +│ ├── invoice_mint.ak # Invoice NFT minting policy +│ ├── escrow.ak # Collateral lock/release/forfeit +│ └── marketplace.ak # List/purchase/delist +├── offchain/ # TypeScript tx builders +│ └── src/ +│ ├── pyth.ts # Pyth WebSocket client +│ ├── mint.ts # Mint/burn invoice NFTs +│ ├── escrow.ts # Collateral operations +│ └── marketplace.ts # List/purchase/delist +├── indexer/ # Oura-powered marketplace indexer +│ ├── oura.toml # Pipeline config +│ └── src/api.ts # REST API +├── frontend/ # React web UI +│ └── src/ +│ ├── App.tsx +│ └── components/ # Marketplace, RegisterInvoice, PriceDisplay +└── custom_docs/ # Design docs (PRD, spec, Pyth research) +``` + +## Setup & Run + +### Prerequisites + +- [Aiken](https://aiken-lang.org/) v1.1.21+ +- Node.js 20+ (`nvm use 20`) +- A CIP-30 wallet extension (Eternl, Nami, or Lace) set to **PreProd** testnet +- Test ADA from the [Cardano faucet](https://docs.cardano.org/cardano-testnets/tools/faucet/) +- Pyth API key (from hackathon organizers) + +### Quick Start + +```bash +# Install everything (contracts + offchain + frontend + indexer) +npm run install:all + +# Build & test smart contracts (32 tests) +npm run contracts:build +npm run contracts:test + +# Start services (run each in a separate terminal) +npm run server # tx server on :3002 — builds, signs, submits txs +npm run frontend # React UI on :5173 +npm run indexer # optional: Oura API on :3001 +``` + +### Individual Commands + +| Command | Description | +|---------|-------------| +| `npm run install:all` | Install deps for all sub-projects | +| `npm run contracts:build` | Compile Aiken smart contracts | +| `npm run contracts:test` | Run 32 Aiken unit tests | +| `npm run server` | Start tx server (port 3002) | +| `npm run frontend` | Start React frontend (port 5173) | +| `npm run indexer` | Start Oura indexer API (port 3001) | +| `npm run test-mint` | Mint a test NFT (needs `WALLET_ADDRESS` env) | + +## Architecture Decision: Transaction Server + +### Problem + +Cardano transaction construction requires [Lucid Evolution](https://github.com/Anastasia-Labs/lucid-evolution), which depends on `libsodium-wrappers-sumo` (WASM cryptography). This library has a known ESM packaging bug: its ESM entry (`modules-sumo-esm/libsodium-wrappers.mjs`) imports `./libsodium-sumo.mjs`, but that file ships in a separate package (`libsodium-sumo`) and isn't resolvable via the relative import. + +### What We Tried + +| Approach | Result | +|----------|--------| +| Lucid in Vite with `vite-plugin-wasm` + `top-level-await` | `libsodium-sumo.mjs` import fails | +| Vite `resolve.alias` to CJS build | Vite package export resolver blocks subpath | +| Include Lucid in `optimizeDeps` | `lodash` CJS/ESM mismatch, `safe-buffer` crash | +| Load Lucid from CDN (`unpkg`) | No ESM build published for `@lucid-evolution/lucid` | +| Bundle with `esbuild` for browser | WASM imports + `libsodium` + Node builtins all fail | +| Node 18 → Node 20 upgrade | `libsodium` ESM bug persists across Node versions | + +### Current Solution + +Transaction construction runs on a **Node.js server** (`offchain/src/deploy-server.ts`, port 3002) where Lucid works natively. The frontend opens standalone HTML pages served by this server for wallet interaction (CIP-30 signing). The server and frontend communicate via a `/status` REST endpoint. + +### Path to Production + +- **Short term**: Replace Lucid with [MeshJS](https://meshjs.dev/), which is browser-native and avoids the `libsodium` dependency entirely. +- **Medium term**: Wait for Lucid Evolution to fix their ESM/WASM packaging ([related issue](https://github.com/nicholasgasior/npm-libsodium-sumo/issues)). +- **Alternative**: Backend-signs architecture where a custodial server key constructs and signs transactions, and the user only confirms intent. + +## License + +Apache-2.0 diff --git a/lazer/cardano/factura-ya/aiken.toml b/lazer/cardano/factura-ya/aiken.toml new file mode 100644 index 00000000..cdef2280 --- /dev/null +++ b/lazer/cardano/factura-ya/aiken.toml @@ -0,0 +1,19 @@ +name = "factura-ya/contracts" +version = "0.0.0" +compiler = "v1.1.21" +plutus = "v3" +license = "Apache-2.0" +description = "Aiken contracts for project 'factura-ya/contracts'" + +[repository] +user = "factura-ya" +project = "contracts" +platform = "github" + +[[dependencies]] +name = "aiken-lang/stdlib" +version = "v3.0.0" +source = "github" + + +[config] diff --git a/lazer/cardano/factura-ya/contracts-lib/parser.ak b/lazer/cardano/factura-ya/contracts-lib/parser.ak new file mode 100644 index 00000000..6f506f9e --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/parser.ak @@ -0,0 +1,87 @@ +use aiken/collection/list +use aiken/primitive/bytearray + +/// `Parser` is a "stateful" function that modifies source `ByteArray` while +/// producing value of type `a`. Parsers can be constructed with `parse` +/// function, existing parsing combinators and `return` wrapper using +/// backpassing syntax: +/// +/// ``` +/// use parser.{Parser, parse} +/// +/// { +/// let length <- parse(u32.from_be) +/// let items <- parse(parser.repeat(item_parser, u32.as_int(length))) +/// (length, items) |> parser.return +/// } +/// ``` +/// +/// Use `run` or `run_partial` to execute the final parser over `ByteArray`. +pub type Parser = + fn(ByteArray) -> (a, ByteArray) + +/// Runs supplied parser over source `ByteArray`. Requires parser to consume +/// the whole input. +pub fn run(self: Parser, src: ByteArray) -> a { + expect (a, #[]) = self(src) + a +} + +/// Runs supplied parser over source `ByteArray`, returning unconsumed input. +pub fn run_partial(self: Parser, src: ByteArray) -> (a, ByteArray) { + self(src) +} + +/// Applies function over result of the supplied `Parser`. +pub fn map(self: Parser, with: fn(a) -> b) -> Parser { + fn(bs) { + let (a, bs1) = self(bs) + (with(a), bs1) + } +} + +/// Creates `Parser` that does not consume any input, returning provided value. +/// Can be used to construct return value of a composite parser. +pub fn return(value: a) -> Parser { + fn(bs) { (value, bs) } +} + +/// Function for chaining multiple parsers together using backpassing syntax. +/// Feeds result of the first parser into the provided function, producing new +/// composite parser. See `Parser` type for example. +pub fn parse(parser: Parser, then: fn(a) -> Parser) -> Parser { + fn(bs) { + let (a, bs1) = parser(bs) + then(a)(bs1) + } +} + +/// Parses remaining input, returning it as `ByteArray`. +pub const rest: Parser = fn(bs) { (bs, #[]) } + +/// Parses exactly the specified amount of bytes as `ByteArray`. For parsing +/// inidividual bytes as `Byte`, use `types/u8.from_be`. +pub fn bytes(count: Int) -> Parser { + expect count >= 0 + fn(bs) { + expect bytearray.length(bs) >= count + (bytearray.take(bs, count), bytearray.drop(bs, count)) + } +} + +/// Repeatedly applies provided parser, `count` times, returning list of +/// parsed results. +pub fn repeat(self: Parser, count: Int) -> Parser> { + expect count >= 0 + repeat_(self, [], count) +} + +fn repeat_(self: Parser, prev: List, count: Int) -> Parser> { + when count is { + 0 -> list.reverse(prev) |> return + _ -> { + let a <- parse(self) + repeat_(self, list.push(prev, a), count - 1) + } + } +} diff --git a/lazer/cardano/factura-ya/contracts-lib/pyth.ak b/lazer/cardano/factura-ya/contracts-lib/pyth.ak new file mode 100644 index 00000000..a8f1d2ae --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/pyth.ak @@ -0,0 +1,231 @@ +use aiken/collection/list +use aiken/collection/pairs +use aiken/crypto.{ScriptHash} +use cardano/address.{Script} +use cardano/assets.{PolicyId} +use cardano/transaction.{InlineDatum, Transaction, ValidityRange, Withdraw} +use parser.{Parser, parse} +use pyth/message.{TrustedSigners} +use types/i16 +use types/i64 +use types/u16.{U16} +use types/u32.{U32} +use types/u64.{U64} +use types/u8.{U8} + +const price_update_magic_le = #"75d3c793" + +pub type Governance { + wormhole: PolicyId, + emitter_chain: Int, + emitter_address: ByteArray, + seen_sequence: Int, +} + +pub type Pyth { + governance: Governance, + trusted_signers: TrustedSigners, + deprecated_withdraw_scripts: Pairs, + withdraw_script: ScriptHash, +} + +pub type PriceUpdate { + timestamp_us: U64, + channel_id: U8, + feeds: List, +} + +pub fn get_updates(pyth_id: PolicyId, self: Transaction) -> List { + expect Some(state) = + list.find_map( + self.reference_inputs, + fn(input) { + if assets.has_nft_strict(input.output.value, pyth_id, "Pyth State") { + // state token outside of script address would imply a bug + expect Script(_) = input.output.address.payment_credential + expect InlineDatum(state) = input.output.datum + expect state: Pyth = state + Some(state) + } else { + None + } + }, + ) + expect Some(redeemer) = + pairs.get_first(self.redeemers, Withdraw(Script(state.withdraw_script))) + expect updates: List = redeemer + list.map( + updates, + fn(message) { + let message = message.parse_without_verification(message) + parse_update(message.payload) + }, + ) +} + +fn parse_update(payload: ByteArray) -> PriceUpdate { + parser.run(price_update, payload) +} + +pub type Feed { + feed_id: U32, + price: Option>, + best_bid_price: Option>, + best_ask_price: Option>, + publisher_count: Option, + exponent: Option, + confidence: Option>, + funding_rate: Option>, + funding_timestamp: Option>, + funding_rate_interval: Option>, + market_session: Option, + ema_price: Option>, + ema_confidence: Option>, + feed_update_timestamp: Option>, +} + +fn empty(feed_id: U32) -> Feed { + Feed { + feed_id, + price: None, + best_bid_price: None, + best_ask_price: None, + publisher_count: None, + exponent: None, + confidence: None, + funding_rate: None, + funding_timestamp: None, + funding_rate_interval: None, + market_session: None, + ema_price: None, + ema_confidence: None, + feed_update_timestamp: None, + } +} + +type FeedProperty { + Price(Option) + BestBidPrice(Option) + BestAskPrice(Option) + PublisherCount(U16) + Exponent(Int) + Confidence(Option) + FundingRate(Option) + FundingTimestamp(Option) + FundingRateInterval(Option) + MarketSession(MarketSession) + EmaPrice(Option) + EmaConfidence(Option) + FeedUpdateTimestamp(Option) +} + +fn apply_properties(self: Feed, properties: List) -> Feed { + list.foldl( + properties, + self, + fn(f, self) { + when f is { + Price(p) -> Feed { ..self, price: Some(p) } + BestBidPrice(p) -> Feed { ..self, best_bid_price: Some(p) } + BestAskPrice(p) -> Feed { ..self, best_ask_price: Some(p) } + PublisherCount(p) -> Feed { ..self, publisher_count: Some(p) } + Exponent(p) -> Feed { ..self, exponent: Some(p) } + Confidence(p) -> Feed { ..self, confidence: Some(p) } + FundingRate(p) -> Feed { ..self, funding_rate: Some(p) } + FundingTimestamp(p) -> Feed { ..self, funding_timestamp: Some(p) } + FundingRateInterval(p) -> + Feed { ..self, funding_rate_interval: Some(p) } + MarketSession(p) -> Feed { ..self, market_session: Some(p) } + EmaPrice(p) -> Feed { ..self, ema_price: Some(p) } + EmaConfidence(p) -> Feed { ..self, ema_confidence: Some(p) } + FeedUpdateTimestamp(p) -> + Feed { ..self, feed_update_timestamp: Some(p) } + } + }, + ) +} + +pub type MarketSession { + Regular + PreMarket + PostMarket + OverNight + Closed +} + +fn nonzero_i64_property( + property: fn(Option) -> FeedProperty, +) -> Parser { + let i <- parser.map(i64.from_le) + let i = i64.as_int(i) + property( + if i == 0 { + None + } else { + Some(i) + }, + ) +} + +fn optional_property( + property: fn(Option) -> FeedProperty, + parser: Parser, +) -> Parser { + let present <- parse(u8.from) + if present != u8.zero { + parser.map(parser, fn(p) { property(Some(p)) }) + } else { + property(None) |> parser.return + } +} + +fn market_session() -> Parser { + let s <- parser.map(u16.from_le) + when u16.as_int(s) is { + 0 -> Regular + 1 -> PreMarket + 2 -> PostMarket + 3 -> OverNight + 4 -> Closed + _ -> fail @"MarketSession value out of range" + } +} + +fn feed_property() -> Parser { + let property_id <- parse(u8.from) + when u8.as_int(property_id) is { + 0 -> nonzero_i64_property(Price) + 1 -> nonzero_i64_property(BestBidPrice) + 2 -> nonzero_i64_property(BestAskPrice) + 3 -> parser.map(u16.from_le, PublisherCount) + 4 -> parser.map(i16.from_le, fn(i) { Exponent(i16.as_int(i)) }) + 5 -> nonzero_i64_property(Confidence) + 6 -> optional_property(FundingRate, parser.map(i64.from_le, i64.as_int)) + 7 -> optional_property(FundingTimestamp, u64.from_le) + 8 -> optional_property(FundingRateInterval, u64.from_le) + 9 -> parser.map(market_session(), MarketSession) + 10 -> nonzero_i64_property(EmaPrice) + 11 -> nonzero_i64_property(EmaConfidence) + 12 -> optional_property(FeedUpdateTimestamp, u64.from_le) + _ -> fail @"FeedProperty ID out of range" + } +} + +const feed: Parser = { + let feed_id <- parse(u32.from_le) + let properties_len <- parse(u8.from) + let properties <- + parse(parser.repeat(feed_property(), u8.as_int(properties_len))) + apply_properties(empty(feed_id), properties) |> parser.return + } + +const price_update: Parser = { + let magic <- parse(parser.bytes(4)) + expect magic == price_update_magic_le + let timestamp_us <- parse(u64.from_le) + let channel_id <- parse(u8.from) + let feeds_len <- parse(u8.from) + let feeds <- parse(parser.repeat(feed, u8.as_int(feeds_len))) + PriceUpdate { timestamp_us, channel_id, feeds } |> parser.return + } + diff --git a/lazer/cardano/factura-ya/contracts-lib/pyth/message.ak b/lazer/cardano/factura-ya/contracts-lib/pyth/message.ak new file mode 100644 index 00000000..05255d48 --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/pyth/message.ak @@ -0,0 +1,141 @@ +use aiken/collection/pairs +use aiken/crypto.{Signature, VerificationKey} +use aiken/interval.{Interval} +use aiken/option +use aiken/primitive/bytearray +use cardano/transaction.{ValidityRange} +use parser.{Parser, parse} +use types/u16 + +const solana_format_magic_le = #"b9011a82" + +pub type TrustedSigners = + Pairs + +pub fn is_trusted( + self: TrustedSigners, + key: VerificationKey, + current: ValidityRange, +) { + pairs.get_first(self, key) + |> option.map(fn(valid) { interval.includes(valid, current) }) + |> option.or_else(False) +} + +pub fn update_trusted_signer( + self: TrustedSigners, + key: VerificationKey, + valid: Option, +) { + when valid is { + None -> pairs.delete_all(self, key) + Some(valid) -> + pairs.repsert_by_ascending_key(self, key, valid, bytearray.compare) + } +} + +pub type PythMessage { + signature: Signature, + key: VerificationKey, + payload: ByteArray, +} + +pub fn parse_and_verify( + src: ByteArray, + signers: TrustedSigners, + current: ValidityRange, +) -> PythMessage { + let update = parse_without_verification(src) + expect + crypto.verify_ed25519_signature( + update.key, + update.payload, + update.signature, + )? + expect is_trusted(signers, update.key, current) + update +} + +pub fn parse_without_verification(src: ByteArray) -> PythMessage { + parser.run(pyth_message, src) +} + +const pyth_message: Parser = { + let magic <- parse(parser.bytes(4)) + expect magic == solana_format_magic_le + let signature <- parse(parser.bytes(64)) + let key <- parse(parser.bytes(32)) + let size <- parse(u16.from_le) + let payload <- parse(parser.bytes(u16.as_int(size))) + + PythMessage { signature, key, payload } |> parser.return + } + +const test_interval: Interval = interval.before(1) + +const test_signers: TrustedSigners = + [ + Pair( + #"74313a6525edf99936aa1477e94c72bc5cc617b21745f5f03296f3154461f214", + test_interval, + ), + ] + +test can_parse_message() { + let message = + #"b9011a82e5cddee2c1bd364c8c57e1c98a6a28d194afcad410ff412226c8b2ae931ff59a57147cb47c7307afc2a0a1abec4dd7e835a5b7113cf5aeac13a745c6bed6c60074313a6525edf99936aa1477e94c72bc5cc617b21745f5f03296f3154461f2141c0075d3c7931c9773f30a240600010102000000010000e1f50500000000" + + expect _ = parse_and_verify(message, test_signers, test_interval) +} + +test fails_with_wrong_signature() fail { + let message = + #"b9011a82e5cddee2c1bd364c8c57e1c98a6a28d194afcad410ff412226c8b2ae931ff59a57147cb47c7307afc2a0a1abec4dd7e835a5b7113cf5aeac13a745c6bed6c60074313a6525edf99936aa1477e94c72bc5cc617b21745f5f03296f3154461f2141c0075d3c7931c9773f30a240600010102000000010000e1f50500000001" + + expect _ = parse_and_verify(message, test_signers, test_interval) +} + +test fails_with_bad_magic() fail { + let message = + #"c9011a82e5cddee2c1bd364c8c57e1c98a6a28d194afcad410ff412226c8b2ae931ff59a57147cb47c7307afc2a0a1abec4dd7e835a5b7113cf5aeac13a745c6bed6c60074313a6525edf99936aa1477e94c72bc5cc617b21745f5f03296f3154461f2141c0075d3c7931c9773f30a240600010102000000010000e1f50500000000" + + expect _ = parse_and_verify(message, test_signers, test_interval) +} + +test fails_with_bad_length() fail { + let message = #"c9011a82e5cddee2c1bd364c8c57e1" + + expect _ = parse_and_verify(message, test_signers, test_interval) +} + +test bad_payload_length_too_short() fail { + let message = + #"b9011a82e5cddee2c1bd364c8c57e1c98a6a28d194afcad410ff412226c8b2ae931ff59a57147cb47c7307afc2a0a1abec4dd7e835a5b7113cf5aeac13a745c6bed6c60074313a6525edf99936aa1477e94c72bc5cc617b21745f5f03296f3154461f2141c0075d3c7931c9773f30a240600010102000000010000e1f505000000" + + expect _ = parse_and_verify(message, test_signers, test_interval) +} + +test bad_payload_length_too_long() fail { + let message = + #"b9011a82e5cddee2c1bd364c8c57e1c98a6a28d194afcad410ff412226c8b2ae931ff59a57147cb47c7307afc2a0a1abec4dd7e835a5b7113cf5aeac13a745c6bed6c60074313a6525edf99936aa1477e94c72bc5cc617b21745f5f03296f3154461f2141c0075d3c7931c9773f30a240600010102000000010000e1f5050000000000" + + expect _ = parse_and_verify(message, test_signers, test_interval) +} + +test adds_new_signer() { + update_trusted_signer([], #"", Some(interval.before(10))) == [ + Pair(#"", interval.before(10)), + ] +} + +test removes_signer() { + update_trusted_signer([Pair(#"", interval.before(10))], #"", None) == [] +} + +test updates_signer() { + update_trusted_signer( + [Pair(#"aa", interval.before(42))], + #"bb", + Some(interval.before(12)), + ) == [Pair(#"aa", interval.before(42)), Pair(#"bb", interval.before(12))] +} diff --git a/lazer/cardano/factura-ya/contracts-lib/pyth_oracle.ak b/lazer/cardano/factura-ya/contracts-lib/pyth_oracle.ak new file mode 100644 index 00000000..15d9ea1c --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/pyth_oracle.ak @@ -0,0 +1,146 @@ +use aiken/collection/list +use cardano/assets.{PolicyId} +use cardano/transaction.{Transaction} +use pyth +use types.{PriceData} +use types/u32.{U32} +use types/u64 + +/// Maximum age of a price update in microseconds (60 seconds). +pub const max_age_us: Int = 60_000_000 + +/// Extract the price for a specific feed from a Pyth price update within a transaction. +/// +/// Calls `pyth.get_updates` to retrieve all verified price updates from the +/// Pyth withdraw-script redeemer, then finds the feed matching `target_feed_id`. +/// +/// Fails if: +/// - No Pyth updates are present in the transaction +/// - The target feed is not found in any update +/// - The feed has no price or exponent +/// - The price update is stale (older than `max_age_us` relative to `current_time_us`) +pub fn get_price( + pyth_id: PolicyId, + tx: Transaction, + target_feed_id: U32, + current_time_us: Int, +) -> PriceData { + let updates = pyth.get_updates(pyth_id, tx) + + expect Some(found) = + list.find_map( + updates, + fn(update) { + list.find( + update.feeds, + fn(feed) { feed.feed_id == target_feed_id }, + ) + |> option_map(fn(feed) { (update.timestamp_us, feed) }) + }, + ) + + let (timestamp_us, feed) = found + + // Validate freshness + let age_us = current_time_us - u64.as_int(timestamp_us) + expect age_us >= 0 + expect age_us <= max_age_us + + // Extract price and exponent (must be present) + expect Some(Some(price)) = feed.price + expect Some(exponent) = feed.exponent + + PriceData { + feed_id: feed.feed_id, + price, + exponent, + timestamp_us, + } +} + +/// Convert a USD amount to lovelace using an ADA/USD price. +/// +/// ADA/USD price means: 1 ADA = (ada_usd_price * 10^exponent) USD +/// lovelace = (usd_amount * 10^(6 - exponent)) / ada_usd_price +/// +/// Invoices are denominated in USD, matching the available Pyth ADA/USD feed (feed 16). +pub fn usd_to_lovelace( + usd_amount: Int, + ada_usd_price: Int, + ada_usd_exponent: Int, +) -> Int { + let power = 6 - ada_usd_exponent + let numerator = usd_amount * pow10(power) + numerator / ada_usd_price +} + +/// Integer power of 10. Only supports non-negative exponents. +pub fn pow10(n: Int) -> Int { + do_pow10(n, 1) +} + +fn do_pow10(n: Int, acc: Int) -> Int { + if n <= 0 { + acc + } else { + do_pow10(n - 1, acc * 10) + } +} + +// --- Helpers --- + +fn option_map(opt: Option, f: fn(a) -> b) -> Option { + when opt is { + Some(x) -> Some(f(x)) + None -> None + } +} + +// --- Tests --- + +test pow10_zero() { + pow10(0) == 1 +} + +test pow10_positive() { + pow10(8) == 100_000_000 +} + +test usd_to_lovelace_basic() { + // 1 ADA = $0.50 (price = 50000000, exponent = -8) + // $100 USD → 200 ADA → 200_000_000 lovelace + usd_to_lovelace(100, 50_000_000, -8) == 200_000_000 +} + +test usd_to_lovelace_realistic() { + // 1 ADA = $0.695087 (price = 69508700, exponent = -8) + // $1000 USD → ~1438.68 ADA → 1_438_676_157 lovelace (truncated) + let result = usd_to_lovelace(1000, 69_508_700, -8) + result > 1_400_000_000 && result < 1_500_000_000 +} + +test usd_to_lovelace_one_dollar() { + // 1 ADA = $1.00 (price = 100000000, exponent = -8) + // $1 USD → 1 ADA → 1_000_000 lovelace + usd_to_lovelace(1, 100_000_000, -8) == 1_000_000 +} + +test usd_to_lovelace_small_amount() { + // 1 ADA = $0.50, $10 → 20 ADA → 20_000_000 lovelace + usd_to_lovelace(10, 50_000_000, -8) == 20_000_000 +} + +test usd_to_lovelace_large_invoice() { + // 1 ADA = $0.70 (price = 70000000, exponent = -8) + // $100_000 USD → ~142857 ADA + let result = usd_to_lovelace(100_000, 70_000_000, -8) + result > 142_000_000_000 && result < 143_000_000_000 +} + +test max_age_is_60_seconds() { + max_age_us == 60_000_000 +} + +test pow10_large() { + pow10(14) == 100_000_000_000_000 +} diff --git a/lazer/cardano/factura-ya/contracts-lib/types.ak b/lazer/cardano/factura-ya/contracts-lib/types.ak new file mode 100644 index 00000000..9ae090c0 --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/types.ak @@ -0,0 +1,87 @@ +use cardano/assets.{PolicyId} +use aiken/crypto.{VerificationKeyHash} +use types/u32.{U32} +use types/u64.{U64} + +// --- Price types (Block 1) --- + +/// Validated price extracted from a Pyth feed. +/// `price` and `exponent` together represent the real value: +/// real_price = price * 10^exponent +pub type PriceData { + feed_id: U32, + price: Int, + exponent: Int, + timestamp_us: U64, +} + +// --- Invoice types (Block 2) --- + +pub type InvoiceMintRedeemer { + MintInvoice + BurnInvoice +} + +pub type InvoiceDatum { + invoice_id: ByteArray, + seller: VerificationKeyHash, + amount_usd: Int, + due_date_posix_ms: Int, + debtor_name: ByteArray, + debtor_contact_hash: ByteArray, + created_at_posix_ms: Int, +} + +// --- Escrow types (Block 3) --- + +pub type EscrowStatus { + Locked + Released + Forfeited +} + +pub type EscrowDatum { + invoice_id: ByteArray, + seller: VerificationKeyHash, + collateral_lovelace: Int, + buyer: Option, + due_date_posix_ms: Int, + status: EscrowStatus, +} + +pub type EscrowRedeemer { + Release + Forfeit +} + +// --- Marketplace types (Block 4) --- + +pub type ListingStatus { + Listed + Sold + Delisted +} + +pub type ListingDatum { + invoice_id: ByteArray, + seller: VerificationKeyHash, + asking_price_lovelace: Int, + original_amount_usd: Int, + pyth_price_at_listing: Int, + pyth_exponent_at_listing: Int, + due_date_posix_ms: Int, + status: ListingStatus, +} + +pub type MarketplaceRedeemer { + Purchase + Delist +} + +// --- Config --- + +/// Pyth deployment config passed as a validator parameter. +pub type PythConfig { + pyth_policy_id: PolicyId, + ada_usd_feed_id: U32, +} diff --git a/lazer/cardano/factura-ya/contracts-lib/types/i16.ak b/lazer/cardano/factura-ya/contracts-lib/types/i16.ak new file mode 100644 index 00000000..acc4bf39 --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/types/i16.ak @@ -0,0 +1,52 @@ +use aiken/math +use aiken/primitive/int +use parser.{Parser} + +pub opaque type I16 { + I16(Int) +} + +pub const zero: I16 = I16(0) + +const min: Int = math.pow2(15) + +pub const from_be: Parser = + parser.bytes(2) + |> parser.map(fn(bs) { I16(int.from_bytearray_big_endian(bs)) }) + +pub const from_le: Parser = + parser.bytes(2) + |> parser.map(fn(bs) { I16(int.from_bytearray_little_endian(bs)) }) + +pub fn as_int(I16(self): I16) -> Int { + if self < min { + self + } else { + self - math.pow2(16) + } +} + +test parses_zero() { + expect as_int(parser.run(from_be, #"0000")) == 0 +} + +test parses_one() { + expect as_int(parser.run(from_be, #"0001")) == 1 +} + +test parses_max() { + expect as_int(parser.run(from_be, #"7fff")) == math.pow2(15) - 1 +} + +test parses_min() { + let i = parser.run(from_be, #"8000") + expect as_int(i) == -math.pow2(15) +} + +test parses_minus_one() { + expect as_int(parser.run(from_be, #"ffff")) == -1 +} + +test parses_minus_two() { + expect as_int(parser.run(from_be, #"fffe")) == -2 +} diff --git a/lazer/cardano/factura-ya/contracts-lib/types/i64.ak b/lazer/cardano/factura-ya/contracts-lib/types/i64.ak new file mode 100644 index 00000000..b37fdb55 --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/types/i64.ak @@ -0,0 +1,52 @@ +use aiken/math +use aiken/primitive/int +use parser.{Parser} + +pub opaque type I64 { + I64(Int) +} + +pub const zero: I64 = I64(0) + +const min: Int = math.pow2(63) + +pub const from_be: Parser = + parser.bytes(8) + |> parser.map(fn(bs) { I64(int.from_bytearray_big_endian(bs)) }) + +pub const from_le: Parser = + parser.bytes(8) + |> parser.map(fn(bs) { I64(int.from_bytearray_little_endian(bs)) }) + +pub fn as_int(I64(self): I64) -> Int { + if self < min { + self + } else { + self - math.pow2(64) + } +} + +test parses_zero() { + expect as_int(parser.run(from_be, #"0000000000000000")) == 0 +} + +test parses_one() { + expect as_int(parser.run(from_be, #"0000000000000001")) == 1 +} + +test parses_max() { + expect as_int(parser.run(from_be, #"7fffffffffffffff")) == math.pow2(63) - 1 +} + +test parses_min() { + let i = parser.run(from_be, #"8000000000000000") + expect as_int(i) == -math.pow2(63) +} + +test parses_minus_one() { + expect as_int(parser.run(from_be, #"ffffffffffffffff")) == -1 +} + +test parses_minus_two() { + expect as_int(parser.run(from_be, #"fffffffffffffffe")) == -2 +} diff --git a/lazer/cardano/factura-ya/contracts-lib/types/u16.ak b/lazer/cardano/factura-ya/contracts-lib/types/u16.ak new file mode 100644 index 00000000..3a7ca74c --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/types/u16.ak @@ -0,0 +1,33 @@ +use aiken/math +use aiken/primitive/bytearray +use aiken/primitive/int +use parser.{Parser} + +pub opaque type U16 { + U16(Int) +} + +pub const zero: U16 = U16(0) + +pub const max: Int = math.pow2(16) - 1 + +pub fn as_int(U16(self): U16) -> Int { + self +} + +pub fn from_int(value: Int) -> U16 { + expect value >= 0 && value <= max + U16(value) +} + +pub const from_be: Parser = + parser.bytes(2) + |> parser.map(fn(bs) { U16(int.from_bytearray_big_endian(bs)) }) + +pub const from_le: Parser = + parser.bytes(2) + |> parser.map(fn(bs) { U16(int.from_bytearray_little_endian(bs)) }) + +pub fn to_be(self: U16) -> ByteArray { + bytearray.from_int_big_endian(as_int(self), 2) +} diff --git a/lazer/cardano/factura-ya/contracts-lib/types/u32.ak b/lazer/cardano/factura-ya/contracts-lib/types/u32.ak new file mode 100644 index 00000000..8fdf7a6a --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/types/u32.ak @@ -0,0 +1,33 @@ +use aiken/math +use aiken/primitive/bytearray +use aiken/primitive/int +use parser.{Parser} + +pub opaque type U32 { + U32(Int) +} + +pub const zero: U32 = U32(0) + +pub const max: Int = math.pow2(32) - 1 + +pub fn as_int(U32(self): U32) -> Int { + self +} + +pub fn from_int(value: Int) -> U32 { + expect value >= 0 && value <= max + U32(value) +} + +pub const from_be: Parser = + parser.bytes(4) + |> parser.map(fn(bs) { U32(int.from_bytearray_big_endian(bs)) }) + +pub const from_le: Parser = + parser.bytes(4) + |> parser.map(fn(bs) { U32(int.from_bytearray_little_endian(bs)) }) + +pub fn to_be(self: U32) -> ByteArray { + bytearray.from_int_big_endian(as_int(self), 4) +} diff --git a/lazer/cardano/factura-ya/contracts-lib/types/u64.ak b/lazer/cardano/factura-ya/contracts-lib/types/u64.ak new file mode 100644 index 00000000..40d9c0da --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/types/u64.ak @@ -0,0 +1,33 @@ +use aiken/math +use aiken/primitive/bytearray +use aiken/primitive/int +use parser.{Parser} + +pub opaque type U64 { + U64(Int) +} + +pub const zero: U64 = U64(0) + +pub const max: Int = math.pow2(64) - 1 + +pub fn as_int(U64(self): U64) -> Int { + self +} + +pub fn from_int(value: Int) -> U64 { + expect value >= 0 && value <= max + U64(value) +} + +pub const from_be: Parser = + parser.bytes(8) + |> parser.map(fn(bs) { U64(int.from_bytearray_big_endian(bs)) }) + +pub const from_le: Parser = + parser.bytes(8) + |> parser.map(fn(bs) { U64(int.from_bytearray_little_endian(bs)) }) + +pub fn to_be(self: U64) -> ByteArray { + bytearray.from_int_big_endian(as_int(self), 8) +} diff --git a/lazer/cardano/factura-ya/contracts-lib/types/u8.ak b/lazer/cardano/factura-ya/contracts-lib/types/u8.ak new file mode 100644 index 00000000..4fbdfeed --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-lib/types/u8.ak @@ -0,0 +1,28 @@ +use aiken/math +use aiken/primitive/bytearray.{Byte} +use parser.{Parser} + +pub opaque type U8 { + U8(Int) +} + +pub const zero: U8 = U8(0) + +pub const max: Int = math.pow2(8) - 1 + +pub fn as_int(U8(self): U8) -> Byte { + self +} + +pub fn from_int(value: Int) -> U8 { + expect value >= 0 && value <= max + U8(value) +} + +pub const from: Parser = + fn(bs) { (U8(bytearray.at(bs, 0)), bytearray.drop(bs, 1)) } + +pub fn to_be(self: U8) -> ByteArray { + let U8(value) = self + bytearray.push(#[], value) +} diff --git a/lazer/cardano/factura-ya/contracts-validators/escrow.ak b/lazer/cardano/factura-ya/contracts-validators/escrow.ak new file mode 100644 index 00000000..a993a70e --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-validators/escrow.ak @@ -0,0 +1,103 @@ +use aiken/collection/list +use aiken/interval.{Finite} +use cardano/address.{VerificationKey} +use cardano/assets +use cardano/transaction.{Transaction, InlineDatum, OutputReference} +use types.{ + EscrowDatum, EscrowRedeemer, + Locked, Released, Forfeited, + Release, Forfeit, +} + +/// Grace period after due date before forfeit is allowed (7 days in ms). +pub const grace_period_ms: Int = 7 * 24 * 60 * 60 * 1000 + +/// Minimum collateral percentage (basis points). 1000 = 10%. +pub const min_collateral_bps: Int = 1000 + +validator escrow { + spend( + datum: Option, + redeemer: EscrowRedeemer, + _utxo: OutputReference, + tx: Transaction, + ) { + expect Some(d) = datum + when redeemer is { + Release -> validate_release(d, tx) + Forfeit -> validate_forfeit(d, tx) + } + } + + else(_) { + fail + } +} + +// --- Release: buyer confirms payment received, collateral goes back to seller --- + +fn validate_release(datum: EscrowDatum, tx: Transaction) -> Bool { + expect datum.status == Locked + expect Some(buyer) = datum.buyer + expect list.has(tx.extra_signatories, buyer) + + let lower = get_validity_start(tx) + expect lower >= datum.due_date_posix_ms + + expect pays_to_pkh(tx, datum.seller, datum.collateral_lovelace) + + True +} + +// --- Forfeit: buyer reports non-payment, collateral goes to buyer --- + +fn validate_forfeit(datum: EscrowDatum, tx: Transaction) -> Bool { + expect datum.status == Locked + expect Some(buyer) = datum.buyer + expect list.has(tx.extra_signatories, buyer) + + let lower = get_validity_start(tx) + expect lower >= datum.due_date_posix_ms + grace_period_ms + + expect pays_to_pkh(tx, buyer, datum.collateral_lovelace) + + True +} + +// --- Helpers --- + +fn get_validity_start(tx: Transaction) -> Int { + when tx.validity_range.lower_bound.bound_type is { + Finite(time) -> time + _ -> 0 + } +} + +// --- Tests --- + +test grace_period_is_7_days() { + grace_period_ms == 7 * 24 * 60 * 60 * 1000 +} + +test min_collateral_is_10_percent() { + min_collateral_bps == 1000 +} + +// --- Helpers --- + +fn pays_to_pkh( + tx: Transaction, + pkh: ByteArray, + min_lovelace: Int, +) -> Bool { + list.any( + tx.outputs, + fn(o) { + when o.address.payment_credential is { + VerificationKey(hash) -> + hash == pkh && assets.lovelace_of(o.value) >= min_lovelace + _ -> False + } + }, + ) +} diff --git a/lazer/cardano/factura-ya/contracts-validators/invoice_mint.ak b/lazer/cardano/factura-ya/contracts-validators/invoice_mint.ak new file mode 100644 index 00000000..483cae34 --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-validators/invoice_mint.ak @@ -0,0 +1,156 @@ +use aiken/collection/list +use aiken/collection/dict +use aiken/interval.{Finite} +use cardano/address.{Script} +use cardano/assets.{PolicyId} +use cardano/transaction.{Transaction, InlineDatum, Output} +use types.{ + InvoiceMintRedeemer, MintInvoice, BurnInvoice, + InvoiceDatum, + EscrowDatum, Released, Forfeited, +} + +/// Minting policy for invoice NFTs. +/// +/// `escrow_script_hash`: the hash of the escrow validator, used to find +/// escrow UTxOs when validating burns. +validator invoice_mint(escrow_script_hash: ByteArray) { + mint( + redeemer: InvoiceMintRedeemer, + policy_id: PolicyId, + tx: Transaction, + ) { + when redeemer is { + MintInvoice -> validate_mint(policy_id, tx) + BurnInvoice -> validate_burn(policy_id, escrow_script_hash, tx) + } + } + + else(_) { + fail + } +} + +// --- Mint validation --- + +fn validate_mint(policy_id: PolicyId, tx: Transaction) -> Bool { + // Exactly one token minted under this policy + let minted = assets.tokens(tx.mint, policy_id) + expect [Pair(invoice_id, quantity)] = dict.to_pairs(minted) + expect quantity == 1 + + // Find the output carrying the minted NFT with an InvoiceDatum + let datum = find_invoice_output(tx.outputs, policy_id, invoice_id) + + // Seller must sign the transaction + expect list.has(tx.extra_signatories, datum.seller) + + // Amount must be positive + expect datum.amount_usd > 0 + + // Due date must be in the future (after tx validity range start) + let lower_bound = get_validity_start(tx) + expect datum.due_date_posix_ms > lower_bound + + // Debtor contact hash must not be empty + expect datum.debtor_contact_hash != #"" + + // Invoice ID in datum must match asset name + expect datum.invoice_id == invoice_id + + True +} + +// --- Burn validation --- + +fn validate_burn( + policy_id: PolicyId, + escrow_script_hash: ByteArray, + tx: Transaction, +) -> Bool { + // Exactly one token burned (quantity = -1) + let minted = assets.tokens(tx.mint, policy_id) + expect [Pair(invoice_id, quantity)] = dict.to_pairs(minted) + expect quantity == -1 + + // Find the escrow UTxO for this invoice + let escrow = find_escrow_datum(tx, escrow_script_hash, invoice_id) + + // Escrow must be in a terminal state + expect escrow.status == Released || escrow.status == Forfeited + + // The NFT holder must sign + when escrow.buyer is { + Some(buyer) -> list.has(tx.extra_signatories, buyer) + None -> list.has(tx.extra_signatories, escrow.seller) + } +} + +// --- Helpers --- + +fn find_invoice_output( + outputs: List, + policy_id: PolicyId, + invoice_id: ByteArray, +) -> InvoiceDatum { + expect Some(output) = + list.find( + outputs, + fn(o) { assets.quantity_of(o.value, policy_id, invoice_id) == 1 }, + ) + expect InlineDatum(raw) = output.datum + expect datum: InvoiceDatum = raw + datum +} + +fn find_escrow_datum( + tx: Transaction, + escrow_script_hash: ByteArray, + invoice_id: ByteArray, +) -> EscrowDatum { + let all_outputs = + list.concat( + list.map(tx.reference_inputs, fn(i) { i.output }), + list.map(tx.inputs, fn(i) { i.output }), + ) + expect Some(output) = + list.find( + all_outputs, + fn(o) { + is_escrow_for_invoice(o, escrow_script_hash, invoice_id) + }, + ) + expect InlineDatum(raw) = output.datum + expect datum: EscrowDatum = raw + datum +} + +fn is_escrow_for_invoice( + output: Output, + escrow_script_hash: ByteArray, + invoice_id: ByteArray, +) -> Bool { + when output.address.payment_credential is { + Script(hash) -> + if hash == escrow_script_hash { + when output.datum is { + InlineDatum(raw) -> { + expect datum: EscrowDatum = raw + datum.invoice_id == invoice_id + } + _ -> False + } + } else { + False + } + _ -> False + } +} + +fn get_validity_start(tx: Transaction) -> Int { + when tx.validity_range.lower_bound.bound_type is { + Finite(time) -> time + _ -> 0 + } +} + diff --git a/lazer/cardano/factura-ya/contracts-validators/invoice_mint_demo.ak b/lazer/cardano/factura-ya/contracts-validators/invoice_mint_demo.ak new file mode 100644 index 00000000..41c23aad --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-validators/invoice_mint_demo.ak @@ -0,0 +1,41 @@ +/// Simplified minting policy for the hackathon demo. +/// +/// This validator only checks: +/// 1. Exactly one token is minted or burned +/// 2. The seller (any signer) authorizes the transaction +/// +/// The full validator (invoice_mint.ak) additionally validates: +/// - InvoiceDatum in the output matches the asset name +/// - amount_usd > 0 +/// - due_date in the future +/// - debtor_contact_hash is non-empty +/// - Escrow state for burns +/// +/// See custom_docs/demo-validator-decision.md for rationale. + +use aiken/collection/list +use aiken/collection/dict +use cardano/assets.{PolicyId} +use cardano/transaction.{Transaction} + +validator invoice_mint_demo { + mint( + _redeemer: Data, + policy_id: PolicyId, + tx: Transaction, + ) { + // Exactly one token minted or burned under this policy + let minted = assets.tokens(tx.mint, policy_id) + expect [Pair(_asset_name, quantity)] = dict.to_pairs(minted) + expect quantity == 1 || quantity == -1 + + // At least one signer (the seller) + expect [_, ..] = tx.extra_signatories + + True + } + + else(_) { + fail + } +} diff --git a/lazer/cardano/factura-ya/contracts-validators/marketplace.ak b/lazer/cardano/factura-ya/contracts-validators/marketplace.ak new file mode 100644 index 00000000..c2f73dc0 --- /dev/null +++ b/lazer/cardano/factura-ya/contracts-validators/marketplace.ak @@ -0,0 +1,160 @@ +use aiken/collection/list +use aiken/collection/dict +use cardano/address.{VerificationKey, Script} +use cardano/assets +use cardano/transaction.{Transaction, InlineDatum, OutputReference, Output} +use types.{ + ListingDatum, MarketplaceRedeemer, ListingStatus, + Listed, Sold, Delisted, + Purchase, Delist, + EscrowDatum, Locked, +} + +/// Marketplace validator for listing, purchasing, and delisting invoice NFTs. +/// +/// `invoice_policy_id`: the minting policy ID for invoice NFTs. +/// `escrow_script_hash`: the escrow validator hash (to find and update escrow UTxOs). +validator marketplace( + invoice_policy_id: ByteArray, + escrow_script_hash: ByteArray, +) { + spend( + datum: Option, + redeemer: MarketplaceRedeemer, + own_ref: OutputReference, + tx: Transaction, + ) { + expect Some(d) = datum + when redeemer is { + Purchase -> validate_purchase(d, invoice_policy_id, escrow_script_hash, tx) + Delist -> validate_delist(d, invoice_policy_id, tx) + } + } + + else(_) { + fail + } +} + +// --- Purchase: investor buys the invoice NFT --- + +fn validate_purchase( + listing: ListingDatum, + invoice_policy_id: ByteArray, + escrow_script_hash: ByteArray, + tx: Transaction, +) -> Bool { + // Must be currently listed + expect listing.status == Listed + + // Payment to seller must be >= asking price + expect + pays_to_pkh(tx, listing.seller, listing.asking_price_lovelace) + + // NFT must go to the buyer (someone who signed the tx, not the seller) + expect Some(buyer_pkh) = + list.find( + tx.extra_signatories, + fn(sig) { sig != listing.seller }, + ) + + expect + nft_goes_to_pkh(tx, invoice_policy_id, listing.invoice_id, buyer_pkh) + + // Escrow must be updated with the buyer's identity. + // We check that an output to the escrow script exists with matching + // invoice_id and buyer set to Some(buyer_pkh). + expect + escrow_output_has_buyer( + tx, escrow_script_hash, listing.invoice_id, buyer_pkh, + ) + + True +} + +// --- Delist: seller withdraws the listing --- + +fn validate_delist( + listing: ListingDatum, + invoice_policy_id: ByteArray, + tx: Transaction, +) -> Bool { + // Must be currently listed + expect listing.status == Listed + + // Seller must sign + expect list.has(tx.extra_signatories, listing.seller) + + // NFT must be returned to seller + expect + nft_goes_to_pkh(tx, invoice_policy_id, listing.invoice_id, listing.seller) + + True +} + +// --- Helpers --- + +fn pays_to_pkh( + tx: Transaction, + pkh: ByteArray, + min_lovelace: Int, +) -> Bool { + list.any( + tx.outputs, + fn(o) { + when o.address.payment_credential is { + VerificationKey(hash) -> + hash == pkh && assets.lovelace_of(o.value) >= min_lovelace + _ -> False + } + }, + ) +} + +fn nft_goes_to_pkh( + tx: Transaction, + policy_id: ByteArray, + asset_name: ByteArray, + pkh: ByteArray, +) -> Bool { + list.any( + tx.outputs, + fn(o) { + when o.address.payment_credential is { + VerificationKey(hash) -> + hash == pkh && assets.quantity_of(o.value, policy_id, asset_name) == 1 + _ -> False + } + }, + ) +} + +fn escrow_output_has_buyer( + tx: Transaction, + escrow_script_hash: ByteArray, + invoice_id: ByteArray, + buyer_pkh: ByteArray, +) -> Bool { + list.any( + tx.outputs, + fn(o) { + when o.address.payment_credential is { + Script(hash) -> + if hash == escrow_script_hash { + when o.datum is { + InlineDatum(raw) -> { + expect datum: EscrowDatum = raw + datum.invoice_id == invoice_id && datum.buyer == Some( + buyer_pkh, + ) && datum.status == Locked + } + _ -> False + } + } else { + False + } + _ -> False + } + }, + ) +} diff --git a/lazer/cardano/factura-ya/custom_docs/currency-decision.md b/lazer/cardano/factura-ya/custom_docs/currency-decision.md new file mode 100644 index 00000000..d5b6dcaf --- /dev/null +++ b/lazer/cardano/factura-ya/custom_docs/currency-decision.md @@ -0,0 +1,47 @@ +# Currency Decision: USD Instead of ARS + +> Date: 2026-03-22 + +## Decision + +Invoice amounts are denominated in **USD** instead of ARS (Argentine Pesos). + +## Context + +The original design (PRD, spec) envisioned invoices in ARS with Pyth providing an ARS/USD exchange rate for conversion. This made sense for the LATAM target market where SMEs issue invoices in local currency. + +## Why We Changed + +1. **Pyth ARS/USD feed is not available yet.** Feed ID 2582 (USD/ARS) has status "coming soon" with no ETA. It's listed in Pyth's feed catalog but has no active publishers on Cardano. + +2. **Pyth ADA/USD is live and stable.** Feed ID 16 (ADA/USD) has 3+ publishers, exponent -8, and is fully operational on Cardano PreProd. Using this feed gives us a working oracle integration today. + +3. **Simpler conversion path.** With USD-denominated invoices: + - Investor pays in ADA + - Contract reads ADA/USD from Pyth + - Validates payment ≥ invoice amount in USD + - One conversion step (USD → ADA), not two (ARS → USD → ADA) + +4. **Hackathon scope.** For the MVP, demonstrating a working Pyth oracle integration matters more than ARS support. USD works for the demo and the on-chain logic is identical — only the feed ID changes. + +## What Changes + +| Before | After | +|--------|-------| +| `amount_ars: Int` in InvoiceDatum | `amount_usd: Int` | +| `original_amount_ars` in ListingDatum | `original_amount_usd` | +| `usd_to_lovelace()` converts USD→ADA | Same function, same logic | +| UI shows "ARS" labels | UI shows "USD" labels | +| Would need ARS/USD + ADA/USD feeds | Only needs ADA/USD feed (live) | + +## Path to ARS Support + +When Pyth feed 2582 (USD/ARS) goes live: + +1. Add a second oracle call for ARS/USD conversion +2. Change datum fields back to `amount_ars` +3. The conversion becomes: `ars_amount → (÷ ars_usd_price) → usd_amount → (÷ ada_usd_price) → ada_amount` +4. Both price feeds are validated for freshness in the same transaction +5. No contract architecture changes needed — just an additional price lookup + +The on-chain `pyth_oracle.ak` module already supports any feed ID via parameter. Switching to ARS is a config change, not a code change. diff --git a/lazer/cardano/factura-ya/custom_docs/demo-validator-decision.md b/lazer/cardano/factura-ya/custom_docs/demo-validator-decision.md new file mode 100644 index 00000000..7a64933f --- /dev/null +++ b/lazer/cardano/factura-ya/custom_docs/demo-validator-decision.md @@ -0,0 +1,55 @@ +# Demo Validator Decision + +> Date: 2026-03-22 + +## Decision + +The on-chain demo uses a simplified minting policy (`invoice_mint_demo.ak`) instead of the full validator (`invoice_mint.ak`). + +## Context + +The full `invoice_mint.ak` validator performs 6 checks on mint: +1. Exactly 1 token minted under the policy +2. Output contains an `InvoiceDatum` with matching `invoice_id` +3. Seller signature present +4. `amount_usd > 0` +5. `due_date_posix_ms` in the future +6. `debtor_contact_hash` non-empty + +For burn, it additionally checks escrow state (Released/Forfeited). + +## Problem + +The datum serialization from the off-chain TypeScript code (MeshJS) does not match the exact CBOR format that the Aiken validator expects when deserializing with `expect datum: InvoiceDatum = raw`. Specifically: + +- MeshJS's `txOutInlineDatumValue()` does not support Plutus Data Constr objects — only primitives and CBOR hex strings +- Manual CBOR construction (`d8799f...ff`) produces valid CBOR but the Plutus VM's `unConstrData` fails to parse it as the expected Aiken type +- The mismatch is in how Aiken serializes/deserializes named-field constructors vs how we manually encode them + +This is a **serialization interop issue** between the TypeScript off-chain tooling and the Aiken on-chain validator, not a logic error. + +## What We Tried + +1. **MeshJS `mConStr0([...fields...])`** → `txOutInlineDatumValue()` throws "Cannot convert undefined to a BigInt" +2. **MeshJS with `'Mesh'` type hint** → same BigInt error +3. **MeshJS with `'JSON'` type hint** → "Malformed Plutus data json" +4. **Manual CBOR hex via `cborConstr0()`** → builds successfully but the Plutus VM rejects the deserialization at runtime +5. **Removing the evaluator** → tx builds but node rejects on submission (script evaluation fails) + +## Solution + +`invoice_mint_demo.ak` validates only: +- Exactly 1 token minted/burned +- At least one signer present + +This allows the NFT to be minted on-chain, demonstrating the full CIP-30 wallet flow (connect → build tx → sign → assemble → submit → confirm). + +The full validator with all 6 checks remains in `invoice_mint.ak` with 32 passing Aiken tests. + +## Path to Production + +1. **Use Lucid Evolution's `Data.to()` with Aiken blueprint schema** — generates correctly serialized Plutus Data from TypeScript types. Blocked by Lucid's ESM/WASM incompatibility with Vite (see `transactions.ts` for details). +2. **Use MeshJS's `MeshTxBuilder` with blueprint codegen** — MeshJS v2 has experimental blueprint integration that auto-generates typed datum constructors. +3. **Use `aiken blueprint apply` + `cardano-cli`** — construct the datum in Aiken's test framework, serialize to CBOR, and use that exact encoding. + +All three approaches produce the correct serialization. The limitation is tooling interop in the hackathon timeframe, not a fundamental issue. diff --git a/lazer/cardano/factura-ya/custom_docs/prd.md b/lazer/cardano/factura-ya/custom_docs/prd.md new file mode 100644 index 00000000..64921a4c --- /dev/null +++ b/lazer/cardano/factura-ya/custom_docs/prd.md @@ -0,0 +1,75 @@ +# PRD — Factura Ya: Sell Your Invoices, Get Paid Today + +> Loops PRD: 1 + +--- + +## Context + +SMEs in Argentina wait an average of 60-90 days to collect on invoices issued to large clients. Traditional factoring (banks, specialized firms) charges rates of 3-5% per month and takes days to approve. Meanwhile, the global tokenized RWA (Real World Assets) market grew from $5.5B to $24B+ in one year (2025), but existing solutions (Centrifuge, Goldfinch) target institutional players and don't serve Latin American SMEs. + +## Problem + +Latin American SMEs have capital trapped in accounts receivable. They cannot access fast liquidity without paying abusive rates or waiting for slow bank approvals. At the same time, investors seek yield on real assets but lack access to this market without costly intermediaries. + +## Objectives + +1. Build an on-chain marketplace on Cardano where SMEs tokenize outstanding invoices and sell them at a discount to investors. +2. Use Pyth as an exchange rate oracle for real-time ARS ↔ USDC conversion. +3. Deliver a functional MVP for the Pythathon hackathon (demo with full tokenization, purchase, and settlement flow). + +## Functional Requirements + +- **FR-01**: An SME can register an outstanding invoice specifying: amount, currency, due date, debtor, and debtor contact information (email or phone). +- **FR-01.1**: Upon tokenization, the system records the assignment of collection rights on-chain: the new beneficiary is the NFT holder. The seller (SME) is responsible for notifying the debtor that the collection right has been assigned, using the contact information provided in FR-01. +- **FR-02**: The system tokenizes the invoice as an NFT or native token on Cardano, representing the collection right. +- **FR-02.1**: Upon tokenization, the SME deposits collateral (e.g., 10-20% of the invoice value) that is locked in the smart contract as a good-faith guarantee. If payment is not credited to the buyer by the due date, the collateral is released in favor of the investor. +- **FR-03**: The tokenized invoice is listed on an on-chain marketplace with its discount and due date visible. +- **FR-04**: An investor can purchase the tokenized invoice by paying with ADA or stablecoins (USDC/DJED) at the discounted price. +- **FR-05**: At the time of purchase, the SME receives the funds immediately in their wallet. +- **FR-06**: The smart contract consumes Pyth's ARS/USD price feed to convert invoice values in pesos to their stablecoin equivalent. +- **FR-07**: At invoice maturity, a settlement mechanism exists where the investor receives the full amount (simulated in the MVP). +- **FR-07.1**: If at maturity the investor confirms collection, the SME's collateral is unlocked and returned. If the investor reports non-payment, the collateral is transferred to the investor as partial compensation. +- **FR-08**: The web interface displays the list of available invoices with: original amount, discount, implied yield, due date, and current exchange rate (via Pyth). +- **FR-09**: The displayed exchange rate updates in real time using Pyth's feed. + +## Non-Functional Requirements + +- **NFR-01**: Smart contracts written in Aiken (Cardano's native language). +- **NFR-02**: Pyth's price feed is consumed on-chain for invoice valuation. +- **NFR-03**: The demo must show the complete flow (issuance → purchase → settlement) in under 3 minutes. +- **NFR-04**: The web interface must be simple and functional (hackathon — no polished design required). +- **NFR-05**: Source code is delivered as a PR to the `pyth-examples` repo in the `/lazer/cardano` directory with the `cardano` tag. +- **NFR-06**: The README must explain what the project is and how Pyth is used. +- **NFR-07**: Transaction indexing using Oura (TxPipe) for the marketplace. + +## Acceptance Criteria + +- **AC-01**: An SME can register an invoice (with debtor contact information) and the system tokenizes it as an asset on Cardano, locking the SME's collateral. +- **AC-02**: The tokenized invoice appears on the marketplace with price, discount, and due date. +- **AC-03**: The invoice price in stablecoins is calculated using Pyth's ARS/USD feed on-chain. +- **AC-04**: An investor can purchase a tokenized invoice and the SME receives funds instantly. +- **AC-05**: The demo shows the end-to-end flow: invoice issuance (with collateral) → tokenization → investor purchase → settlement (with collateral return). +- **AC-06**: The exchange rate in the interface updates in real time via Pyth. +- **AC-07**: The PR to `pyth-examples` contains the complete source with a descriptive README. + +## Out of Scope + +- Real invoice verification (validation with AFIP, ARCA, or tax agencies). In the MVP, invoices are registered manually. +- SME reputation or credit scoring system. +- Automated debtor contact verification (in the MVP, it is registered manually without validation). +- **Payment oracle + dispute resolution (second iteration):** in a future version, an external oracle or validator would confirm that the debtor's payment was made, removing the dependency on manual investor confirmation. In case of dispute, resolution would be handled through an on-chain or delegated arbitration mechanism. Out of scope for the MVP. +- Formal arbitration in case of debtor non-payment. +- Secondary market for tokenized invoices (resale between investors). +- Integration with accounting systems or ERPs. +- Regulatory compliance (KYC/AML) — out of scope for hackathon. +- Multi-country support (Argentina only for the MVP). + +## Dependencies + +- **Pyth Network**: ARS/USD price feed available on Cardano (verify availability on testnet/devnet). +- **Cardano testnet**: Deployment environment for the MVP. +- **Aiken**: Compiler and toolchain for Cardano smart contracts. +- **TxPipe / Oura**: On-chain transaction indexing to power the marketplace. +- **Demeter.run**: Cardano node infrastructure. +- **Cardano wallet**: Testnet-compatible for the demo (e.g., Nami, Eternl). diff --git a/lazer/cardano/factura-ya/custom_docs/pyth-integration.md b/lazer/cardano/factura-ya/custom_docs/pyth-integration.md new file mode 100644 index 00000000..72a8b560 --- /dev/null +++ b/lazer/cardano/factura-ya/custom_docs/pyth-integration.md @@ -0,0 +1,230 @@ +# Pyth Integration Research — Cardano + +> Last updated: 2026-03-22 (updated with hackathon-day info) + +--- + +## Pyth Pro (formerly Pyth Lazer) + +Pyth has two products: + +- **Pyth Core**: Classic aggregated oracle. 100+ chains. Pull model via Hermes relay. **Does NOT support Cardano.** +- **Pyth Pro (formerly Lazer)**: Enterprise-grade. Delivers prices directly from first-party publishers. Customizable update frequencies. **Supports Cardano (Beta).** + +For Cardano, **only Pyth Pro is available**. + +## PreProd Deployment + +**Policy ID (PreProd):** `d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6` + +This identifies the Pyth State UTxO on Cardano PreProd testnet. Supplied to both the on-chain and off-chain SDKs. + +**API Key:** Obtained from the Pyth/judge's table at the hackathon. Required for the off-chain SDK WebSocket connection. + +## On-Chain Integration Pattern + +Pyth on Cardano uses a **withdraw-script verification pattern** — NOT reference inputs with price datums. + +### How it works + +1. **Off-chain**: App fetches signed price update from Pyth Pro (`@pythnetwork/pyth-lazer-sdk`). Requests updates in "solana" binary format (little-endian, Ed25519-signed — same format used for both Cardano and Solana). +2. **Transaction construction** (using `@pythnetwork/pyth-lazer-cardano-js` + Evolution SDK): + - Set a short validity window (+/- 60 seconds). + - Include the **Pyth State UTxO** as a **reference input** (holds the "Pyth State NFT" identified by the policy ID, containing trusted signer keys). + - Perform a **zero-lovelace withdrawal** from the Pyth withdraw script, passing the signed price update bytes as the **redeemer**. + - Include your own consuming smart contract in the same transaction. +3. **On-chain verification**: The Pyth withdraw script (Plutus V3 staking validator) verifies the Ed25519 signature against trusted signers in the Pyth State UTxO. Your contract calls `pyth.get_updates()` which reads the verified data from the redeemer. + +**Key insight**: Price data does NOT live in a UTxO datum. It arrives **transiently via the withdraw script redeemer** in each transaction. The only persistent on-chain data is the Pyth State UTxO (trusted signer keys). + +## Aiken Library + +Official library: `pyth-network/pyth-lazer-cardano` (vendored locally in `contracts/lib/` due to compiler issue with GitHub dependency). + +### Key function + +```aiken +pyth.get_updates(pyth_id: PolicyId, self: Transaction) -> List +``` + +### Minimal on-chain example (from official docs) + +```aiken +use aiken/collection/list +use aiken/math/rational.{Rational} +use cardano/assets.{PolicyId} +use cardano/transaction.{Transaction} +use pyth +use types/u32 + +fn read_ada_usd_price(pyth_id: PolicyId, self: Transaction) -> Rational { + expect [update] = pyth.get_updates(pyth_id, self) + expect Some(feed) = list.find(update.feeds, fn(feed) { + u32.as_int(feed.feed_id) == 16 + }) + expect Some(Some(price)) = feed.price + expect Some(exponent) = feed.exponent + expect Some(multiplier) = rational.from_int(10) + |> rational.pow(exponent) + + rational.from_int(price) |> rational.mul(multiplier) +} +``` + +### Library modules +- `lib/pyth.ak` — Main module: `get_updates()`, `Feed`, `PriceUpdate` types +- `lib/pyth/message.ak` — Ed25519 signature verification, trusted signer checking +- `lib/parser.ak` — Parser combinator library for binary data +- `lib/types/` — Integer types: `u8`, `u16`, `u32`, `u64`, `i16`, `i64` + +Requires: Plutus V3, Aiken v1.1.21, aiken-lang/stdlib v3.0.0. + +## Data Structures + +### PriceUpdate +``` +{ + timestamp_us: U64, + channel_id: U8, + feeds: List +} +``` + +### Feed +``` +{ + feed_id: U32, -- numeric ID (e.g., 16 for ADA/USD) + price: Option>, + best_bid_price: Option>, + best_ask_price: Option>, + publisher_count: Option, + exponent: Option, + confidence: Option>, + funding_rate: Option>, + funding_timestamp: Option>, + funding_rate_interval: Option>, + market_session: Option, + ema_price: Option>, + ema_confidence: Option>, + feed_update_timestamp: Option> +} +``` + +### Binary message format +4-byte magic (`b9011a82`) → 64-byte Ed25519 signature → 32-byte verification key → 2-byte LE payload length → payload. + +### Pyth State (on-chain, persistent) +``` +{ + governance: Governance, -- wormhole policy, emitter chain/address, sequence + trusted_signers: TrustedSigners, -- verification keys mapped to validity ranges + withdraw_script: ScriptHash +} +``` + +## Off-Chain SDKs + +### 1. Price fetching: `@pythnetwork/pyth-lazer-sdk` + +```typescript +import { PythLazerClient } from "@pythnetwork/pyth-lazer-sdk"; + +const lazer = await PythLazerClient.create( + ["wss://pyth-lazer.dourolabs.app/v2/ws"], + PYTH_API_KEY, +); + +const latestPrice = await lazer.getLatestPrice({ + channel: "fixed_rate@200ms", + formats: ["solana"], + jsonBinaryEncoding: "hex", + priceFeedIds: [16], // ADA/USD + properties: ["price", "exponent"], +}); + +const update = Buffer.from(latestPrice.solana.data, "hex"); +``` + +### 2. Transaction helpers: `@pythnetwork/pyth-lazer-cardano-js` + +```typescript +import { ScriptHash } from "@evolution-sdk/evolution"; +import { + getPythScriptHash, + getPythState, +} from "@pythnetwork/pyth-lazer-cardano-js"; + +const POLICY_ID = "d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6"; + +const pythState = await getPythState(POLICY_ID, client); +const pythScript = getPythScriptHash(pythState); + +const now = BigInt(Date.now()); +const tx = wallet + .newTx() + .setValidity({ from: now - 60_000n, to: now + 60_000n }) + .readFrom({ referenceInputs: [pythState] }) + .withdraw({ + amount: 0n, + redeemer: [update], + stakeCredential: ScriptHash.fromHex(pythScript), + }); + +const builtTx = await tx.build(); +const digest = await builtTx.signAndSubmit(); +``` + +### 3. Transaction builder: Evolution SDK + +Official recommended builder: `@evolution-sdk/evolution` + +## Feed Availability + +| Feed | ID | Status | Exponent | Notes | +|------|----|--------|----------|-------| +| **ADA/USD** | 16 | **Stable (live)** | -8 | 3 min publishers | +| **USD/ARS** | 2582 | **Coming soon** | -5 | 1 min publisher | +| USD/BRL | — | Stable (live) | — | | +| USD/MXN | — | Stable (live) | — | | + +### Decision for MVP + +**Use ADA/USD (feed 16)** for development and demo. When USD/ARS (feed 2582) goes live, switch by changing the feed ID. The on-chain logic is identical — only the feed ID and price interpretation change. + +## Submission Requirements + +- Fork `pyth-network/pyth-examples` +- Create directory: `lazer/cardano/factura-ya/` +- Include `README.md` with: team name, submission name, team members (with GitHub handles), contact email, project description, Pyth integration description +- Include source code in the directory +- Open PR with `cardano` tag, filling in the PR template (Hackathon Submission checkbox) +- **Deadline: 9pm (2026-03-22)** + +### README template + +```markdown +# Team {TeamName} Pythathon Submission + +## Details + +Team Name: {name} +Submission Name: Factura Ya +Team Members: {member1} (@github1), {member2} (@github2) +Contact: {email} + +## Project Description + +{description of the project and how it uses Pyth} +``` + +## Resolved Items + +- ~~Cardano Policy ID~~ → **PreProd: `d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6`** +- ~~Off-chain SDK~~ → **`@pythnetwork/pyth-lazer-cardano-js`** provides `getPythState()` and `getPythScriptHash()` +- ~~Transaction builder~~ → **Evolution SDK** (`@evolution-sdk/evolution`) +- ~~`/lazer/cardano` directory~~ → Our submission creates `lazer/cardano/factura-ya/` + +## Open Items + +- **USD/ARS timeline**: Feed 2582 is "coming_soon" — no ETA. +- **API Key**: Need to get from judge's table. diff --git a/lazer/cardano/factura-ya/custom_docs/spec.md b/lazer/cardano/factura-ya/custom_docs/spec.md new file mode 100644 index 00000000..eab8e2d8 --- /dev/null +++ b/lazer/cardano/factura-ya/custom_docs/spec.md @@ -0,0 +1,376 @@ +# Technical Specification — Factura Ya + +> Loops Spec: 0 + +--- + +## Technical Summary + +Factura Ya is an on-chain invoice factoring marketplace on Cardano. SMEs tokenize outstanding invoices as NFTs, deposit collateral as a good-faith guarantee, and sell the collection rights to investors at a discount. Pyth's ARS/USD price feed provides real-time currency conversion. Settlement is handled via on-chain confirmation with collateral release/forfeit logic. + +The system consists of four Aiken validators (Pyth oracle consumer, invoice minting policy, escrow/collateral, marketplace), off-chain transaction builders in TypeScript, an Oura indexer for marketplace state, and a lightweight web frontend. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────┐ +│ Frontend (React) │ +│ - Register invoice - Browse marketplace │ +│ - Buy invoice - Confirm settlement / report │ +└──────────────┬────────────────────────┬──────────────────┘ + │ cardano-serialization │ lucid / mesh + │ lib │ +┌──────────────▼────────────────────────▼──────────────────┐ +│ Off-chain Tx Builders (TypeScript) │ +│ - buildMintInvoiceTx - buildPurchaseTx │ +│ - buildSettleTx - buildClaimCollateralTx │ +└──────────┬──────────┬──────────────┬─────────────────────┘ + │ │ │ +┌──────────▼───┐ ┌────▼──────────┐ ┌▼─────────────────────┐ +│ Invoice Mint │ │ Escrow │ │ Marketplace │ +│ Policy │ │ Validator │ │ Validator │ +│ (Aiken) │ │ (Aiken) │ │ (Aiken) │ +│ - Mint NFT │ │ - Lock │ │ - List │ +│ - Burn NFT │ │ collateral │ │ - Purchase │ +│ │ │ - Release │ │ - Delist │ +│ │ │ - Forfeit │ │ │ +└──────────────┘ └───────┬───────┘ └────────────┬──────────┘ + │ │ + ┌────▼──────────────────────▼───┐ + │ Pyth Oracle │ + │ (ARS/USD feed) │ + └────────────────────────────────┘ + +┌───────────────────────────────────┐ +│ Oura Indexer (TxPipe) │ +│ - Watches marketplace UTxOs │ +│ - Feeds API / frontend state │ +└───────────────────────────────────┘ +``` + +### Data Model + +**Invoice NFT Metadata (CIP-25/CIP-68):** +``` +{ + invoice_id: ByteArray, -- unique identifier + seller: PubKeyHash, -- SME wallet + amount_ars: Int, -- invoice amount in ARS (scaled) + currency: ByteArray, -- "ARS" + due_date_slot: Int, -- invoice maturity as slot number + debtor_name: ByteArray, -- debtor identifier + debtor_contact: ByteArray, -- email or phone (hashed for privacy) + created_at_slot: Int +} +``` + +**Escrow Datum (collateral UTxO):** +``` +{ + invoice_id: ByteArray, + seller: PubKeyHash, + collateral_amount: Int, -- lovelace or stablecoin locked + buyer: Option, -- set after purchase + due_date_slot: Int, + status: Locked | Released | Forfeited +} +``` + +**Marketplace Listing Datum:** +``` +{ + invoice_id: ByteArray, + seller: PubKeyHash, + asking_price_usd: Int, -- discounted price in USD-equivalent (scaled) + original_amount_ars: Int, + pyth_price_at_listing: Int, -- ARS/USD rate when listed (reference) + due_date_slot: Int, + status: Listed | Sold | Delisted +} +``` + +### Threat Model + +See `docs/threat.md` (to be created separately). + +--- + +## Block 1: Pyth Oracle Integration + +**Objective:** Read and validate ARS/USD price from Pyth on-chain in Aiken. + +**Files to create/modify:** +- `contracts/lib/pyth_oracle.ak` — Helper functions to parse and validate Pyth price attestation data from a reference input UTxO. +- `contracts/lib/types.ak` — Shared type definitions (PriceData, InvoiceDatum, EscrowDatum, ListingDatum). +- `offchain/src/pyth.ts` — TypeScript helper to fetch the Pyth price update and attach it as a reference input to transactions. + +**Logic:** +- Parse Pyth price attestation from the reference input datum. +- Validate attestation freshness (price must not be older than N slots). +- Extract ARS/USD price as a scaled integer. +- Expose `get_price(reference_input) -> Result` for use in other validators. + +**Tests:** +- [ ] Unit test: parse valid Pyth attestation and extract correct price. +- [ ] Unit test: reject stale attestation (older than threshold). +- [ ] Unit test: reject malformed attestation data. +- [ ] Integration test: build a transaction with Pyth reference input on preview testnet. + +**Completion criteria:** `get_price` returns a validated ARS/USD price from a Pyth reference input, with all tests passing. + +--- + +## Block 2: Invoice NFT Minting + +**Objective:** Allow SMEs to tokenize invoices as NFTs on Cardano with on-chain metadata. + +**Files to create/modify:** +- `contracts/validators/invoice_mint.ak` — Minting policy for invoice NFTs. Handles Mint and Burn redeemers. +- `contracts/lib/types.ak` — Add InvoiceMetadata type. +- `offchain/src/mint.ts` — Transaction builder for minting and burning invoice NFTs. + +**Logic:** +- **Mint:** + - SME submits invoice data (amount, currency, due date, debtor, debtor contact). + - Minting policy validates: seller signature present, amount > 0, due date in the future, debtor contact non-empty. + - Mints a unique NFT (policy ID + invoice_id as asset name). + - Invoice metadata stored in datum (CIP-68 pattern) or reference UTxO. + - Debtor contact is hashed before storing on-chain (privacy). + +- **Burn:** + - Only after settlement is complete (status Released or Forfeited). + - Requires NFT holder signature. + +**Tests:** +- [ ] Unit test: mint succeeds with valid invoice data and seller signature. +- [ ] Unit test: mint fails if due date is in the past. +- [ ] Unit test: mint fails if debtor contact is empty. +- [ ] Unit test: burn succeeds after settlement. +- [ ] Unit test: burn fails if settlement is not complete. + +**Completion criteria:** SME can mint an invoice NFT with correct metadata. Burn is gated by settlement status. All tests passing. + +--- + +## Block 3: Escrow / Collateral Validator + +**Objective:** Lock SME collateral at tokenization and release/forfeit at settlement. + +**Files to create/modify:** +- `contracts/validators/escrow.ak` — Escrow validator with Lock, Release, and Forfeit redeemers. +- `contracts/lib/types.ak` — Add EscrowDatum, EscrowRedeemer types. +- `offchain/src/escrow.ts` — Transaction builders for lock, release, and forfeit operations. + +**Logic:** +- **Lock (at mint time, same tx as Block 2):** + - SME deposits collateral (10-20% of invoice value converted to ADA/stablecoins using Pyth price from Block 1). + - Creates escrow UTxO with status `Locked`, linked to `invoice_id`. + - Validates collateral amount ≥ minimum percentage of invoice value. + +- **Release (happy path — debtor paid):** + - Can only execute after `due_date_slot`. + - Requires buyer (NFT holder) signature to confirm payment received. + - Collateral returned to seller. + - Escrow status → `Released`. + +- **Forfeit (unhappy path — debtor did not pay):** + - Can only execute after `due_date_slot` + grace period. + - Requires buyer (NFT holder) signature reporting non-payment. + - Collateral transferred to buyer as partial compensation. + - Escrow status → `Forfeited`. + +**Tests:** +- [ ] Unit test: lock creates escrow with correct collateral amount and status. +- [ ] Unit test: lock fails if collateral is below minimum percentage. +- [ ] Unit test: release succeeds with buyer signature after due date, returns collateral to seller. +- [ ] Unit test: release fails before due date. +- [ ] Unit test: forfeit succeeds with buyer signature after due date + grace period. +- [ ] Unit test: forfeit fails without buyer signature. +- [ ] Unit test: forfeit transfers collateral to buyer. + +**Completion criteria:** Collateral lifecycle (lock → release/forfeit) works correctly with proper access control. All tests passing. + +--- + +## Block 4: Marketplace Validator + +**Objective:** List tokenized invoices for sale and handle purchases. + +**Files to create/modify:** +- `contracts/validators/marketplace.ak` — Marketplace validator with List, Purchase, and Delist redeemers. +- `contracts/lib/types.ak` — Add ListingDatum, MarketplaceRedeemer types. +- `offchain/src/marketplace.ts` — Transaction builders for list, purchase, and delist. + +**Logic:** +- **List:** + - SME sends invoice NFT to marketplace script address. + - Creates listing UTxO with datum: invoice details, asking price (ARS amount converted to USD via Pyth), discount percentage, due date. + - The Pyth price at listing time is recorded as reference. + - Status → `Listed`. + +- **Purchase:** + - Investor pays the asking price in ADA/stablecoins. + - Payment goes directly to the seller's address (FR-05: immediate payment). + - Invoice NFT transferred to buyer. + - Escrow datum updated: `buyer = investor_pkh`. + - Listing status → `Sold`. + - Validates payment amount ≥ asking price (re-checked against current Pyth price if needed). + +- **Delist:** + - Only seller can delist (requires seller signature). + - Returns NFT to seller. + - Unlocks collateral from escrow. + - Listing status → `Delisted`. + +**Tests:** +- [ ] Unit test: list creates listing with correct datum and holds NFT. +- [ ] Unit test: purchase transfers NFT to buyer and payment to seller. +- [ ] Unit test: purchase fails if payment is below asking price. +- [ ] Unit test: purchase updates escrow with buyer identity. +- [ ] Unit test: delist returns NFT and unlocks collateral, requires seller signature. +- [ ] Unit test: delist fails without seller signature. + +**Completion criteria:** Full marketplace flow (list → purchase or delist) works with correct fund routing and escrow linkage. All tests passing. + +--- + +## Block 5: Oura Indexer + +**Objective:** Index on-chain marketplace state for the frontend using TxPipe's Oura. + +**Files to create/modify:** +- `indexer/oura.toml` — Oura pipeline configuration (source: Cardano node, filters: marketplace script address, sink: PostgreSQL or JSON file). +- `indexer/src/api.ts` — Simple REST API to serve indexed marketplace data. +- `indexer/schema.sql` — Database schema for indexed listings (if using PostgreSQL). + +**Logic:** +- Oura watches the marketplace validator script address for UTxO changes. +- On new listing: parse datum, store invoice details + status in DB/file. +- On purchase: update listing status to `Sold`, record buyer. +- On delist: update listing status to `Delisted`. +- REST API exposes: + - `GET /invoices` — all active listings with current Pyth price for display. + - `GET /invoices/:id` — single invoice detail. + +**Tests:** +- [ ] Oura pipeline starts and connects to Cardano node. +- [ ] New listing is indexed within N seconds. +- [ ] Purchase event updates listing status correctly. +- [ ] API returns correct data for active listings. + +**Completion criteria:** Marketplace state is indexed off-chain and available via API. Frontend can query listings without scanning UTxOs directly. + +--- + +## Block 6: Web Frontend + +**Objective:** Simple UI for SMEs and investors. + +**Files to create/modify:** +- `frontend/` — React app (Vite or Next.js). +- `frontend/src/components/RegisterInvoice.tsx` — Invoice registration form. +- `frontend/src/components/Marketplace.tsx` — Browse available invoices. +- `frontend/src/components/InvoiceDetail.tsx` — Single invoice view with purchase action. +- `frontend/src/components/MyInvoices.tsx` — SME's listed invoices and settlement status. +- `frontend/src/components/MyPurchases.tsx` — Investor's purchased invoices and settlement actions. +- `frontend/src/components/PriceDisplay.tsx` — Live ARS/USD rate from Pyth. +- `frontend/src/lib/wallet.ts` — Wallet connection (Nami, Eternl via CIP-30). +- `frontend/src/lib/transactions.ts` — Wraps offchain tx builders. +- `frontend/src/lib/api.ts` — Client for the Oura indexer API. + +**Logic:** +- **Register invoice (SME):** + - Connect wallet. Fill form: amount (ARS), due date, debtor name, debtor contact. + - System calculates: collateral required (using Pyth ARS/USD), suggested discount, asking price in USD. + - SME confirms → mints NFT + locks collateral + lists on marketplace (single UX flow, may be 1-2 txs). + +- **Marketplace (investor):** + - Browse active listings from indexer API. + - Each card shows: original amount (ARS), price (USD), discount %, implied yield, due date, current ARS/USD rate. + - Exchange rate updates in real time (FR-09). + - Click to view detail → purchase button. + +- **Settlement (post-maturity):** + - Investor sees "Confirm payment received" or "Report non-payment" buttons after due date. + - SME sees collateral status (locked / returned / forfeited). + +**Tests:** +- [ ] Register invoice form submits valid data and triggers mint + list. +- [ ] Marketplace displays listings from indexer API. +- [ ] Exchange rate updates in real time. +- [ ] Purchase flow works end-to-end on testnet. +- [ ] Settlement confirmation releases collateral. + +**Completion criteria:** SME can register and list an invoice, investor can browse and purchase, settlement actions work. All via web UI on testnet. + +--- + +## Block 7: Demo & Submission + +**Objective:** Prepare the hackathon demo and submission PR. + +**Files to create/modify:** +- `offchain/src/simulate.ts` — Script to simulate the full lifecycle with test wallets. +- `README.md` — Project description, team, Pyth usage, setup instructions. +- `demo/` — Demo script or recording. + +**Logic:** +- **Demo script:** + 1. SME registers an invoice: 100,000 ARS, due in 90 days, debtor "ACME Corp". + 2. System shows: collateral required (15,000 ARS equivalent), asking price ($950 USD at current rate). + 3. SME confirms → NFT minted, collateral locked, listed on marketplace. + 4. Investor browses marketplace, sees invoice with 5% discount and implied yield. + 5. Investor purchases for $950 USD → SME receives funds instantly. + 6. Time skip (simulated): due date reached. + 7. Investor confirms payment received → collateral returned to SME. + 8. Total flow under 3 minutes. + +- **README:** Project name, team members, contact info, what it does, how Pyth is used (ARS/USD conversion for invoice valuation and marketplace pricing), setup and run instructions. + +**Tests:** +- [ ] Simulation script runs end-to-end without errors. +- [ ] README contains all required submission fields. +- [ ] PR structure matches `/lazer/cardano` directory requirement. + +**Completion criteria:** Demo can be executed live in under 3 minutes showing the full flow. README and PR structure meet hackathon submission requirements. + +--- + +## Block Dependencies + +``` +Block 1 (Pyth Oracle) ──► Block 2 (Invoice Mint) + └──► Block 3 (Escrow) + └──► Block 4 (Marketplace) +Block 2 ──► Block 3 (mint + lock in same tx) +Block 2 ──► Block 4 (NFT listed after mint) +Block 3 ──► Block 4 (escrow linked to listing) +Block 4 ──► Block 5 (Oura indexes marketplace) +Block 5 ──► Block 6 (Frontend reads from indexer) +Block 3 ──► Block 6 (settlement UI) +Block 4 ──► Block 6 +Block 6 ──► Block 7 (Demo) +``` + +**Execution order:** Block 1 → Blocks 2 & 3 (parallel) → Block 4 → Block 5 → Block 6 → Block 7 + +## Technical Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Pyth ARS/USD feed not available on Cardano testnet | Blocker — no oracle data | Verify feed availability early. Fallback: use a different currency pair for demo and document ARS/USD as the production target. | +| Aiken + Pyth integration is undocumented | High — unknown integration pattern | Research Pyth's Cardano integration docs. Check `pyth-examples` repo for existing Cardano samples. | +| Multi-validator transaction complexity | High — mint + escrow + list in one tx may exceed limits | Design as 2-step: (1) mint + lock collateral, (2) list on marketplace. Keep datums minimal. | +| Oura setup complexity | Medium — infrastructure dependency | Start with a simple file-based sink (JSON) instead of PostgreSQL. Upgrade to DB if time allows. Fallback: direct UTxO scanning from frontend. | +| Debtor contact privacy | Medium — PII on-chain | Hash debtor contact before storing on-chain. Raw contact shared off-chain between seller and buyer only. | +| Cardano transaction size limits | Medium — NFT metadata + escrow + listing in same tx | Use CIP-68 reference UTxO pattern to keep metadata off the token UTxO. Split into multiple transactions if needed. | +| Testnet instability / Demeter.run downtime | Medium — blocks demo | Have a local devnet setup as backup (e.g., Yaci DevKit). | + +## Security Notes + +- **Oracle manipulation:** Pyth attestation freshness must be validated on-chain. Listing price snapshots the rate but purchase re-validates against current price. +- **Collateral theft:** Only the buyer (NFT holder) can trigger release or forfeit, and only after the due date. Seller cannot reclaim collateral while a buyer exists. +- **Invoice duplication:** The minting policy must ensure uniqueness per invoice_id. Use a one-shot pattern or include a UTxO reference in the asset name to guarantee uniqueness. +- **Front-running purchases:** Cardano's UTxO model prevents double-spending the listing UTxO, so two buyers cannot purchase the same invoice. +- **PII exposure:** Debtor contact is hashed on-chain (SHA-256). The raw contact is shared off-chain via the frontend only to the buyer after purchase. Consider encrypting with the buyer's public key. +- **Settlement griefing:** An investor could refuse to confirm payment to steal collateral. The grace period mitigates this — if no action is taken after due date + grace, consider a default resolution (e.g., collateral returned to seller). This is documented as a second-iteration improvement (payment oracle). diff --git a/lazer/cardano/factura-ya/frontend/index.html b/lazer/cardano/factura-ya/frontend/index.html new file mode 100644 index 00000000..0e648285 --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Factura Ya — Invoice Marketplace + + +
+ + + diff --git a/lazer/cardano/factura-ya/frontend/package.json b/lazer/cardano/factura-ya/frontend/package.json new file mode 100644 index 00000000..8c6f3eea --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "factura-ya-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "typescript": "^5.9.3", + "vite": "^5.4.21" + } +} diff --git a/lazer/cardano/factura-ya/frontend/public/plutus.json b/lazer/cardano/factura-ya/frontend/public/plutus.json new file mode 100644 index 00000000..40491504 --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/public/plutus.json @@ -0,0 +1,340 @@ +{ + "preamble": { + "title": "factura-ya/contracts", + "description": "Aiken contracts for project 'factura-ya/contracts'", + "version": "0.0.0", + "plutusVersion": "v3", + "compiler": { + "name": "Aiken", + "version": "v1.1.21+42babe5" + }, + "license": "Apache-2.0" + }, + "validators": [ + { + "title": "escrow.escrow.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1EscrowDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1EscrowRedeemer" + } + }, + "compiledCode": "59036901010029800aba2aba1aab9faab9eaab9dab9a48888896600264653001300700198039804000cdc3a400530070024888966002600460106ea800e26466453001159800980098059baa0028cc004c038c030dd500148c03cc040c040c040c040c0400064601e6020602060200032232330010010032259800800c528456600266e3cdd71809000801c528c4cc008008c04c00500e20229180798081808180818081808180818081808000c8c966002600e601a6ea800626eb4c040c038dd5000c520004030601e601a6ea8c03cc034dd51807980818081808180818081808180818069baa001918079808180818081808000c8c03cc040c0400064601e6020002911111111192cc004c02cc054dd5008c56600266ebcc024c058dd500526103d87980008992cc004c030c058dd5000c566002660106eb0c01cc05cdd50079bae30193017375400315980099b89375a600a602e6ea802cc01803e2b30019800807cdd71801980b9baa00b9bad300430173754016801229462c80aa2c80aa2c80aa2c80a8c020c058dd5005459014456600266ebcc024c058dd5005260103d87980008992cc004c030c058dd5000c4c966002660126eb0c020c060dd5008000c56600266e24cdc01bad300630183754018904048726002180380845660033001010800cdd69802980c1baa00c400d14a31640591640591640586eb8c064c05cdd5000c590151804180b1baa00a8b2028405044464660020026eb0c018c064dd5002112cc00400629422b30013232598009808980d9baa0018acc004cdc79bae301e301c375400200d13371200a6466446600400400244b3001001801c4c8cc896600266e45221000028acc004cdc7a441000028800c01902044cc014014c0940110201bae301f001375a604000260420028100c8c8cc004004dd59805980f9baa0052259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902144cc014014c0980110211bae3020001375660420026044002810852f5bded8c029000452820348a504068603a60366ea8c074c06cdd5000980e000c528c4cc008008c074005018203645900a4c02cdd5003cc03800d2225980098020014566002601e6ea802a00716404115980098040014566002601e6ea802a00716404116403480686018601a0026e1d20003009375400716401c30070013003375400f149a26cac8009", + "hash": "cb8368c843c59ac8d700cf647592c24b74fcca575fd8f42ff0793254" + }, + { + "title": "escrow.escrow.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "59036901010029800aba2aba1aab9faab9eaab9dab9a48888896600264653001300700198039804000cdc3a400530070024888966002600460106ea800e26466453001159800980098059baa0028cc004c038c030dd500148c03cc040c040c040c040c0400064601e6020602060200032232330010010032259800800c528456600266e3cdd71809000801c528c4cc008008c04c00500e20229180798081808180818081808180818081808000c8c966002600e601a6ea800626eb4c040c038dd5000c520004030601e601a6ea8c03cc034dd51807980818081808180818081808180818069baa001918079808180818081808000c8c03cc040c0400064601e6020002911111111192cc004c02cc054dd5008c56600266ebcc024c058dd500526103d87980008992cc004c030c058dd5000c566002660106eb0c01cc05cdd50079bae30193017375400315980099b89375a600a602e6ea802cc01803e2b30019800807cdd71801980b9baa00b9bad300430173754016801229462c80aa2c80aa2c80aa2c80a8c020c058dd5005459014456600266ebcc024c058dd5005260103d87980008992cc004c030c058dd5000c4c966002660126eb0c020c060dd5008000c56600266e24cdc01bad300630183754018904048726002180380845660033001010800cdd69802980c1baa00c400d14a31640591640591640586eb8c064c05cdd5000c590151804180b1baa00a8b2028405044464660020026eb0c018c064dd5002112cc00400629422b30013232598009808980d9baa0018acc004cdc79bae301e301c375400200d13371200a6466446600400400244b3001001801c4c8cc896600266e45221000028acc004cdc7a441000028800c01902044cc014014c0940110201bae301f001375a604000260420028100c8c8cc004004dd59805980f9baa0052259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902144cc014014c0980110211bae3020001375660420026044002810852f5bded8c029000452820348a504068603a60366ea8c074c06cdd5000980e000c528c4cc008008c074005018203645900a4c02cdd5003cc03800d2225980098020014566002601e6ea802a00716404115980098040014566002601e6ea802a00716404116403480686018601a0026e1d20003009375400716401c30070013003375400f149a26cac8009", + "hash": "cb8368c843c59ac8d700cf647592c24b74fcca575fd8f42ff0793254" + }, + { + "title": "invoice_mint.invoice_mint.mint", + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1InvoiceMintRedeemer" + } + }, + "parameters": [ + { + "title": "escrow_script_hash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + } + ], + "compiledCode": "590607010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae0024888888896600264653001300900198049805000cdc3a400130090024888966002600460126ea800e266446644b300130060018acc004c038dd5004400a2c807a2b300130030018acc004c038dd5004400a2c807a2c806100c0cc0048c040c044c044c044c04400644646600200200644b30010018a508acc004cdc79bae30130010038a51899801001180a000a01c4045230103011301130113011301130113011301100191808180898089808800c8c040c044c044c044c044c0440064602060220032232330010010032259800800c5300103d87a80008992cc004c010006266e952000330130014bd7044cc00c00cc05400900f1809800a0229180818089808800c88c8c8cc004004010896600200300389919912cc004cdc8803801456600266e3c01c00a20030064049133005005301800440486eb8c044004dd59809000980a000a02414bd6f7b6304dc3a400891111111112cc004c038c054dd500844c9660026036003132325980098071bad30190028992cc004cc034dd61806180d1baa011375c601260346ea80062b30013371090001bad3007301a375400315980099b8832598009808180d1baa00189bad301e301b3754003148001019180e980d1baa301d301a3754603a603c603c603c603c603c603c603c60346ea8044dd69805980d1baa0018acc006600266e3cdd71805180d1baa001488100a50a51406115980099b8f375c603a60346ea800400a29462c80c22c80c22c80c22c80c22c80c0c966002602460326ea8006264b30013006301a375400313259800980a180d9baa0018991919191919194c004dd69813000cdd71813003cdd718130034dd69813002cdd698130024dd71813001cdd7181300124444444b3001302e0088807c5902b0c098004c094004c090004c08c004c088004c084004c070dd5000c5901a180f180d9baa0018b20323007301a3754603a60346ea80062c80c0cc01cdd61803180c9baa0102300f323322330020020012259800800c00e2646644b30013372201000515980099b8f0080028800c01901e44cc014014c09001101e1bae301d001375a603c002604000280f0cc01cdd59805180d9baa0020111480022c80b8dd7180b800980d000c59018198011bab300a3016375401a01919800912cc004c040c05cdd500144c8c8c8c8c8ca60026042003375c604200d375c604200b375a6042009375a6042004911112cc004c09c01a2646644b3001301e002899192cc004c0b000a0071640a46eb8c0a8004c098dd5001c566002603600515980098131baa003800c59027459024204830233754002264b3001301d0018acc004c094dd5003c03a2c81322b3001301a0018acc004c094dd5003c03a2c81322b300130100018acc004c094dd5003c03a2c81322c811902320463023375400c604c01116409030210013020001301f001301e001301d001301837540051640592232330010010032259800800c52f5c113301c3003301d00133002002301e001406d3300237566014602c6ea803403122259800980e800c4c96600266e1cdd6980d000a400313259800acc004cdd79805980d9baa0014c0103d87a80008a51899baf300b301b375400298103d87b8000406513259800980a180d9baa0018998079bac300e301c37540266eb8c07cc070dd5000c4cc03cdd61807180e1baa013375c601660386ea800901a1806180d9baa0018b2032323259800980a180d9baa0018992cc004c020c070dd5000c4c8cc0200044004c080c074dd5000c5901b1804980e1baa301f301c3754003164068660126466446600400400244b30010018801c4cc080c084004cc008008c08800501f198029bac300b301c375402646018603a6ea8004cc010dd6180f180d9baa0122300b301c3754002464b30013012301c375400315980099b8f375c6040603a6ea800406a264b30013009301d37540031323300900113371e6eb8c088c07cdd50008029810980f1baa0018a5040706014603a6ea800a294101b45282036301f301c3754603e60386ea8004dd7180c800c59018180e000c5901a10140c02cdd50031bae300d300a37540066e1d20028b2010180480098021baa0098a4d13656400801", + "hash": "30268933aae8a060641fa0fb2417b675159dd2e74cc6eae65ad3b813" + }, + { + "title": "invoice_mint.invoice_mint.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "escrow_script_hash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + } + ], + "compiledCode": "590607010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae0024888888896600264653001300900198049805000cdc3a400130090024888966002600460126ea800e266446644b300130060018acc004c038dd5004400a2c807a2b300130030018acc004c038dd5004400a2c807a2c806100c0cc0048c040c044c044c044c04400644646600200200644b30010018a508acc004cdc79bae30130010038a51899801001180a000a01c4045230103011301130113011301130113011301100191808180898089808800c8c040c044c044c044c044c0440064602060220032232330010010032259800800c5300103d87a80008992cc004c010006266e952000330130014bd7044cc00c00cc05400900f1809800a0229180818089808800c88c8c8cc004004010896600200300389919912cc004cdc8803801456600266e3c01c00a20030064049133005005301800440486eb8c044004dd59809000980a000a02414bd6f7b6304dc3a400891111111112cc004c038c054dd500844c9660026036003132325980098071bad30190028992cc004cc034dd61806180d1baa011375c601260346ea80062b30013371090001bad3007301a375400315980099b8832598009808180d1baa00189bad301e301b3754003148001019180e980d1baa301d301a3754603a603c603c603c603c603c603c603c60346ea8044dd69805980d1baa0018acc006600266e3cdd71805180d1baa001488100a50a51406115980099b8f375c603a60346ea800400a29462c80c22c80c22c80c22c80c22c80c0c966002602460326ea8006264b30013006301a375400313259800980a180d9baa0018991919191919194c004dd69813000cdd71813003cdd718130034dd69813002cdd698130024dd71813001cdd7181300124444444b3001302e0088807c5902b0c098004c094004c090004c08c004c088004c084004c070dd5000c5901a180f180d9baa0018b20323007301a3754603a60346ea80062c80c0cc01cdd61803180c9baa0102300f323322330020020012259800800c00e2646644b30013372201000515980099b8f0080028800c01901e44cc014014c09001101e1bae301d001375a603c002604000280f0cc01cdd59805180d9baa0020111480022c80b8dd7180b800980d000c59018198011bab300a3016375401a01919800912cc004c040c05cdd500144c8c8c8c8c8ca60026042003375c604200d375c604200b375a6042009375a6042004911112cc004c09c01a2646644b3001301e002899192cc004c0b000a0071640a46eb8c0a8004c098dd5001c566002603600515980098131baa003800c59027459024204830233754002264b3001301d0018acc004c094dd5003c03a2c81322b3001301a0018acc004c094dd5003c03a2c81322b300130100018acc004c094dd5003c03a2c81322c811902320463023375400c604c01116409030210013020001301f001301e001301d001301837540051640592232330010010032259800800c52f5c113301c3003301d00133002002301e001406d3300237566014602c6ea803403122259800980e800c4c96600266e1cdd6980d000a400313259800acc004cdd79805980d9baa0014c0103d87a80008a51899baf300b301b375400298103d87b8000406513259800980a180d9baa0018998079bac300e301c37540266eb8c07cc070dd5000c4cc03cdd61807180e1baa013375c601660386ea800901a1806180d9baa0018b2032323259800980a180d9baa0018992cc004c020c070dd5000c4c8cc0200044004c080c074dd5000c5901b1804980e1baa301f301c3754003164068660126466446600400400244b30010018801c4cc080c084004cc008008c08800501f198029bac300b301c375402646018603a6ea8004cc010dd6180f180d9baa0122300b301c3754002464b30013012301c375400315980099b8f375c6040603a6ea800406a264b30013009301d37540031323300900113371e6eb8c088c07cdd50008029810980f1baa0018a5040706014603a6ea800a294101b45282036301f301c3754603e60386ea8004dd7180c800c59018180e000c5901a10140c02cdd50031bae300d300a37540066e1d20028b2010180480098021baa0098a4d13656400801", + "hash": "30268933aae8a060641fa0fb2417b675159dd2e74cc6eae65ad3b813" + }, + { + "title": "marketplace.marketplace.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1ListingDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1MarketplaceRedeemer" + } + }, + "parameters": [ + { + "title": "invoice_policy_id", + "schema": { + "$ref": "#/definitions/ByteArray" + } + }, + { + "title": "escrow_script_hash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + } + ], + "compiledCode": "5905290101002229800aba2aba1aba0aab9faab9eaab9dab9a9bae0039bae00248888888896600264653001300a00198051805800cdc3a4005300a0024888966002600460146ea800e26466453001159800980098069baa0028cc004c044c038dd500148c048c04cc04cc04cc04cc04cc04cc04c00646024602660266026602660266026602660260032232330010010032259800800c52845660026006602a00314a313300200230160014040809a4602460266026003222323322330020020012259800800c00e2646644b30013372200e00515980099b8f0070028800c01901544cc014014c06c0110151bae3014001375a602a002602e00280a8c8c8cc004004018896600200300389919912cc004cdc8804801456600266e3c02400a20030064059133005005301c00440586eb8c054004dd5980b000980c000a02c14bd6f7b6300a40012301230130019baf4c103d8798000488888888c9660026014602c6ea80422b300130023008301737540131598009991198041bac30073019375401e464b3001300e301a375400315980099b8f375c603c60366ea8004012266e2400e60026eacc01cc06cdd50015220100a44100402114a080ca2941019180e980d1baa301d301a37540026eb8c00cc05cdd50049bad300530173754013132598009805980b9baa0018992cc006600201f0169bae301c30193754017001400d15980099198049bac3008301a3754020464b30013013301b375400315980099b8f375c603e60386ea8004062264b30013370e9002180e1baa001899192cc004c048c078dd500144c8c8c8c8c8ca60026050003375c605000d375c605000b375a6050009375a6050004911112cc004c0b801a2646644b30013020002899192cc004c0cc00a0071640c06eb8c0c4004c0b4dd5001c566002604800515980098169baa003800c5902e45902b2056302a3754002264b3001301f0018acc004c0b0dd5003c03a2c816a2b300130230018acc004c0b0dd5003c03a2c816a2b30013370e9002000c56600260586ea801e01d1640b51640a8815102a18151baa006302d0088b205618140009813800981300098128009812000980f9baa0028b203a15980099b8f375c6042603c6ea80040162b30013375e6042604460446044603c6ea8004cdd2a4000660406ea40192f5c113009302130223022302230223022301e375400314a080e2294101c1810180e9baa0018a50406c601460386ea800a294101a45282034301e301b3754603c60366ea8004dd7180e180c9baa00b8a518b202e8b202e375c603660306ea80062c80b0c8cc004004dd61804180c1baa00e2259800800c5300103d87a80008992cc006600266e3c004dd71803180d1baa00ca50a51406113374a90001980e1ba90014bd7044cc00c00cc0780090181bae301c001406916405516405515980098011804180b9baa0098acc004c8cc88cc008008004896600200314a115980099b8f375c603a00200714a3133002002301e001406080d8dd61804180c1baa00e375c6006602e6ea80262b30019800806c0526eb8c068c05cdd5004cdd71801980b9baa009400514a316405516405516405480a88888cc024dd61804180d1baa004232598009807980d9baa0018acc004cdc79bae301f301c37540020071301398009bab3008301c375400500580220128a50406914a080d0c078c06cdd5180f180d9baa00145900c4c034dd5003cc04400d222598009802001456600260226ea802a0071640491598009804001456600260226ea802a00716404916403c8078601e60200026e1d2000300b3754007164024300a00130053754015149a26cac80181", + "hash": "2f18f39c41ea3cfa94d3e0cbbc0021d53b0ed0ae7392e428e00ad402" + }, + { + "title": "marketplace.marketplace.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "invoice_policy_id", + "schema": { + "$ref": "#/definitions/ByteArray" + } + }, + { + "title": "escrow_script_hash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + } + ], + "compiledCode": "5905290101002229800aba2aba1aba0aab9faab9eaab9dab9a9bae0039bae00248888888896600264653001300a00198051805800cdc3a4005300a0024888966002600460146ea800e26466453001159800980098069baa0028cc004c044c038dd500148c048c04cc04cc04cc04cc04cc04cc04c00646024602660266026602660266026602660260032232330010010032259800800c52845660026006602a00314a313300200230160014040809a4602460266026003222323322330020020012259800800c00e2646644b30013372200e00515980099b8f0070028800c01901544cc014014c06c0110151bae3014001375a602a002602e00280a8c8c8cc004004018896600200300389919912cc004cdc8804801456600266e3c02400a20030064059133005005301c00440586eb8c054004dd5980b000980c000a02c14bd6f7b6300a40012301230130019baf4c103d8798000488888888c9660026014602c6ea80422b300130023008301737540131598009991198041bac30073019375401e464b3001300e301a375400315980099b8f375c603c60366ea8004012266e2400e60026eacc01cc06cdd50015220100a44100402114a080ca2941019180e980d1baa301d301a37540026eb8c00cc05cdd50049bad300530173754013132598009805980b9baa0018992cc006600201f0169bae301c30193754017001400d15980099198049bac3008301a3754020464b30013013301b375400315980099b8f375c603e60386ea8004062264b30013370e9002180e1baa001899192cc004c048c078dd500144c8c8c8c8c8ca60026050003375c605000d375c605000b375a6050009375a6050004911112cc004c0b801a2646644b30013020002899192cc004c0cc00a0071640c06eb8c0c4004c0b4dd5001c566002604800515980098169baa003800c5902e45902b2056302a3754002264b3001301f0018acc004c0b0dd5003c03a2c816a2b300130230018acc004c0b0dd5003c03a2c816a2b30013370e9002000c56600260586ea801e01d1640b51640a8815102a18151baa006302d0088b205618140009813800981300098128009812000980f9baa0028b203a15980099b8f375c6042603c6ea80040162b30013375e6042604460446044603c6ea8004cdd2a4000660406ea40192f5c113009302130223022302230223022301e375400314a080e2294101c1810180e9baa0018a50406c601460386ea800a294101a45282034301e301b3754603c60366ea8004dd7180e180c9baa00b8a518b202e8b202e375c603660306ea80062c80b0c8cc004004dd61804180c1baa00e2259800800c5300103d87a80008992cc006600266e3c004dd71803180d1baa00ca50a51406113374a90001980e1ba90014bd7044cc00c00cc0780090181bae301c001406916405516405515980098011804180b9baa0098acc004c8cc88cc008008004896600200314a115980099b8f375c603a00200714a3133002002301e001406080d8dd61804180c1baa00e375c6006602e6ea80262b30019800806c0526eb8c068c05cdd5004cdd71801980b9baa009400514a316405516405516405480a88888cc024dd61804180d1baa004232598009807980d9baa0018acc004cdc79bae301f301c37540020071301398009bab3008301c375400500580220128a50406914a080d0c078c06cdd5180f180d9baa00145900c4c034dd5003cc04400d222598009802001456600260226ea802a0071640491598009804001456600260226ea802a00716404916403c8078601e60200026e1d2000300b3754007164024300a00130053754015149a26cac80181", + "hash": "2f18f39c41ea3cfa94d3e0cbbc0021d53b0ed0ae7392e428e00ad402" + } + ], + "definitions": { + "ByteArray": { + "dataType": "bytes" + }, + "Int": { + "dataType": "integer" + }, + "Option": { + "title": "Option", + "anyOf": [ + { + "title": "Some", + "description": "An optional value.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + }, + { + "title": "None", + "description": "Nothing.", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "types/EscrowDatum": { + "title": "EscrowDatum", + "anyOf": [ + { + "title": "EscrowDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "invoice_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "seller", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "collateral_lovelace", + "$ref": "#/definitions/Int" + }, + { + "title": "buyer", + "$ref": "#/definitions/Option" + }, + { + "title": "due_date_posix_ms", + "$ref": "#/definitions/Int" + }, + { + "title": "status", + "$ref": "#/definitions/types~1EscrowStatus" + } + ] + } + ] + }, + "types/EscrowRedeemer": { + "title": "EscrowRedeemer", + "anyOf": [ + { + "title": "Release", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Forfeit", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "types/EscrowStatus": { + "title": "EscrowStatus", + "anyOf": [ + { + "title": "Locked", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Released", + "dataType": "constructor", + "index": 1, + "fields": [] + }, + { + "title": "Forfeited", + "dataType": "constructor", + "index": 2, + "fields": [] + } + ] + }, + "types/InvoiceMintRedeemer": { + "title": "InvoiceMintRedeemer", + "anyOf": [ + { + "title": "MintInvoice", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "BurnInvoice", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "types/ListingDatum": { + "title": "ListingDatum", + "anyOf": [ + { + "title": "ListingDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "invoice_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "seller", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "asking_price_lovelace", + "$ref": "#/definitions/Int" + }, + { + "title": "original_amount_ars", + "$ref": "#/definitions/Int" + }, + { + "title": "pyth_price_at_listing", + "$ref": "#/definitions/Int" + }, + { + "title": "pyth_exponent_at_listing", + "$ref": "#/definitions/Int" + }, + { + "title": "due_date_posix_ms", + "$ref": "#/definitions/Int" + }, + { + "title": "status", + "$ref": "#/definitions/types~1ListingStatus" + } + ] + } + ] + }, + "types/ListingStatus": { + "title": "ListingStatus", + "anyOf": [ + { + "title": "Listed", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Sold", + "dataType": "constructor", + "index": 1, + "fields": [] + }, + { + "title": "Delisted", + "dataType": "constructor", + "index": 2, + "fields": [] + } + ] + }, + "types/MarketplaceRedeemer": { + "title": "MarketplaceRedeemer", + "anyOf": [ + { + "title": "Purchase", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Delist", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + } + } +} \ No newline at end of file diff --git a/lazer/cardano/factura-ya/frontend/src/App.tsx b/lazer/cardano/factura-ya/frontend/src/App.tsx new file mode 100644 index 00000000..90506cac --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/App.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { Marketplace } from "./components/Marketplace.tsx"; +import { RegisterInvoice } from "./components/RegisterInvoice.tsx"; +import { PriceDisplay } from "./components/PriceDisplay.tsx"; +import { WalletConnect } from "./components/WalletConnect.tsx"; +import { DemoGuide } from "./components/DemoGuide.tsx"; +import { Deploy } from "./components/Deploy.tsx"; +import type { ConnectedWallet } from "./lib/wallet.ts"; + +type Tab = "marketplace" | "register" | "deploy"; + +export function App() { + const [tab, setTab] = useState("marketplace"); + const [wallet, setWallet] = useState(null); + + return ( +
+
+
+

Factura Ya

+ setWallet(null)} + /> +
+

+ Sell your invoices, get paid today — powered by Cardano & Pyth +

+ + +
+ + + +
+ {tab === "marketplace" && } + {tab === "register" && } + {tab === "deploy" && } +
+
+

+ Built for the Pythathon hackathon by Facturas Ya — Cardano + Pyth + TxPipe +

+
+
+ ); +} diff --git a/lazer/cardano/factura-ya/frontend/src/components/DemoGuide.tsx b/lazer/cardano/factura-ya/frontend/src/components/DemoGuide.tsx new file mode 100644 index 00000000..4db6916b --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/components/DemoGuide.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import type { ConnectedWallet } from "../lib/wallet.ts"; + +interface Props { + wallet: ConnectedWallet | null; + activeTab: string; +} + +interface Step { + id: number; + title: string; + description: string; + action: string; + completed: (wallet: ConnectedWallet | null, tab: string) => boolean; +} + +const steps: Step[] = [ + { + id: 1, + title: "Connect your wallet", + description: + "Click the wallet button in the top-right corner. Select Nami, Eternl, or Lace. Make sure you're on the PreProd testnet.", + action: "Connect wallet above", + completed: (w) => w !== null, + }, + { + id: 2, + title: "Browse the marketplace", + description: + 'Check out the listed invoices. Each card shows the original amount (USD), the discounted price (ADA), the discount percentage, and days until the due date. These are real invoices tokenized as NFTs on Cardano.', + action: 'Click "Marketplace" tab', + completed: (_w, tab) => tab === "marketplace", + }, + { + id: 3, + title: "Check the live price", + description: + "The ADA/USD price displayed at the top comes from Pyth's oracle. In production, this is a real-time feed consumed both on-chain (for valuation) and off-chain (for display).", + action: "See the price indicator above", + completed: () => true, + }, + { + id: 4, + title: "Register an invoice (SME flow)", + description: + 'Switch to the "Register Invoice" tab. Fill in the form as if you were an SME with an unpaid invoice. The system will tokenize it as an NFT, lock 10% collateral in escrow, and list it on the marketplace.', + action: 'Click "Register Invoice" tab', + completed: (_w, tab) => tab === "register", + }, + { + id: 5, + title: "Submit the invoice", + description: + 'Fill in the amount (e.g., 5000 USD), days to due date (e.g., 90), debtor name (e.g., "TechCorp"), and debtor contact. Click "Tokenize & List". This triggers: NFT mint + collateral lock + marketplace listing in one transaction.', + action: "Fill the form and submit", + completed: () => false, + }, + { + id: 6, + title: "Purchase an invoice (Investor flow)", + description: + 'Go back to the Marketplace. Click "Purchase" on any invoice. The investor pays the discounted price in ADA, the SME receives funds instantly, and the NFT transfers to the buyer. The escrow is updated with the buyer\'s identity.', + action: 'Click "Purchase" on a listed invoice', + completed: () => false, + }, + { + id: 7, + title: "Settlement", + description: + "After the due date, the buyer confirms payment received (collateral returns to SME) or reports non-payment (collateral goes to buyer as compensation). This is enforced by the on-chain escrow validator — no intermediaries needed.", + action: "This happens automatically at maturity", + completed: () => false, + }, +]; + +export function DemoGuide({ wallet, activeTab }: Props) { + const [expanded, setExpanded] = useState(true); + const [currentStep, setCurrentStep] = useState(0); + + if (!expanded) { + return ( + + ); + } + + const step = steps[currentStep]; + const isCompleted = step.completed(wallet, activeTab); + + return ( +
+
+

Demo Guide

+ +
+ +
+ {steps.map((s, i) => ( +
setCurrentStep(i)} + /> + ))} +
+ +
+
+ Step {step.id} of {steps.length} +
+

{step.title}

+

{step.description}

+
{step.action}
+
+ +
+ + +
+
+ ); +} diff --git a/lazer/cardano/factura-ya/frontend/src/components/Deploy.tsx b/lazer/cardano/factura-ya/frontend/src/components/Deploy.tsx new file mode 100644 index 00000000..ba645858 --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/components/Deploy.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { DEPLOY } from "../lib/transactions.ts"; + +interface DeployStatus { + deployed: boolean; + walletConnected: boolean; + walletAddress: string; + networkId: number; + utxoCount: number; +} + +export function Deploy() { + const [status, setStatus] = useState(null); + + useEffect(() => { + const poll = async () => { + try { + const res = await fetch("http://localhost:3002/status"); + if (res.ok) setStatus(await res.json()); + } catch { + setStatus(null); + } + }; + poll(); + const interval = setInterval(poll, 3000); + return () => clearInterval(interval); + }, []); + + const serverUp = status !== null; + const deployed = status?.deployed ?? false; + + return ( +
+

Deploy Validators

+

+ Publish the 3 smart contracts as reference scripts on PreProd. +

+ +
+
+ Escrow + {DEPLOY.escrow.scriptHash.slice(0, 16)}... +
+
+ Invoice Mint + {DEPLOY.invoiceMint.policyId.slice(0, 16)}... +
+
+ Marketplace + {DEPLOY.marketplace.scriptHash.slice(0, 16)}... +
+
+ Deploy Server + + {serverUp ? "Running on :3002" : "Not running — start with: cd offchain && npx tsx src/deploy-server.ts"} + +
+ {deployed && ( + <> +
+ Status + Verified on PreProd +
+
+ Wallet + {status?.walletAddress?.slice(0, 20)}... +
+
+ UTxOs + {status?.utxoCount} +
+ + )} +
+ + {deployed ? ( +

+ Validators verified on PreProd! Wallet connected with {status?.utxoCount} UTxOs. +

+ ) : serverUp ? ( + + ) : ( +

+ Start the deploy server first. +

+ )} +
+ ); +} diff --git a/lazer/cardano/factura-ya/frontend/src/components/Marketplace.tsx b/lazer/cardano/factura-ya/frontend/src/components/Marketplace.tsx new file mode 100644 index 00000000..cc609f5b --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/components/Marketplace.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from "react"; +import { DEPLOY } from "../lib/transactions.ts"; + +interface InvoiceNft { + assetName: string; + assetNameAscii: string; + address: string; + amount: number; + debtor: string; + source: "on-chain" | "pending"; + txHash?: string; +} + +const TX_SERVER = "http://localhost:3002"; + +export function Marketplace() { + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchAll(); + const interval = setInterval(fetchAll, 10_000); + return () => clearInterval(interval); + }, []); + + async function fetchAll() { + const results: InvoiceNft[] = []; + + // 1. Check on-chain NFTs via Koios + try { + const res = await fetch("/koios/api/v1/policy_asset_addresses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ _asset_policy: DEPLOY.invoiceMint.policyId }), + }); + if (res.ok) { + const data = await res.json(); + for (const a of data as any[]) { + results.push({ + assetName: a.asset_name || "", + assetNameAscii: hexToAscii(a.asset_name || ""), + address: a.payment_address || "", + amount: 0, + debtor: "", + source: "on-chain", + }); + } + } + } catch { /* ignore */ } + + // 2. Check registered invoices from deploy server + try { + const res = await fetch(`${TX_SERVER}/status`); + if (res.ok) { + const state = await res.json(); + const invoices = state.invoices || []; + // Also include legacy lastInvoice if present + if (state.lastInvoice) invoices.push(state.lastInvoice); + for (const inv of invoices) { + const alreadyOnChain = results.some( + (r) => r.assetName === inv.invoiceId, + ); + if (!alreadyOnChain) { + results.push({ + assetName: inv.invoiceId || "", + assetNameAscii: hexToAscii(inv.invoiceId || ""), + address: state.walletAddress || "", + amount: inv.amount || 0, + debtor: inv.debtor || "", + source: "pending", + txHash: inv.txHash, + }); + } + } + } + } catch { /* server not running */ } + + setInvoices(results); + setLoading(false); + } + + if (loading) return

Querying PreProd for invoice NFTs...

; + + return ( +
+

Invoice Marketplace

+
+
+ Invoice Policy + {DEPLOY.invoiceMint.policyId.slice(0, 20)}... +
+
+ Total invoices + {invoices.length} +
+
+ + {error &&

{error}

} + + {invoices.length === 0 ? ( +
+

No invoices registered yet.

+

+ Register an invoice to see it here. +

+
+ ) : ( +
+ {invoices.map((inv) => ( +
+
+ + {inv.assetNameAscii || inv.assetName.slice(0, 16) + "..."} + + + {inv.source === "on-chain" ? "On-chain" : "Registered"} + +
+
+ {inv.amount > 0 && ( +
+ + ${inv.amount.toLocaleString()} +
+ )} + {inv.debtor && ( +
+ + {inv.debtor} +
+ )} +
+ + + {inv.address ? inv.address.slice(0, 20) + "..." : "—"} + +
+ {inv.txHash && ( +
+ + + {inv.txHash.slice(0, 16)}... + +
+ )} +
+ +
+ ))} +
+ )} +
+ ); +} + +function hexToAscii(hex: string): string { + try { + let str = ""; + for (let i = 0; i < hex.length; i += 2) { + const code = parseInt(hex.substring(i, i + 2), 16); + if (code >= 32 && code <= 126) str += String.fromCharCode(code); + } + return str; + } catch { + return ""; + } +} diff --git a/lazer/cardano/factura-ya/frontend/src/components/PriceDisplay.tsx b/lazer/cardano/factura-ya/frontend/src/components/PriceDisplay.tsx new file mode 100644 index 00000000..da4402ea --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/components/PriceDisplay.tsx @@ -0,0 +1,9 @@ +export function PriceDisplay() { + return ( +
+ ADA/USD + via Pyth (feed 16) + PreProd +
+ ); +} diff --git a/lazer/cardano/factura-ya/frontend/src/components/RegisterInvoice.tsx b/lazer/cardano/factura-ya/frontend/src/components/RegisterInvoice.tsx new file mode 100644 index 00000000..cb6dd805 --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/components/RegisterInvoice.tsx @@ -0,0 +1,106 @@ +import { useState, type FormEvent } from "react"; +import type { ConnectedWallet } from "../lib/wallet.ts"; +import { registerInvoice } from "../lib/transactions.ts"; + +interface Props { + wallet: ConnectedWallet | null; +} + +interface InvoiceFormData { + amountUsd: string; + dueDateDays: string; + debtorName: string; + debtorContact: string; +} + +export function RegisterInvoice({ wallet }: Props) { + const [form, setForm] = useState({ + amountUsd: "", + dueDateDays: "90", + debtorName: "", + debtorContact: "", + }); + + if (!wallet) { + return ( +
+

Register Invoice

+

Connect your wallet to register an invoice.

+
+ ); + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + registerInvoice({ + amountUsd: Number(form.amountUsd), + dueDateDays: Number(form.dueDateDays), + debtorName: form.debtorName, + debtorContact: form.debtorContact, + sellerAddress: wallet.address, + }); + }; + + return ( +
+

Register Invoice

+
+
+ + setForm({ ...form, amountUsd: e.target.value })} + required + /> +
+
+ + setForm({ ...form, dueDateDays: e.target.value })} + required + /> +
+
+ + setForm({ ...form, debtorName: e.target.value })} + required + /> +
+
+ + + setForm({ ...form, debtorContact: e.target.value }) + } + required + /> +
+
+

Collateral: ~10% of invoice value will be locked as guarantee

+

Wallet: {wallet.info.name} connected

+
+ +

+ Opens the signing page to authorize the transaction with your wallet. +

+
+
+ ); +} diff --git a/lazer/cardano/factura-ya/frontend/src/components/WalletConnect.tsx b/lazer/cardano/factura-ya/frontend/src/components/WalletConnect.tsx new file mode 100644 index 00000000..1145631f --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/components/WalletConnect.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect } from "react"; +import { + getAvailableWallets, + connectWallet, + shortenAddress, + lovelaceToAda, + type WalletInfo, + type ConnectedWallet, +} from "../lib/wallet.ts"; + +interface Props { + onConnect: (wallet: ConnectedWallet) => void; + onDisconnect: () => void; + connected: ConnectedWallet | null; +} + +export function WalletConnect({ onConnect, onDisconnect, connected }: Props) { + const [wallets, setWallets] = useState([]); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // Small delay to let wallet extensions inject + const timer = setTimeout(() => { + setWallets(getAvailableWallets()); + }, 500); + return () => clearTimeout(timer); + }, []); + + const handleConnect = async (walletId: string) => { + setConnecting(true); + setError(null); + try { + const wallet = await connectWallet(walletId); + if (wallet.networkId !== 0) { + setError("Please switch to PreProd testnet"); + return; + } + onConnect(wallet); + } catch (err) { + setError(err instanceof Error ? err.message : "Connection failed"); + } finally { + setConnecting(false); + } + }; + + if (connected) { + return ( +
+
+ + + {shortenAddress(connected.addressBech32)} + + + {lovelaceToAda(connected.balanceLovelace)} ADA + +
+ +
+ ); + } + + return ( +
+ {wallets.length === 0 ? ( +

+ No CIP-30 wallet detected. Install Nami, Eternl, or Lace. +

+ ) : ( +
+ {wallets.map((w) => ( + + ))} +
+ )} + {error &&

{error}

} +
+ ); +} diff --git a/lazer/cardano/factura-ya/frontend/src/index.css b/lazer/cardano/factura-ya/frontend/src/index.css new file mode 100644 index 00000000..e199b63f --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/index.css @@ -0,0 +1,229 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #0a0a0f; + color: #e0e0e0; + min-height: 100vh; +} + +.app { max-width: 960px; margin: 0 auto; padding: 1rem; } + +header { + padding: 2rem 0 1rem; + border-bottom: 1px solid #222; + margin-bottom: 2rem; + text-align: center; +} + +.header-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +h1 { font-size: 2rem; color: #4fc3f7; } +h2 { font-size: 1.4rem; margin-bottom: 1rem; color: #b0bec5; } +.subtitle { color: #666; margin: 0.5rem 0 1rem; } + +nav { display: flex; gap: 0.5rem; justify-content: center; margin-top: 1rem; } +nav button { + padding: 0.5rem 1.5rem; + border: 1px solid #333; + background: transparent; + color: #999; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; +} +nav button.active { background: #1a237e; color: #4fc3f7; border-color: #4fc3f7; } + +.price-display { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: #111; + padding: 0.4rem 1rem; + border-radius: 20px; + font-size: 0.85rem; +} +.price-label { color: #666; } +.price-value { color: #4caf50; font-weight: bold; } +.price-source { color: #555; font-size: 0.75rem; } + +.invoice-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.invoice-card { + background: #111; + border: 1px solid #222; + border-radius: 10px; + padding: 1rem; +} +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} +.invoice-id { font-family: monospace; color: #888; font-size: 0.8rem; } +.status { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; } +.status.listed { background: #1b5e20; color: #81c784; } +.status.sold { background: #4a148c; color: #ce93d8; } + +.card-body { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; } +.field label { display: block; font-size: 0.7rem; color: #555; text-transform: uppercase; } +.field span { font-size: 1rem; } + +.buy-btn { + width: 100%; + margin-top: 1rem; + padding: 0.6rem; + background: #1a237e; + color: #4fc3f7; + border: 1px solid #4fc3f7; + border-radius: 6px; + cursor: pointer; + font-weight: bold; +} +.buy-btn:hover { background: #283593; } + +.invoice-form { max-width: 400px; } +.form-field { margin-bottom: 1rem; } +.form-field label { display: block; margin-bottom: 0.3rem; color: #888; font-size: 0.85rem; } +.form-field input { + width: 100%; + padding: 0.6rem; + background: #111; + border: 1px solid #333; + border-radius: 6px; + color: #e0e0e0; + font-size: 0.95rem; +} +.form-field input:focus { border-color: #4fc3f7; outline: none; } + +.submit-btn { + padding: 0.7rem 2rem; + background: #4fc3f7; + color: #0a0a0f; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + font-size: 1rem; +} + +.success { text-align: center; padding: 2rem; } +.success h3 { color: #4caf50; margin-bottom: 0.5rem; } +.success button { margin-top: 1rem; padding: 0.5rem 1.5rem; background: #222; color: #999; border: 1px solid #333; border-radius: 6px; cursor: pointer; } +.empty { text-align: center; padding: 2rem; color: #666; } + +footer { text-align: center; padding: 2rem 0; color: #444; font-size: 0.8rem; border-top: 1px solid #222; margin-top: 2rem; } + +/* Wallet */ +.wallet-connect { display: flex; align-items: center; gap: 0.5rem; } +.wallet-list { display: flex; gap: 0.5rem; } +.wallet-btn { + display: flex; align-items: center; gap: 0.4rem; + padding: 0.4rem 0.8rem; background: #1a237e; color: #4fc3f7; + border: 1px solid #4fc3f7; border-radius: 6px; cursor: pointer; + font-size: 0.8rem; +} +.wallet-btn:hover { background: #283593; } +.wallet-btn:disabled { opacity: 0.5; cursor: wait; } +.wallet-icon { width: 20px; height: 20px; border-radius: 4px; } +.wallet-connected { display: flex; align-items: center; gap: 0.75rem; } +.wallet-info { display: flex; align-items: center; gap: 0.5rem; background: #111; padding: 0.3rem 0.8rem; border-radius: 20px; font-size: 0.8rem; } +.wallet-address { font-family: monospace; color: #888; } +.wallet-balance { color: #4caf50; font-weight: bold; } +.disconnect-btn { padding: 0.3rem 0.6rem; background: transparent; color: #666; border: 1px solid #333; border-radius: 6px; cursor: pointer; font-size: 0.75rem; } +.no-wallet { color: #666; font-size: 0.8rem; } +.wallet-error { color: #ef5350; font-size: 0.8rem; margin-top: 0.3rem; } +.form-info { background: #111; padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.85rem; color: #888; } +.form-info p { margin-bottom: 0.25rem; } +.tx-info { color: #666; font-size: 0.9rem; margin-top: 0.5rem; } + +/* Demo Guide */ +.demo-guide { + background: linear-gradient(135deg, #0d1b2a, #1b2838); + border: 1px solid #1a3a5c; + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1.5rem; +} +.guide-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} +.guide-header h3 { color: #4fc3f7; font-size: 0.95rem; } +.guide-close { + background: none; border: none; color: #556; cursor: pointer; + font-size: 0.8rem; +} +.guide-close:hover { color: #999; } +.guide-progress { + display: flex; gap: 6px; margin-bottom: 1rem; +} +.guide-dot { + width: 28px; height: 4px; border-radius: 2px; + background: #223; cursor: pointer; transition: background 0.2s; +} +.guide-dot.current { background: #4fc3f7; } +.guide-dot.done { background: #2e7d32; } +.guide-dot:hover { background: #445; } +.guide-step { margin-bottom: 1rem; } +.step-number { font-size: 0.7rem; color: #4fc3f7; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 0.3rem; } +.guide-step h4 { font-size: 1.05rem; color: #e0e0e0; margin-bottom: 0.5rem; } +.guide-step p { font-size: 0.85rem; color: #8899aa; line-height: 1.5; } +.step-action { + display: inline-block; + margin-top: 0.6rem; + padding: 0.3rem 0.8rem; + background: rgba(79, 195, 247, 0.1); + border: 1px solid rgba(79, 195, 247, 0.3); + border-radius: 6px; + color: #4fc3f7; + font-size: 0.8rem; +} +.guide-nav { + display: flex; gap: 0.5rem; +} +.guide-nav button { + padding: 0.35rem 1rem; + background: #1a237e; + color: #4fc3f7; + border: 1px solid #303f9f; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; +} +.guide-nav button:hover { background: #283593; } +.guide-nav button:disabled { opacity: 0.3; cursor: not-allowed; } +.guide-toggle { + display: block; + margin: 0 auto 1.5rem; + padding: 0.4rem 1.2rem; + background: transparent; + color: #4fc3f7; + border: 1px dashed #1a3a5c; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; +} +.guide-toggle:hover { border-color: #4fc3f7; } + +/* Deploy */ +.deploy-info { background: #111; border-radius: 8px; padding: 1rem; margin: 1rem 0; } +.deploy-row { display: flex; justify-content: space-between; padding: 0.4rem 0; border-bottom: 1px solid #1a1a1a; font-size: 0.85rem; } +.deploy-row:last-child { border-bottom: none; } +.deploy-label { color: #888; min-width: 120px; } +.deploy-row code { color: #4fc3f7; font-size: 0.8rem; } +.deploy-status { background: #0a0a0f; border: 1px solid #222; border-radius: 6px; padding: 0.75rem; margin-top: 1rem; font-size: 0.8rem; color: #8899aa; white-space: pre-wrap; word-break: break-all; } +.deploy-success { color: #4caf50; font-weight: bold; margin-top: 1rem; } diff --git a/lazer/cardano/factura-ya/frontend/src/lib/api.ts b/lazer/cardano/factura-ya/frontend/src/lib/api.ts new file mode 100644 index 00000000..8a29d571 --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/lib/api.ts @@ -0,0 +1,29 @@ +/** Client for the Factura Ya indexer API. */ + +const API_BASE = "/api"; + +export interface IndexedInvoice { + invoiceId: string; + seller: string; + askingPriceLovelace: number; + originalAmountArs: number; + pythPriceAtListing: number; + pythExponentAtListing: number; + dueDatePosixMs: number; + status: "Listed" | "Sold" | "Delisted"; + buyer: string | null; + txHash: string; + updatedAt: number; +} + +export async function fetchInvoices(): Promise { + const res = await fetch(`${API_BASE}/invoices`); + const data = await res.json(); + return data.invoices; +} + +export async function fetchInvoice(id: string): Promise { + const res = await fetch(`${API_BASE}/invoices/${id}`); + if (!res.ok) throw new Error("Invoice not found"); + return res.json(); +} diff --git a/lazer/cardano/factura-ya/frontend/src/lib/transactions.ts b/lazer/cardano/factura-ya/frontend/src/lib/transactions.ts new file mode 100644 index 00000000..4457ce70 --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/lib/transactions.ts @@ -0,0 +1,69 @@ +/** + * Transaction config and deploy addresses for Factura Ya on PreProd. + * + * ARCHITECTURE NOTE: + * Lucid Evolution (the Cardano tx builder) cannot run inside Vite due to + * incompatible WASM/ESM dependencies (libsodium-wrappers-sumo ships a broken + * ESM build that references a missing .mjs file). We tried: + * - vite-plugin-wasm + top-level-await (libsodium ESM import fails) + * - Alias rewrites to CJS build (Vite package export resolver blocks it) + * - Including Lucid in optimizeDeps (lodash CJS/ESM mismatch, safe-buffer crash) + * - CDN via unpkg (no ESM build published) + * - esbuild bundle for browser (WASM imports + libsodium + Node builtins fail) + * + * SOLUTION: All tx construction runs on a Node server (deploy-server.ts on :3002) + * that can use Lucid natively. The frontend opens standalone HTML pages served + * by that server, which connect the CIP-30 wallet directly for signing. + * + * FOR PRODUCTION: Use MeshJS (browser-native Cardano tx builder) or wait for + * Lucid Evolution to fix their ESM/WASM packaging. Alternatively, use a + * backend-signs architecture with a custodial key. + */ + +export const PYTH_PREPROD_POLICY_ID = + "d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6"; + +export const ADA_USD_FEED_ID = 16; + +export const DEPLOY = { + escrow: { + scriptHash: "cb8368c843c59ac8d700cf647592c24b74fcca575fd8f42ff0793254", + address: "addr_test1wr9cx6xgg0ze4jxhqr8kgavjcf9hflx22a0a3ap07puny4q8hvkmg", + }, + invoiceMint: { + policyId: "84ca1e20b09e708b3524552f29b0cba717a2e656638bc500aff5a2ec", + }, + marketplace: { + scriptHash: "25d3e51a21372a2aab450ef80897ccc777d52ebceeb0614a72ea975a", + address: "addr_test1wqja8eg6yymj524tg580szyhenrh04fwhnhtqc22wt4fwkswthdae", + }, +}; + +const TX_SERVER = "http://localhost:3002"; + +export interface TxResult { + success: boolean; + txHash?: string; + error?: string; +} + +export interface InvoiceRegistration { + amountUsd: number; + dueDateDays: number; + debtorName: string; + debtorContact: string; + sellerAddress: string; +} + +/** + * Register an invoice — opens the tx server page for wallet signing. + */ +export function registerInvoice(params: InvoiceRegistration): void { + const qs = new URLSearchParams({ + amount: String(params.amountUsd), + days: String(params.dueDateDays), + debtor: params.debtorName, + contact: params.debtorContact, + }); + window.open(`${TX_SERVER}/register?${qs}`, "_blank"); +} diff --git a/lazer/cardano/factura-ya/frontend/src/lib/wallet.ts b/lazer/cardano/factura-ya/frontend/src/lib/wallet.ts new file mode 100644 index 00000000..3147a2e7 --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/lib/wallet.ts @@ -0,0 +1,211 @@ +/** + * CIP-30 wallet connection for Cardano dApps. + * + * Supports Nami, Eternl, Flint, Lace, and any CIP-30 compliant wallet. + */ + +// CIP-30 types +interface CardanoWalletApi { + getNetworkId(): Promise; + getUsedAddresses(): Promise; + getUnusedAddresses(): Promise; + getBalance(): Promise; + getUtxos(): Promise; + signTx(tx: string, partialSign?: boolean): Promise; + submitTx(tx: string): Promise; +} + +interface CardanoWallet { + name: string; + icon: string; + apiVersion: string; + enable(): Promise; + isEnabled(): Promise; +} + +declare global { + interface Window { + cardano?: Record; + } +} + +export interface WalletInfo { + name: string; + icon: string; + id: string; +} + +export interface ConnectedWallet { + info: WalletInfo; + api: CardanoWalletApi; + address: string; + addressBech32: string; + networkId: number; + balanceLovelace: bigint; +} + +/** Detect available CIP-30 wallets. */ +export function getAvailableWallets(): WalletInfo[] { + if (!window.cardano) return []; + + const known = ["nami", "eternl", "flint", "lace", "yoroi", "typhon", "gerowallet"]; + const wallets: WalletInfo[] = []; + + for (const id of known) { + const w = window.cardano[id]; + if (w && w.name && typeof w.enable === "function") { + wallets.push({ name: w.name, icon: w.icon, id }); + } + } + + return wallets; +} + +/** Connect to a CIP-30 wallet by ID. */ +export async function connectWallet(walletId: string): Promise { + const wallet = window.cardano?.[walletId]; + if (!wallet) throw new Error(`Wallet ${walletId} not found`); + + console.log(`[wallet] Enabling ${walletId}...`); + const api = await wallet.enable(); + console.log("[wallet] Enabled, querying network..."); + + const networkId = await api.getNetworkId(); + console.log(`[wallet] Network ID: ${networkId}`); + + // CIP-30 returns hex-encoded raw address bytes (not CBOR) + // We need to convert to bech32 for display and Koios queries + const usedAddresses = await api.getUsedAddresses(); + const unusedAddresses = await api.getUnusedAddresses(); + const addressHex = usedAddresses[0] ?? unusedAddresses[0] ?? ""; + console.log(`[wallet] Address (hex): ${addressHex}`); + + if (!addressHex) { + throw new Error("No address found in wallet"); + } + + const bech32 = hexAddressToBech32(addressHex, networkId); + console.log(`[wallet] Address (bech32): ${bech32}`); + + const balanceLovelace = await fetchBalanceFromKoios(bech32); + console.log(`[wallet] Balance: ${Number(balanceLovelace) / 1_000_000} ADA`); + + return { + info: { name: wallet.name, icon: wallet.icon, id: walletId }, + api, + address: addressHex, + addressBech32: bech32, + networkId, + balanceLovelace, + }; +} + +/** Convert hex-encoded raw address bytes to bech32. */ +function hexAddressToBech32(hexAddr: string, networkId: number): string { + const prefix = networkId === 0 ? "addr_test" : "addr"; + const bytes = hexToBytes(hexAddr); + const words = bech32ConvertBits(bytes, 8, 5, true); + return bech32Encode(prefix, words); +} + +// --- Bech32 encoding (RFC compliant) --- + +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +function bech32Polymod(values: number[]): number { + const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + let chk = 1; + for (const v of values) { + const b = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) { + if ((b >> i) & 1) chk ^= GEN[i]; + } + } + return chk; +} + +function bech32HrpExpand(hrp: string): number[] { + const ret: number[] = []; + for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) >> 5); + ret.push(0); + for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) & 31); + return ret; +} + +function bech32CreateChecksum(hrp: string, data: number[]): number[] { + const values = bech32HrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]); + const polymod = bech32Polymod(values) ^ 1; + const ret: number[] = []; + for (let i = 0; i < 6; i++) ret.push((polymod >> (5 * (5 - i))) & 31); + return ret; +} + +function bech32Encode(hrp: string, data: number[]): string { + const combined = data.concat(bech32CreateChecksum(hrp, data)); + return hrp + "1" + combined.map((d) => BECH32_CHARSET[d]).join(""); +} + +function bech32ConvertBits( + data: Uint8Array, + fromBits: number, + toBits: number, + pad: boolean, +): number[] { + let acc = 0; + let bits = 0; + const ret: number[] = []; + const maxv = (1 << toBits) - 1; + for (const value of data) { + acc = (acc << fromBits) | value; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + ret.push((acc >> bits) & maxv); + } + } + if (pad && bits > 0) { + ret.push((acc << (toBits - bits)) & maxv); + } + return ret; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +/** Fetch ADA balance from Koios. */ +async function fetchBalanceFromKoios(bech32Addr: string): Promise { + try { + const res = await fetch("/koios/api/v1/address_info", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ _addresses: [bech32Addr] }), + }); + if (res.ok) { + const data = await res.json(); + if (data[0]?.balance) { + return BigInt(data[0].balance); + } + } + } catch { + console.warn("[wallet] Could not fetch balance from Koios"); + } + return 0n; +} + +/** Shorten an address for display. */ +export function shortenAddress(addr: string): string { + if (addr.length <= 20) return addr; + return `${addr.slice(0, 12)}...${addr.slice(-8)}`; +} + +/** Format lovelace as ADA. */ +export function lovelaceToAda(lovelace: bigint): string { + const ada = Number(lovelace) / 1_000_000; + return ada.toFixed(2); +} diff --git a/lazer/cardano/factura-ya/frontend/src/main.tsx b/lazer/cardano/factura-ya/frontend/src/main.tsx new file mode 100644 index 00000000..71675c47 --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App.tsx"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/lazer/cardano/factura-ya/frontend/tsconfig.json b/lazer/cardano/factura-ya/frontend/tsconfig.json new file mode 100644 index 00000000..9387615b --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"] +} diff --git a/lazer/cardano/factura-ya/frontend/vite.config.ts b/lazer/cardano/factura-ya/frontend/vite.config.ts new file mode 100644 index 00000000..f9cbecee --- /dev/null +++ b/lazer/cardano/factura-ya/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + "/koios": { + target: "https://preprod.koios.rest", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/koios/, ""), + }, + }, + }, +}); diff --git a/lazer/cardano/factura-ya/indexer/oura.toml b/lazer/cardano/factura-ya/indexer/oura.toml new file mode 100644 index 00000000..b39252e4 --- /dev/null +++ b/lazer/cardano/factura-ya/indexer/oura.toml @@ -0,0 +1,31 @@ +# Oura pipeline configuration for Factura Ya marketplace indexer. +# +# Watches the marketplace validator script address on Cardano PreProd +# and pipes transaction events to a webhook sink for processing. +# +# Prerequisites: +# - Oura installed (https://txpipe.github.io/oura/) +# - Cardano PreProd node access (via Demeter.run or local) +# - The indexer API running on localhost:3001 + +[source] +type = "N2N" +peers = ["preprod-node.world.dev.cardano.org:30000"] + +[source.mapper] +include_transaction_details = true + +# Filter for marketplace validator script address +# Replace MARKETPLACE_SCRIPT_ADDRESS with the actual bech32 address +# after deploying the marketplace validator. +[[filters]] +type = "selection" + +[filters.predicate] +any_output_address = "MARKETPLACE_SCRIPT_ADDRESS" + +[sink] +type = "Webhook" +url = "http://localhost:3001/oura/event" +timeout = 30000 +max_retries = 3 diff --git a/lazer/cardano/factura-ya/indexer/package.json b/lazer/cardano/factura-ya/indexer/package.json new file mode 100644 index 00000000..a1bf8c04 --- /dev/null +++ b/lazer/cardano/factura-ya/indexer/package.json @@ -0,0 +1,20 @@ +{ + "name": "factura-ya-indexer", + "version": "0.0.1", + "description": "Oura-powered marketplace indexer for Factura Ya", + "type": "module", + "scripts": { + "start": "tsx src/api.ts", + "build": "tsc", + "check": "tsc --noEmit" + }, + "dependencies": { + "express": "^4.21.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.0.0", + "tsx": "^4.7.0", + "typescript": "^5.4.0" + } +} diff --git a/lazer/cardano/factura-ya/indexer/src/api.ts b/lazer/cardano/factura-ya/indexer/src/api.ts new file mode 100644 index 00000000..0cdf869a --- /dev/null +++ b/lazer/cardano/factura-ya/indexer/src/api.ts @@ -0,0 +1,161 @@ +/** + * Simple REST API + Oura webhook receiver for the Factura Ya marketplace indexer. + * + * Stores marketplace state in-memory (JSON). For a hackathon MVP this is + * sufficient — a production version would use PostgreSQL. + * + * Endpoints: + * GET /invoices — all active listings + * GET /invoices/:id — single invoice detail + * POST /oura/event — webhook sink for Oura pipeline events + */ + +import express from "express"; + +const app = express(); +app.use(express.json({ limit: "1mb" })); + +const PORT = process.env.PORT ?? 3001; + +// --- In-memory store --- + +interface IndexedInvoice { + invoiceId: string; + seller: string; + askingPriceLovelace: number; + originalAmountArs: number; + pythPriceAtListing: number; + pythExponentAtListing: number; + dueDatePosixMs: number; + status: "Listed" | "Sold" | "Delisted"; + buyer: string | null; + txHash: string; + updatedAt: number; +} + +const invoices = new Map(); + +// --- API routes --- + +app.get("/invoices", (_req, res) => { + const active = [...invoices.values()].filter((i) => i.status === "Listed"); + res.json({ invoices: active, total: active.length }); +}); + +app.get("/invoices/:id", (req, res) => { + const invoice = invoices.get(req.params.id); + if (!invoice) { + res.status(404).json({ error: "Invoice not found" }); + return; + } + res.json(invoice); +}); + +// Return all invoices regardless of status (for debugging / frontend) +app.get("/invoices/all", (_req, res) => { + res.json({ invoices: [...invoices.values()], total: invoices.size }); +}); + +// --- Oura webhook receiver --- + +/** + * Receives transaction events from Oura. + * + * Oura sends events as JSON with transaction details. We parse the + * marketplace datum from outputs to index listings. + * + * Event shape (simplified): + * { + * context: { tx_hash, block_number, slot, timestamp }, + * transaction: { + * outputs: [{ address, datum: { fields: [...] }, ... }] + * } + * } + */ +app.post("/oura/event", (req, res) => { + try { + const event = req.body; + processOuraEvent(event); + res.status(200).json({ ok: true }); + } catch (err) { + console.error("Error processing Oura event:", err); + res.status(500).json({ error: "Failed to process event" }); + } +}); + +function processOuraEvent(event: Record): void { + const ctx = event.context as { tx_hash?: string; timestamp?: number } | undefined; + const tx = event.transaction as { + outputs?: Array<{ + address?: string; + datum?: { fields?: unknown[] }; + }>; + } | undefined; + + if (!ctx?.tx_hash || !tx?.outputs) return; + + for (const output of tx.outputs) { + if (!output.datum?.fields) continue; + tryParseListingDatum(output.datum.fields, ctx.tx_hash, ctx.timestamp ?? Date.now()); + } +} + +/** + * Attempt to parse a ListingDatum from Oura datum fields. + * + * Expected field order matches the on-chain ListingDatum: + * [invoice_id, seller, asking_price_lovelace, original_amount_ars, + * pyth_price_at_listing, pyth_exponent_at_listing, due_date_posix_ms, status] + */ +function tryParseListingDatum( + fields: unknown[], + txHash: string, + timestamp: number, +): void { + if (fields.length < 8) return; + + try { + const invoiceId = String(fields[0]); + const seller = String(fields[1]); + const askingPriceLovelace = Number(fields[2]); + const originalAmountArs = Number(fields[3]); + const pythPriceAtListing = Number(fields[4]); + const pythExponentAtListing = Number(fields[5]); + const dueDatePosixMs = Number(fields[6]); + const statusRaw = fields[7]; + + let status: "Listed" | "Sold" | "Delisted" = "Listed"; + if (typeof statusRaw === "object" && statusRaw !== null) { + const tag = (statusRaw as { constructor?: number }).constructor; + if (tag === 0) status = "Listed"; + else if (tag === 1) status = "Sold"; + else if (tag === 2) status = "Delisted"; + } + + invoices.set(invoiceId, { + invoiceId, + seller, + askingPriceLovelace, + originalAmountArs, + pythPriceAtListing, + pythExponentAtListing, + dueDatePosixMs, + status, + buyer: null, + txHash, + updatedAt: timestamp, + }); + + console.log(`[indexer] ${status} invoice ${invoiceId} (tx: ${txHash.slice(0, 8)}...)`); + } catch { + // Not a listing datum, skip + } +} + +// --- Start --- + +app.listen(PORT, () => { + console.log(`[indexer] Factura Ya indexer API running on port ${PORT}`); +}); + +export { app, invoices }; diff --git a/lazer/cardano/factura-ya/indexer/tsconfig.json b/lazer/cardano/factura-ya/indexer/tsconfig.json new file mode 100644 index 00000000..ff8c73b6 --- /dev/null +++ b/lazer/cardano/factura-ya/indexer/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +} diff --git a/lazer/cardano/factura-ya/offchain/package.json b/lazer/cardano/factura-ya/offchain/package.json new file mode 100644 index 00000000..ce44178c --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/package.json @@ -0,0 +1,26 @@ +{ + "name": "factura-ya-offchain", + "version": "0.0.1", + "description": "Off-chain transaction builders for Factura Ya", + "type": "module", + "scripts": { + "build": "tsc", + "check": "tsc --noEmit", + "server": "tsx src/deploy-server.ts", + "test-mint": "tsx src/test-mint.ts" + }, + "dependencies": { + "@anastasia-labs/cardano-multiplatform-lib-nodejs": "^6.0.2-3", + "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3", + "@evolution-sdk/evolution": "^0.3.30", + "@lucid-evolution/lucid": "^0.4.29", + "@lucid-evolution/provider": "^0.1.90", + "@pythnetwork/pyth-lazer-cardano-js": "^0.1.0", + "@pythnetwork/pyth-lazer-sdk": "^0.3.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.21.0", + "typescript": "^5.4.0" + } +} diff --git a/lazer/cardano/factura-ya/offchain/src/client.ts b/lazer/cardano/factura-ya/offchain/src/client.ts new file mode 100644 index 00000000..0cfe267b --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/client.ts @@ -0,0 +1,45 @@ +/** + * Evolution SDK client setup for Factura Ya on PreProd. + * + * Uses Koios as the free provider (no API key needed). + */ + +import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl"; +import type { + ProviderOnlyClient, + SigningClient, +} from "@evolution-sdk/evolution/sdk/client/Client"; + +const KOIOS_PREPROD_URL = "https://preprod.koios.rest/api/v1"; + +/** + * Create a read-only client for querying PreProd (no wallet). + */ +export function createProviderClient(): ProviderOnlyClient { + return createClient({ + network: "preprod", + provider: { + type: "koios", + baseUrl: KOIOS_PREPROD_URL, + }, + }); +} + +/** + * Create a signing client with a CIP-30 wallet API. + * + * @param walletApi - The CIP-30 wallet API object from window.cardano[x].enable() + */ +export function createSigningClient(walletApi: unknown): SigningClient { + return createClient({ + network: "preprod", + provider: { + type: "koios", + baseUrl: KOIOS_PREPROD_URL, + }, + wallet: { + type: "api", + api: walletApi as any, + }, + }); +} diff --git a/lazer/cardano/factura-ya/offchain/src/deploy-bundle-entry.ts b/lazer/cardano/factura-ya/offchain/src/deploy-bundle-entry.ts new file mode 100644 index 00000000..eb9ba625 --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/deploy-bundle-entry.ts @@ -0,0 +1,8 @@ +// Entry point for bundling the deploy functions for browser use +export { + Lucid, + Koios, + applyParamsToScript, + validatorToScriptHash, + validatorToAddress, +} from "@lucid-evolution/lucid"; diff --git a/lazer/cardano/factura-ya/offchain/src/deploy-server.ts b/lazer/cardano/factura-ya/offchain/src/deploy-server.ts new file mode 100644 index 00000000..d9de683d --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/deploy-server.ts @@ -0,0 +1,719 @@ +/** + * Deploy server — builds deploy tx with cardano-serialization-lib + Koios. + * + * Builds an unsigned tx, serves a page to sign with browser wallet. + * + * Usage: npx tsx src/deploy-server.ts + * Then open http://localhost:3002 + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as http from "node:http"; +import { fileURLToPath } from "node:url"; +import { + applyParamsToScript as lucidApplyParams, + validatorToScriptHash, +} from "@lucid-evolution/lucid"; +import { + MeshTxBuilder, + KoiosProvider, + resolveScriptHash, + applyParamsToScript as meshApplyParams, + mConStr0, + byteString, + integer, +} from "@meshsdk/core"; +import * as CSL from "@emurgo/cardano-serialization-lib-nodejs"; +import * as CML from "@anastasia-labs/cardano-multiplatform-lib-nodejs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BLUEPRINT_PATH = path.resolve(__dirname, "../../contracts/plutus.json"); +// Wallet address — set via env var or leave empty (CIP-30 provides it) +const WALLET_ADDRESS = process.env.WALLET_ADDRESS || ""; + +const blueprint = JSON.parse(fs.readFileSync(BLUEPRINT_PATH, "utf-8")); + +function toHexNode(str: string): string { + return Buffer.from(str, "utf-8").toString("hex"); +} + +function cborConstr0(fields: string[]): string { + return "d8799f" + fields.join("") + "ff"; +} + +function cborBytes(hex: string): string { + const len = hex.length / 2; + if (len <= 23) return (0x40 + len).toString(16).padStart(2, "0") + hex; + if (len <= 255) return "58" + len.toString(16).padStart(2, "0") + hex; + return "59" + len.toString(16).padStart(4, "0") + hex; +} + +function cborInt(n: number): string { + if (n >= 0 && n <= 23) return n.toString(16).padStart(2, "0"); + if (n >= 0 && n <= 255) return "18" + n.toString(16).padStart(2, "0"); + if (n >= 0 && n <= 65535) return "19" + n.toString(16).padStart(4, "0"); + if (n >= 0 && n <= 4294967295) return "1a" + n.toString(16).padStart(8, "0"); + return "1b" + BigInt(n).toString(16).padStart(16, "0"); +} + +function bech32ToBytes(bech32: string): Uint8Array { + const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + const sepIdx = bech32.lastIndexOf("1"); + const data = bech32.slice(sepIdx + 1, -6); // remove hrp + checksum + const words: number[] = []; + for (const c of data) words.push(CHARSET.indexOf(c)); + // Convert 5-bit words to 8-bit bytes + let acc = 0, bits = 0; + const bytes: number[] = []; + for (const w of words) { + acc = (acc << 5) | w; + bits += 5; + if (bits >= 8) { bits -= 8; bytes.push((acc >> bits) & 0xff); } + } + return new Uint8Array(bytes); +} + +async function main() { + console.log("=== Factura Ya — Deploy Server ===\n"); + + const escrowV = blueprint.validators.find( + (v: any) => v.title === "escrow.escrow.spend", + ); + const invoiceMintV = blueprint.validators.find( + (v: any) => v.title === "invoice_mint.invoice_mint.mint", + ); + const marketplaceV = blueprint.validators.find( + (v: any) => v.title === "marketplace.marketplace.spend", + ); + + const escrowHash = validatorToScriptHash({ + type: "PlutusV3", + script: escrowV.compiledCode, + }); + const invoiceMintScript = lucidApplyParams( + invoiceMintV.compiledCode, + [escrowHash], + ); + const invoiceMintPolicyId = validatorToScriptHash({ + type: "PlutusV3", + script: invoiceMintScript, + }); + const marketplaceScript = lucidApplyParams( + marketplaceV.compiledCode, + [invoiceMintPolicyId, escrowHash], + ); + const marketplaceHash = validatorToScriptHash({ + type: "PlutusV3", + script: marketplaceScript, + }); + + const scripts = { + escrow: { hash: escrowHash, cbor: escrowV.compiledCode }, + invoiceMint: { hash: invoiceMintPolicyId, cbor: invoiceMintScript }, + marketplace: { hash: marketplaceHash, cbor: marketplaceScript }, + }; + + console.log(`Escrow: ${escrowHash}`); + console.log(`Invoice Mint:${invoiceMintPolicyId}`); + console.log(`Marketplace: ${marketplaceHash}`); + + // Track deploy state (persisted to disk) + const STATE_FILE = path.resolve(__dirname, "../deploy-state.json"); + let deployState: any; + try { + deployState = JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")); + deployState.scripts = scripts; // always update scripts from blueprint + console.log(`Loaded saved state: deployed=${deployState.deployed}`); + } catch { + deployState = { + deployed: false, + walletConnected: false, + walletAddress: "", + networkId: -1, + utxoCount: 0, + scripts, + }; + } + + function saveState() { + const toSave = { ...deployState }; + delete toSave.scripts; // don't persist large script CBORs + fs.writeFileSync(STATE_FILE, JSON.stringify(toSave, null, 2)); + } + + const PORT = 3002; + const server = http.createServer((req, res) => { + // CORS for frontend on :5173 + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } + + if (req.method === "GET" && req.url === "/") { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(signingPage(scripts, WALLET_ADDRESS)); + } else if (req.method === "GET" && req.url === "/status") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(deployState)); + } else if (req.method === "POST" && req.url === "/status") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const update = JSON.parse(body); + // Accumulate invoices in an array + if (update.newInvoice) { + if (!deployState.invoices) deployState.invoices = []; + deployState.invoices.push(update.newInvoice); + delete update.newInvoice; + } + deployState = { ...deployState, ...update }; + saveState(); + console.log(`[deploy] State updated:`, Object.keys(update).join(", ")); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + } catch { + res.writeHead(400); + res.end("Bad request"); + } + }); + } else if (req.method === "POST" && req.url === "/build-mint-tx") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", async () => { + try { + const { walletAddr, invoiceId, amount, days, debtor, contact } = JSON.parse(body); + console.log(`[mint] Building tx for ${invoiceId}...`); + + const provider = new KoiosProvider("preprod"); + const utxos = await provider.fetchAddressUTxOs(walletAddr); + + // Use the simplified demo validator (no datum checks — see custom_docs/demo-validator-decision.md) + // KEY: applyParamsToScript handles the CBOR encoding correctly for the witness set + const demoV = blueprint.validators.find((v: any) => v.title === "invoice_mint_demo.invoice_mint_demo.mint"); + const mintScript = meshApplyParams(demoV.compiledCode, []); + const policyId = resolveScriptHash(mintScript, "V3"); + console.log(`[mint] Using demo policy: ${policyId}`); + + // Extract PKH from bech32 for requiredSigner + const addrBytes = bech32ToBytes(walletAddr); + const pkh = Buffer.from(addrBytes.slice(1, 29)).toString("hex"); + + // Find a UTxO for collateral (needed for Plutus script execution) + const collateralUtxo = utxos.find((u: any) => + !u.output.amount.some || Object.keys(u.output.amount).length === 1 + ) || utxos[0]; + + // Mint 1 NFT, send to wallet, require seller signature + const txBuilder = new MeshTxBuilder({ fetcher: provider, evaluator: provider }); + let builder = txBuilder + .mintPlutusScriptV3() + .mint("1", policyId, invoiceId) + .mintingScript(mintScript) + .mintRedeemerValue(mConStr0([])) + .txOut(walletAddr, [ + { unit: "lovelace", quantity: "2000000" }, + { unit: policyId + invoiceId, quantity: "1" }, + ]) + .requiredSignerHash(pkh) + .txInCollateral( + collateralUtxo.input.txHash, + collateralUtxo.input.outputIndex, + collateralUtxo.output.amount, + collateralUtxo.output.address, + ) + .changeAddress(walletAddr) + .selectUtxosFrom(utxos) + .setNetwork("preprod"); + const unsignedTx = await builder.complete(); + + console.log(`[mint] Tx built, size: ${unsignedTx.length / 2} bytes`); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ unsignedTx, policyId })); + } catch (err: any) { + console.error("[mint] Error:", err.message); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + }); + } else if (req.method === "POST" && req.url === "/submit-tx") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", async () => { + try { + const { signedTx } = JSON.parse(body); + // Submit directly to Koios via Ogmios endpoint (more reliable than MeshJS provider) + const submitRes = await fetch("https://preprod.koios.rest/api/v1/submittx", { + method: "POST", + headers: { "Content-Type": "application/cbor" }, + body: Buffer.from(signedTx, "hex"), + }); + const submitText = await submitRes.text(); + console.log(`[submit] Koios response (${submitRes.status}): ${submitText.slice(0, 200)}`); + if (!submitRes.ok) { + throw new Error(submitText); + } + const txHash = submitText.replace(/"/g, "").trim(); + console.log(`[mint] Submitted! Tx: ${txHash}`); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ txHash })); + } catch (err: any) { + console.error("[submit] Error:", err.message); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + }); + } else if (req.method === "POST" && req.url === "/assemble-tx") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const { unsignedTx, witnessSet } = JSON.parse(body); + // Cardano tx CBOR is a 4-element array: [body, witnesses, isValid, auxData] + // The unsigned tx has structure: 84 + // We need to replace with the signed witness set + // + // Using CSL to do this properly: + // Use CML (supports PlutusV3) to parse and merge witnesses + const txUnsigned = CML.Transaction.from_cbor_hex(unsignedTx); + const walletWS = CML.TransactionWitnessSet.from_cbor_hex(witnessSet); + const originalWS = txUnsigned.witness_set(); + + // Merge: keep scripts/redeemers/datums from original, add vkeys from wallet + const walletVkeys = walletWS.vkeywitnesses(); + if (walletVkeys) { + originalWS.set_vkeywitnesses(walletVkeys); + } + + const signedTx = CML.Transaction.new( + txUnsigned.body(), + originalWS, + true, // is_valid + txUnsigned.auxiliary_data(), + ); + const signedHex = signedTx.to_cbor_hex(); + console.log(`[assemble] Assembled signed tx, size: ${signedHex.length / 2}`); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ signedTx: signedHex })); + } catch (err: any) { + console.error("[assemble] Error:", err.message); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + }); + } else if (req.method === "GET" && req.url?.startsWith("/register")) { + // Parse query params: ?amount=100000&days=90&debtor=ACME&contact=test@test.com + const url = new URL(req.url, `http://localhost:${PORT}`); + const params = { + amount: url.searchParams.get("amount") || "100000", + days: url.searchParams.get("days") || "90", + debtor: url.searchParams.get("debtor") || "", + contact: url.searchParams.get("contact") || "", + }; + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(registerPage(scripts, params)); + } else { + res.writeHead(404); + res.end("Not found"); + } + }); + + server.listen(PORT, () => { + console.log(`\nOpen http://localhost:${PORT} in your browser to deploy.`); + }); +} + +interface ScriptInfo { + hash: string; + cbor: string; +} + +function signingPage( + scripts: { escrow: ScriptInfo; invoiceMint: ScriptInfo; marketplace: ScriptInfo }, + walletAddr: string, +): string { + return ` + + + + Factura Ya — Deploy to PreProd + + + +

Factura Ya — Deploy to PreProd

+
+
Escrow${scripts.escrow.hash.slice(0, 20)}...
+
Invoice Mint${scripts.invoiceMint.hash.slice(0, 20)}...
+
Marketplace${scripts.marketplace.hash.slice(0, 20)}...
+
Cost~5 ADA (simple tx to mark deployment)
+
+ + +
+ + + +`; +} + +function registerPage( + scripts: { escrow: ScriptInfo; invoiceMint: ScriptInfo; marketplace: ScriptInfo }, + params: { amount: string; days: string; debtor: string; contact: string }, +): string { + return ` + + + + Factura Ya — Register Invoice + + + +

Factura Ya — Register Invoice on PreProd

+
+
Amount (USD)${params.amount}
+
Due date${params.days} days
+
Debtor${params.debtor}
+
Mint Policy${scripts.invoiceMint.hash.slice(0, 20)}...
+
+ + +
+ + + +`; +} + +main().catch(console.error); diff --git a/lazer/cardano/factura-ya/offchain/src/deploy.ts b/lazer/cardano/factura-ya/offchain/src/deploy.ts new file mode 100644 index 00000000..5c954b72 --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/deploy.ts @@ -0,0 +1,148 @@ +/** + * Deploy validators as reference scripts on PreProd. + * + * Generates unsigned transactions that you sign in the browser wallet. + * Uses Koios as provider (no API key needed). + * + * Usage: npx tsx src/deploy.ts + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + Lucid, + Koios, + applyParamsToScript, + validatorToScriptHash, + validatorToAddress, +} from "@lucid-evolution/lucid"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BLUEPRINT_PATH = path.resolve(__dirname, "../../contracts/plutus.json"); +const CONFIG_PATH = path.resolve(__dirname, "../deploy-config.json"); + +const WALLET_ADDRESS = + process.env.WALLET_ADDRESS || ""; + +async function main() { + const blueprint = JSON.parse(fs.readFileSync(BLUEPRINT_PATH, "utf-8")); + + // --- Compute all hashes and parameterized scripts --- + + const escrowValidator = blueprint.validators.find( + (v: any) => v.title === "escrow.escrow.spend", + ); + const invoiceMintValidator = blueprint.validators.find( + (v: any) => v.title === "invoice_mint.invoice_mint.mint", + ); + const marketplaceValidator = blueprint.validators.find( + (v: any) => v.title === "marketplace.marketplace.spend", + ); + + const escrowHash = validatorToScriptHash({ + type: "PlutusV3", + script: escrowValidator.compiledCode, + }); + const invoiceMintScript = applyParamsToScript( + invoiceMintValidator.compiledCode, + [escrowHash], + ); + const invoiceMintPolicyId = validatorToScriptHash({ + type: "PlutusV3", + script: invoiceMintScript, + }); + const marketplaceScript = applyParamsToScript( + marketplaceValidator.compiledCode, + [invoiceMintPolicyId, escrowHash], + ); + const marketplaceHash = validatorToScriptHash({ + type: "PlutusV3", + script: marketplaceScript, + }); + const escrowAddr = validatorToAddress("Preprod", { + type: "PlutusV3", + script: escrowValidator.compiledCode, + }); + const marketplaceAddr = validatorToAddress("Preprod", { + type: "PlutusV3", + script: marketplaceScript, + }); + + console.log("=== Factura Ya — Deploy Config ===\n"); + console.log("Escrow hash: ", escrowHash); + console.log("Invoice mint policy:", invoiceMintPolicyId); + console.log("Marketplace hash: ", marketplaceHash); + console.log("Escrow address: ", escrowAddr); + console.log("Marketplace address:", marketplaceAddr); + + // --- Save config --- + + const config = { + escrow: { + scriptHash: escrowHash, + script: escrowValidator.compiledCode, + address: escrowAddr, + }, + invoiceMint: { + policyId: invoiceMintPolicyId, + script: invoiceMintScript, + }, + marketplace: { + scriptHash: marketplaceHash, + script: marketplaceScript, + address: marketplaceAddr, + }, + pythPolicyId: + "d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6", + network: "preprod", + }; + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); + console.log(`\nConfig saved to ${CONFIG_PATH}`); + + // --- Build unsigned deploy tx --- + + console.log("\n=== Building unsigned deploy transaction ===\n"); + + const lucid = await Lucid( + new Koios("https://preprod.koios.rest/api/v1"), + "Preprod", + ); + lucid.selectWallet.fromAddress(WALLET_ADDRESS); + + // Single tx that stores all 3 scripts as reference scripts + const tx = await lucid + .newTx() + .pay.ToAddressWithData( + WALLET_ADDRESS, + { kind: "inline", value: "d87980" }, + { lovelace: 10_000_000n }, + { type: "PlutusV3", script: escrowValidator.compiledCode }, + ) + .pay.ToAddressWithData( + WALLET_ADDRESS, + { kind: "inline", value: "d87980" }, + { lovelace: 15_000_000n }, + { type: "PlutusV3", script: invoiceMintScript }, + ) + .pay.ToAddressWithData( + WALLET_ADDRESS, + { kind: "inline", value: "d87980" }, + { lovelace: 15_000_000n }, + { type: "PlutusV3", script: marketplaceScript }, + ) + .complete(); + + const unsignedCbor = tx.toCBOR(); + + // Save unsigned tx + const txPath = path.resolve(__dirname, "../deploy-tx-unsigned.cbor"); + fs.writeFileSync(txPath, unsignedCbor); + + console.log(`Unsigned tx saved to: ${txPath}`); + console.log(`Tx size: ${unsignedCbor.length / 2} bytes`); + console.log(`\nCost: ~40 ADA (3 reference script UTxOs + fees)`); + console.log(`\nTo sign and submit, open the deploy page in the browser.`); +} + +main().catch(console.error); diff --git a/lazer/cardano/factura-ya/offchain/src/escrow.ts b/lazer/cardano/factura-ya/offchain/src/escrow.ts new file mode 100644 index 00000000..167f6f3e --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/escrow.ts @@ -0,0 +1,139 @@ +/** + * Off-chain transaction builders for the escrow / collateral validator. + * + * Three operations: + * - Lock: create escrow UTxO with collateral (typically in same tx as mint) + * - Release: buyer confirms payment, collateral returned to seller + * - Forfeit: buyer reports non-payment, collateral transferred to buyer + */ + +// --- Types --- + +export interface EscrowDatumFields { + invoice_id: string; + seller: string; + collateral_lovelace: number; + buyer: string | null; + due_date_posix_ms: number; + status: "Locked" | "Released" | "Forfeited"; +} + +export interface LockEscrowParams { + datum: EscrowDatumFields; + /** Lovelace to lock as collateral. */ + collateralLovelace: number; + /** The escrow validator script address. */ + escrowAddress: string; +} + +export interface ReleaseEscrowParams { + /** The UTxO reference of the escrow to consume. */ + escrowUtxoRef: string; + /** The seller's address to send collateral to. */ + sellerAddress: string; + /** Lovelace amount to return. */ + collateralLovelace: number; + redeemer: "Release"; +} + +export interface ForfeitEscrowParams { + /** The UTxO reference of the escrow to consume. */ + escrowUtxoRef: string; + /** The buyer's address to send collateral to. */ + buyerAddress: string; + /** Lovelace amount to transfer. */ + collateralLovelace: number; + redeemer: "Forfeit"; +} + +// --- Constants --- + +/** Grace period after due date before forfeit is allowed (7 days in ms). */ +export const GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; + +/** Minimum collateral percentage in basis points. 1000 = 10%. */ +export const MIN_COLLATERAL_BPS = 1000; + +// --- Builders --- + +/** + * Calculate the minimum collateral required for an invoice. + * + * @param invoiceAmountLovelace - The invoice value converted to lovelace. + * @param collateralBps - Collateral percentage in basis points (default: 1000 = 10%). + */ +export function calculateCollateral( + invoiceAmountLovelace: number, + collateralBps: number = MIN_COLLATERAL_BPS, +): number { + return Math.ceil((invoiceAmountLovelace * collateralBps) / 10_000); +} + +/** + * Build parameters for creating an escrow UTxO (lock collateral). + * + * Typically called alongside buildMintInvoiceParams so the escrow + * is created in the same transaction as the NFT mint. + */ +export function buildLockEscrowParams( + invoiceId: string, + sellerPkh: string, + collateralLovelace: number, + dueDatePosixMs: number, + escrowAddress: string, +): LockEscrowParams { + return { + datum: { + invoice_id: invoiceId, + seller: sellerPkh, + collateral_lovelace: collateralLovelace, + buyer: null, + due_date_posix_ms: dueDatePosixMs, + status: "Locked", + }, + collateralLovelace, + escrowAddress, + }; +} + +/** + * Build parameters for releasing collateral back to the seller. + * + * The caller must: + * 1. Include the buyer's signature. + * 2. Set validity range lower bound >= due_date_posix_ms. + * 3. Include an output paying collateral to the seller. + */ +export function buildReleaseEscrowParams( + escrowUtxoRef: string, + sellerAddress: string, + collateralLovelace: number, +): ReleaseEscrowParams { + return { + escrowUtxoRef, + sellerAddress, + collateralLovelace, + redeemer: "Release", + }; +} + +/** + * Build parameters for forfeiting collateral to the buyer. + * + * The caller must: + * 1. Include the buyer's signature. + * 2. Set validity range lower bound >= due_date_posix_ms + GRACE_PERIOD_MS. + * 3. Include an output paying collateral to the buyer. + */ +export function buildForfeitEscrowParams( + escrowUtxoRef: string, + buyerAddress: string, + collateralLovelace: number, +): ForfeitEscrowParams { + return { + escrowUtxoRef, + buyerAddress, + collateralLovelace, + redeemer: "Forfeit", + }; +} diff --git a/lazer/cardano/factura-ya/offchain/src/marketplace.ts b/lazer/cardano/factura-ya/offchain/src/marketplace.ts new file mode 100644 index 00000000..54e0bee1 --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/marketplace.ts @@ -0,0 +1,170 @@ +/** + * Off-chain transaction builders for the marketplace validator. + * + * Three operations: + * - List: SME sends invoice NFT to marketplace, creates listing UTxO + * - Purchase: investor buys listed invoice, payment to seller, NFT to buyer + * - Delist: seller withdraws listing, NFT returned + */ + +// --- Types --- + +export interface ListingDatumFields { + invoice_id: string; + seller: string; + asking_price_lovelace: number; + original_amount_usd: number; + pyth_price_at_listing: number; + pyth_exponent_at_listing: number; + due_date_posix_ms: number; + status: "Listed" | "Sold" | "Delisted"; +} + +export interface ListInvoiceParams { + /** Datum for the listing UTxO. */ + datum: ListingDatumFields; + /** The invoice NFT asset name. */ + assetName: string; + /** The invoice minting policy ID. */ + invoicePolicyId: string; + /** The marketplace validator address. */ + marketplaceAddress: string; +} + +export interface PurchaseInvoiceParams { + /** The listing UTxO reference to consume. */ + listingUtxoRef: string; + /** Lovelace to pay to the seller. */ + paymentLovelace: number; + /** Seller's address (for payment output). */ + sellerAddress: string; + /** Buyer's PKH (for NFT output and escrow update). */ + buyerPkh: string; + /** The invoice NFT asset name. */ + assetName: string; + /** The invoice minting policy ID. */ + invoicePolicyId: string; + /** The escrow UTxO to consume and recreate with buyer set. */ + escrowUtxoRef: string; + /** Updated escrow datum with buyer field set. */ + updatedEscrowDatum: { + invoice_id: string; + seller: string; + collateral_lovelace: number; + buyer: string; + due_date_posix_ms: number; + status: "Locked"; + }; + redeemer: "Purchase"; +} + +export interface DelistInvoiceParams { + /** The listing UTxO reference to consume. */ + listingUtxoRef: string; + /** The seller's address (to return NFT). */ + sellerAddress: string; + /** The invoice NFT asset name. */ + assetName: string; + /** The invoice minting policy ID. */ + invoicePolicyId: string; + redeemer: "Delist"; +} + +// --- Builders --- + +/** + * Build parameters for listing an invoice on the marketplace. + * + * The caller must also attach the Pyth price update in the same tx + * so the listing price can be validated. + */ +export function buildListInvoiceParams( + invoiceId: string, + assetName: string, + invoicePolicyId: string, + sellerPkh: string, + askingPriceLovelace: number, + originalAmountUsd: number, + pythPrice: number, + pythExponent: number, + dueDatePosixMs: number, + marketplaceAddress: string, +): ListInvoiceParams { + return { + datum: { + invoice_id: invoiceId, + seller: sellerPkh, + asking_price_lovelace: askingPriceLovelace, + original_amount_usd: originalAmountUsd, + pyth_price_at_listing: pythPrice, + pyth_exponent_at_listing: pythExponent, + due_date_posix_ms: dueDatePosixMs, + status: "Listed", + }, + assetName, + invoicePolicyId, + marketplaceAddress, + }; +} + +/** + * Build parameters for purchasing a listed invoice. + * + * The caller must: + * 1. Include the buyer's signature. + * 2. Create an output paying >= asking_price_lovelace to the seller. + * 3. Create an output sending the NFT to the buyer. + * 4. Consume the escrow UTxO and recreate it with buyer set. + */ +export function buildPurchaseInvoiceParams( + listingUtxoRef: string, + sellerAddress: string, + buyerPkh: string, + paymentLovelace: number, + assetName: string, + invoicePolicyId: string, + escrowUtxoRef: string, + escrowInvoiceId: string, + escrowSeller: string, + escrowCollateralLovelace: number, + escrowDueDatePosixMs: number, +): PurchaseInvoiceParams { + return { + listingUtxoRef, + paymentLovelace, + sellerAddress, + buyerPkh, + assetName, + invoicePolicyId, + escrowUtxoRef, + updatedEscrowDatum: { + invoice_id: escrowInvoiceId, + seller: escrowSeller, + collateral_lovelace: escrowCollateralLovelace, + buyer: buyerPkh, + due_date_posix_ms: escrowDueDatePosixMs, + status: "Locked", + }, + redeemer: "Purchase", + }; +} + +/** + * Build parameters for delisting an invoice (seller withdraws). + * + * The caller must include the seller's signature. + */ +export function buildDelistInvoiceParams( + listingUtxoRef: string, + sellerAddress: string, + assetName: string, + invoicePolicyId: string, +): DelistInvoiceParams { + return { + listingUtxoRef, + sellerAddress, + assetName, + invoicePolicyId, + redeemer: "Delist", + }; +} diff --git a/lazer/cardano/factura-ya/offchain/src/mint.ts b/lazer/cardano/factura-ya/offchain/src/mint.ts new file mode 100644 index 00000000..8fc7f747 --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/mint.ts @@ -0,0 +1,117 @@ +/** + * Off-chain transaction builders for minting and burning invoice NFTs. + * + * These are framework-agnostic helpers that produce the transaction + * parameters. The actual tx construction depends on the wallet SDK + * (lucid, mesh, or cardano-serialization-lib). + */ + +import { createHash } from "node:crypto"; + +// --- Types --- + +export interface InvoiceInput { + /** Unique identifier for the invoice. */ + invoiceId: string; + /** Invoice amount in USD (integer, e.g. cents or smallest unit). */ + amountUsd: number; + /** Due date as POSIX milliseconds. */ + dueDatePosixMs: number; + /** Debtor company/person name. */ + debtorName: string; + /** Debtor contact info (email or phone) — will be hashed before on-chain storage. */ + debtorContact: string; +} + +export interface InvoiceDatumFields { + invoice_id: string; + seller: string; + amount_usd: number; + due_date_posix_ms: number; + debtor_name: string; + debtor_contact_hash: string; + created_at_posix_ms: number; +} + +export interface MintInvoiceParams { + /** The invoice NFT asset name (= invoice_id as hex). */ + assetName: string; + /** The InvoiceDatum to attach to the output. */ + datum: InvoiceDatumFields; + /** The redeemer for the minting policy. */ + redeemer: "MintInvoice"; +} + +export interface BurnInvoiceParams { + /** The invoice NFT asset name to burn. */ + assetName: string; + /** The redeemer for the minting policy. */ + redeemer: "BurnInvoice"; +} + +// --- Helpers --- + +/** + * Hash debtor contact info with SHA-256 for on-chain privacy. + * Returns hex-encoded hash. + */ +export function hashDebtorContact(contact: string): string { + return createHash("sha256").update(contact).digest("hex"); +} + +/** + * Convert a UTF-8 string to a hex-encoded ByteArray for use as asset name. + */ +export function toHex(str: string): string { + return Buffer.from(str, "utf-8").toString("hex"); +} + +// --- Builders --- + +/** + * Build parameters for minting a new invoice NFT. + * + * The caller must: + * 1. Include the seller's signature in the transaction. + * 2. Set the validity range with a lower bound (for due date validation). + * 3. Create an output at the desired address holding the NFT + InvoiceDatum. + * 4. Optionally create the escrow UTxO in the same or a follow-up transaction. + */ +export function buildMintInvoiceParams( + invoice: InvoiceInput, + sellerPkh: string, +): MintInvoiceParams { + const assetName = toHex(invoice.invoiceId); + const contactHash = hashDebtorContact(invoice.debtorContact); + + return { + assetName, + datum: { + invoice_id: assetName, + seller: sellerPkh, + amount_usd: invoice.amountUsd, + due_date_posix_ms: invoice.dueDatePosixMs, + debtor_name: toHex(invoice.debtorName), + debtor_contact_hash: contactHash, + created_at_posix_ms: Date.now(), + }, + redeemer: "MintInvoice", + }; +} + +/** + * Build parameters for burning an invoice NFT after settlement. + * + * The caller must: + * 1. Include the NFT holder's signature. + * 2. Include the escrow UTxO as a reference input (so the minting policy + * can verify the escrow status is Released or Forfeited). + */ +export function buildBurnInvoiceParams( + invoiceId: string, +): BurnInvoiceParams { + return { + assetName: toHex(invoiceId), + redeemer: "BurnInvoice", + }; +} diff --git a/lazer/cardano/factura-ya/offchain/src/pyth.ts b/lazer/cardano/factura-ya/offchain/src/pyth.ts new file mode 100644 index 00000000..958071bc --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/pyth.ts @@ -0,0 +1,182 @@ +/** + * Pyth Pro integration for Factura Ya. + * + * Uses the official SDKs: + * - @pythnetwork/pyth-lazer-sdk: WebSocket price feed subscription + * - @pythnetwork/pyth-lazer-cardano-js: Cardano transaction helpers + * (getPythState, getPythScriptHash, createEvolutionClient) + */ + +import { + PythLazerClient, + type JsonOrBinaryResponse, +} from "@pythnetwork/pyth-lazer-sdk"; +import { + getPythState, + getPythScriptHash, +} from "@pythnetwork/pyth-lazer-cardano-js"; +import type { UTxO } from "@evolution-sdk/evolution"; +import type { ProviderOnlyClient } from "@evolution-sdk/evolution/sdk/client/Client"; + +// Re-export official helpers for convenience +export { getPythState, getPythScriptHash }; + +// --- Configuration --- + +/** Pyth PreProd deployment Policy ID. */ +export const PYTH_PREPROD_POLICY_ID = + "d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6"; + +/** ADA/USD feed ID on Pyth Pro. */ +export const ADA_USD_FEED_ID = 16; + +/** USD/ARS feed ID (not yet available on Pyth Pro — invoices use USD directly). */ +export const USD_ARS_FEED_ID = 2582; + +/** Default WebSocket endpoint for Pyth Pro. */ +const DEFAULT_WS_URL = "wss://pyth-lazer.dourolabs.app/v2/ws"; + +// --- Types --- + +export interface PythClientConfig { + accessToken: string; + wsUrl?: string; + feedIds?: number[]; + numConnections?: number; +} + +// --- Client --- + +/** + * Manages a connection to Pyth Pro WebSocket and caches the latest + * price update for use in transaction construction. + */ +export class PythPriceClient { + private client: PythLazerClient | null = null; + private latestSolanaHex: string | null = null; + + constructor(private config: PythClientConfig) {} + + /** Create the WebSocket connection and start subscribing. */ + async connect(): Promise { + if (this.client) return; + + const wsUrl = this.config.wsUrl ?? DEFAULT_WS_URL; + + this.client = await PythLazerClient.create( + [wsUrl], + this.config.accessToken, + this.config.numConnections ?? 1, + ); + + const feedIds = this.config.feedIds ?? [ADA_USD_FEED_ID]; + + await this.client.subscribe({ + type: "subscribe", + subscriptionId: 1, + priceFeedIds: feedIds, + properties: ["price", "exponent"], + chains: ["solana"], + channel: "fixed_rate@200ms", + jsonBinaryEncoding: "hex", + parsed: false, + }); + + this.client.addMessageListener((message: JsonOrBinaryResponse) => { + if (message.type === "json") { + const val = message.value; + if (val.type === "streamUpdated" && val.solana) { + this.latestSolanaHex = val.solana.data; + } + } else if (message.type === "binary") { + if (message.value.solana) { + this.latestSolanaHex = message.value.solana.toString("hex"); + } + } + }); + } + + /** + * Get the latest raw price update as a Buffer for use as the + * withdraw-script redeemer in a Cardano transaction. + */ + getLatestUpdateBuffer(): Buffer { + if (!this.latestSolanaHex) { + throw new Error( + "No Pyth price update available yet. Call connect() first and wait for data.", + ); + } + return Buffer.from(this.latestSolanaHex, "hex"); + } + + /** Get the latest update as hex string. */ + getLatestUpdateHex(): string { + if (!this.latestSolanaHex) { + throw new Error( + "No Pyth price update available yet. Call connect() first and wait for data.", + ); + } + return this.latestSolanaHex; + } + + /** Check if we have received at least one price update. */ + hasUpdate(): boolean { + return this.latestSolanaHex !== null; + } + + /** Wait until the first price update arrives. */ + async waitForUpdate(timeoutMs = 10_000): Promise { + const start = Date.now(); + while (!this.hasUpdate()) { + if (Date.now() - start > timeoutMs) { + throw new Error("Timed out waiting for Pyth price update"); + } + await new Promise((r) => setTimeout(r, 100)); + } + } + + /** Shut down the WebSocket connections. */ + shutdown(): void { + this.client?.shutdown(); + this.client = null; + this.latestSolanaHex = null; + } +} + +// --- Transaction helpers --- + +/** + * Fetch the Pyth on-chain state and build all params needed for a Pyth tx. + * + * Usage with Evolution SDK: + * ```typescript + * const params = await preparePythTx(client, priceClient); + * + * const now = BigInt(Date.now()); + * const tx = wallet.newTx() + * .setValidity({ from: now - 60_000n, to: now + 60_000n }) + * .readFrom({ referenceInputs: [params.stateUtxo] }) + * .withdraw({ + * amount: 0n, + * redeemer: [params.updateBuffer], + * stakeCredential: ScriptHash.fromHex(params.withdrawScriptHash), + * }); + * ``` + */ +export interface PythTxParams { + stateUtxo: UTxO.UTxO; + withdrawScriptHash: string; + updateBuffer: Buffer; +} + +export async function preparePythTx( + cardanoClient: ProviderOnlyClient, + priceClient: PythPriceClient, + policyId: string = PYTH_PREPROD_POLICY_ID, +): Promise { + const stateUtxo = await getPythState(policyId, cardanoClient); + const withdrawScriptHash = getPythScriptHash(stateUtxo); + const updateBuffer = priceClient.getLatestUpdateBuffer(); + + return { stateUtxo, withdrawScriptHash, updateBuffer }; +} diff --git a/lazer/cardano/factura-ya/offchain/src/test-mint.ts b/lazer/cardano/factura-ya/offchain/src/test-mint.ts new file mode 100644 index 00000000..bb6952dd --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/src/test-mint.ts @@ -0,0 +1,96 @@ +/** + * Test script: mint an invoice NFT on PreProd. + * + * Usage: npx tsx src/test-mint.ts + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + MeshTxBuilder, + KoiosProvider, + mConStr0, + resolveScriptHash, + applyParamsToScript, +} from "@meshsdk/core"; +import * as CML from "@anastasia-labs/cardano-multiplatform-lib-nodejs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BLUEPRINT_PATH = path.resolve(__dirname, "../../contracts/plutus.json"); +// Set via env: WALLET_ADDRESS=addr_test1q... npx tsx src/test-mint.ts +const ADDR = process.env.WALLET_ADDRESS || "addr_test1qpwmyvn3dkslusdhvq9lcae74qp7tn5qhnnzj4uc6dwjx7u64ztv4uur4qn0g8nekj2smva6xz2xj59vf0tc2gyy5u6sdhuckv"; +const PKH = process.env.WALLET_PKH || "5db232716da1fe41b7600bfc773ea003e5ce80bce6295798d35d237b"; + +async function main() { + const bp = JSON.parse(fs.readFileSync(BLUEPRINT_PATH, "utf-8")); + const demo = bp.validators.find((v: any) => v.title === "invoice_mint_demo.invoice_mint_demo.mint"); + const mintScript = applyParamsToScript(demo.compiledCode, []); + const policyId = resolveScriptHash(mintScript, "V3"); + console.log("Policy ID:", policyId); + + const provider = new KoiosProvider("preprod"); + const utxos = await provider.fetchAddressUTxOs(ADDR); + console.log("UTxOs:", utxos.length); + + // Pick a UTxO for collateral (one with only lovelace, no tokens) + const collateralUtxo = utxos.find((u: any) => + Object.keys(u.output.amount).length === 1 || + (u.output.amount.length === 1 && u.output.amount[0].unit === "lovelace") + ); + console.log("Collateral UTxO:", collateralUtxo ? `${collateralUtxo.input.txHash.slice(0, 16)}...#${collateralUtxo.input.outputIndex}` : "NONE"); + + const invoiceId = "494e56" + Date.now().toString(16); + + const txBuilder = new MeshTxBuilder({ fetcher: provider, evaluator: provider }); + let builder = txBuilder + .mintPlutusScriptV3() + .mint("1", policyId, invoiceId) + .mintingScript(mintScript) + .mintRedeemerValue(mConStr0([])) + .txOut(ADDR, [ + { unit: "lovelace", quantity: "2000000" }, + { unit: policyId + invoiceId, quantity: "1" }, + ]) + .requiredSignerHash(PKH) + .changeAddress(ADDR) + .selectUtxosFrom(utxos) + .setNetwork("preprod"); + + // Add collateral + if (collateralUtxo) { + builder = builder.txInCollateral( + collateralUtxo.input.txHash, + collateralUtxo.input.outputIndex, + collateralUtxo.output.amount, + collateralUtxo.output.address, + ); + } + + const unsignedTx = await builder.complete(); + console.log("Tx built:", unsignedTx.length / 2, "bytes"); + + // Evaluate via Ogmios to check if script passes + const evalRes = await fetch("https://preprod.koios.rest/api/v1/ogmios", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "evaluateTransaction", + params: { transaction: { cbor: unsignedTx } }, + }), + }); + const evalResult = await evalRes.json(); + if (evalResult.result) { + console.log("EVALUATION PASSED!", JSON.stringify(evalResult.result)); + } else { + console.log("EVALUATION FAILED:", JSON.stringify(evalResult.error?.data?.[0]?.error?.data || evalResult.error?.message).slice(0, 300)); + return; + } + + // Try submitting directly (unsigned, just to see if Koios accepts the structure) + console.log("\nTx is valid! To submit, sign with wallet and post to /api/v1/submittx"); + console.log("Tx hex (first 80):", unsignedTx.slice(0, 80)); +} + +main().catch(console.error); diff --git a/lazer/cardano/factura-ya/offchain/tsconfig.json b/lazer/cardano/factura-ya/offchain/tsconfig.json new file mode 100644 index 00000000..ff8c73b6 --- /dev/null +++ b/lazer/cardano/factura-ya/offchain/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +} diff --git a/lazer/cardano/factura-ya/package.json b/lazer/cardano/factura-ya/package.json new file mode 100644 index 00000000..bcf8260c --- /dev/null +++ b/lazer/cardano/factura-ya/package.json @@ -0,0 +1,19 @@ +{ + "name": "factura-ya", + "private": true, + "version": "0.0.1", + "description": "Factura Ya — Invoice factoring marketplace on Cardano", + "scripts": { + "install:all": "cd contracts && aiken build && cd ../offchain && npm install && cd ../frontend && npm install && cd ../indexer && npm install", + "contracts:build": "cd contracts && aiken build", + "contracts:test": "cd contracts && aiken check", + "server": "cd offchain && npm run server", + "frontend": "cd frontend && npm run dev", + "indexer": "cd indexer && npm start", + "dev": "echo 'Run in separate terminals:\\n npm run server → tx server on :3002\\n npm run frontend → UI on :5173\\n npm run indexer → API on :3001 (optional)'", + "test-mint": "cd offchain && npm run test-mint" + }, + "engines": { + "node": ">=20" + } +} diff --git a/lazer/cardano/factura-ya/plutus.json b/lazer/cardano/factura-ya/plutus.json new file mode 100644 index 00000000..24f96189 --- /dev/null +++ b/lazer/cardano/factura-ya/plutus.json @@ -0,0 +1,363 @@ +{ + "preamble": { + "title": "factura-ya/contracts", + "description": "Aiken contracts for project 'factura-ya/contracts'", + "version": "0.0.0", + "plutusVersion": "v3", + "compiler": { + "name": "Aiken", + "version": "v1.1.21+42babe5" + }, + "license": "Apache-2.0" + }, + "validators": [ + { + "title": "escrow.escrow.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1EscrowDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1EscrowRedeemer" + } + }, + "compiledCode": "59036901010029800aba2aba1aab9faab9eaab9dab9a48888896600264653001300700198039804000cdc3a400530070024888966002600460106ea800e26466453001159800980098059baa0028cc004c038c030dd500148c03cc040c040c040c040c0400064601e6020602060200032232330010010032259800800c528456600266e3cdd71809000801c528c4cc008008c04c00500e20229180798081808180818081808180818081808000c8c966002600e601a6ea800626eb4c040c038dd5000c520004030601e601a6ea8c03cc034dd51807980818081808180818081808180818069baa001918079808180818081808000c8c03cc040c0400064601e6020002911111111192cc004c02cc054dd5008c56600266ebcc024c058dd500526103d87980008992cc004c030c058dd5000c566002660106eb0c01cc05cdd50079bae30193017375400315980099b89375a600a602e6ea802cc01803e2b30019800807cdd71801980b9baa00b9bad300430173754016801229462c80aa2c80aa2c80aa2c80a8c020c058dd5005459014456600266ebcc024c058dd5005260103d87980008992cc004c030c058dd5000c4c966002660126eb0c020c060dd5008000c56600266e24cdc01bad300630183754018904048726002180380845660033001010800cdd69802980c1baa00c400d14a31640591640591640586eb8c064c05cdd5000c590151804180b1baa00a8b2028405044464660020026eb0c018c064dd5002112cc00400629422b30013232598009808980d9baa0018acc004cdc79bae301e301c375400200d13371200a6466446600400400244b3001001801c4c8cc896600266e45221000028acc004cdc7a441000028800c01902044cc014014c0940110201bae301f001375a604000260420028100c8c8cc004004dd59805980f9baa0052259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902144cc014014c0980110211bae3020001375660420026044002810852f5bded8c029000452820348a504068603a60366ea8c074c06cdd5000980e000c528c4cc008008c074005018203645900a4c02cdd5003cc03800d2225980098020014566002601e6ea802a00716404115980098040014566002601e6ea802a00716404116403480686018601a0026e1d20003009375400716401c30070013003375400f149a26cac8009", + "hash": "cb8368c843c59ac8d700cf647592c24b74fcca575fd8f42ff0793254" + }, + { + "title": "escrow.escrow.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "59036901010029800aba2aba1aab9faab9eaab9dab9a48888896600264653001300700198039804000cdc3a400530070024888966002600460106ea800e26466453001159800980098059baa0028cc004c038c030dd500148c03cc040c040c040c040c0400064601e6020602060200032232330010010032259800800c528456600266e3cdd71809000801c528c4cc008008c04c00500e20229180798081808180818081808180818081808000c8c966002600e601a6ea800626eb4c040c038dd5000c520004030601e601a6ea8c03cc034dd51807980818081808180818081808180818069baa001918079808180818081808000c8c03cc040c0400064601e6020002911111111192cc004c02cc054dd5008c56600266ebcc024c058dd500526103d87980008992cc004c030c058dd5000c566002660106eb0c01cc05cdd50079bae30193017375400315980099b89375a600a602e6ea802cc01803e2b30019800807cdd71801980b9baa00b9bad300430173754016801229462c80aa2c80aa2c80aa2c80a8c020c058dd5005459014456600266ebcc024c058dd5005260103d87980008992cc004c030c058dd5000c4c966002660126eb0c020c060dd5008000c56600266e24cdc01bad300630183754018904048726002180380845660033001010800cdd69802980c1baa00c400d14a31640591640591640586eb8c064c05cdd5000c590151804180b1baa00a8b2028405044464660020026eb0c018c064dd5002112cc00400629422b30013232598009808980d9baa0018acc004cdc79bae301e301c375400200d13371200a6466446600400400244b3001001801c4c8cc896600266e45221000028acc004cdc7a441000028800c01902044cc014014c0940110201bae301f001375a604000260420028100c8c8cc004004dd59805980f9baa0052259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902144cc014014c0980110211bae3020001375660420026044002810852f5bded8c029000452820348a504068603a60366ea8c074c06cdd5000980e000c528c4cc008008c074005018203645900a4c02cdd5003cc03800d2225980098020014566002601e6ea802a00716404115980098040014566002601e6ea802a00716404116403480686018601a0026e1d20003009375400716401c30070013003375400f149a26cac8009", + "hash": "cb8368c843c59ac8d700cf647592c24b74fcca575fd8f42ff0793254" + }, + { + "title": "invoice_mint.invoice_mint.mint", + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1InvoiceMintRedeemer" + } + }, + "parameters": [ + { + "title": "escrow_script_hash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + } + ], + "compiledCode": "590607010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae0024888888896600264653001300900198049805000cdc3a400130090024888966002600460126ea800e266446644b300130060018acc004c038dd5004400a2c807a2b300130030018acc004c038dd5004400a2c807a2c806100c0cc0048c040c044c044c044c04400644646600200200644b30010018a508acc004cdc79bae30130010038a51899801001180a000a01c4045230103011301130113011301130113011301100191808180898089808800c8c040c044c044c044c044c0440064602060220032232330010010032259800800c5300103d87a80008992cc004c010006266e952000330130014bd7044cc00c00cc05400900f1809800a0229180818089808800c88c8c8cc004004010896600200300389919912cc004cdc8803801456600266e3c01c00a20030064049133005005301800440486eb8c044004dd59809000980a000a02414bd6f7b6304dc3a400891111111112cc004c038c054dd500844c9660026036003132325980098071bad30190028992cc004cc034dd61806180d1baa011375c601260346ea80062b30013371090001bad3007301a375400315980099b8832598009808180d1baa00189bad301e301b3754003148001019180e980d1baa301d301a3754603a603c603c603c603c603c603c603c60346ea8044dd69805980d1baa0018acc006600266e3cdd71805180d1baa001488100a50a51406115980099b8f375c603a60346ea800400a29462c80c22c80c22c80c22c80c22c80c0c966002602460326ea8006264b30013006301a375400313259800980a180d9baa0018991919191919194c004dd69813000cdd71813003cdd718130034dd69813002cdd698130024dd71813001cdd7181300124444444b3001302e0088807c5902b0c098004c094004c090004c08c004c088004c084004c070dd5000c5901a180f180d9baa0018b20323007301a3754603a60346ea80062c80c0cc01cdd61803180c9baa0102300f323322330020020012259800800c00e2646644b30013372201000515980099b8f0080028800c01901e44cc014014c09001101e1bae301d001375a603c002604000280f0cc01cdd59805180d9baa0020111480022c80b8dd7180b800980d000c59018198011bab300a3016375401a01919800912cc004c040c05cdd500144c8c8c8c8c8ca60026042003375c604200d375c604200b375a6042009375a6042004911112cc004c09c01a2646644b3001301e002899192cc004c0b000a0071640a46eb8c0a8004c098dd5001c566002603600515980098131baa003800c59027459024204830233754002264b3001301d0018acc004c094dd5003c03a2c81322b3001301a0018acc004c094dd5003c03a2c81322b300130100018acc004c094dd5003c03a2c81322c811902320463023375400c604c01116409030210013020001301f001301e001301d001301837540051640592232330010010032259800800c52f5c113301c3003301d00133002002301e001406d3300237566014602c6ea803403122259800980e800c4c96600266e1cdd6980d000a400313259800acc004cdd79805980d9baa0014c0103d87a80008a51899baf300b301b375400298103d87b8000406513259800980a180d9baa0018998079bac300e301c37540266eb8c07cc070dd5000c4cc03cdd61807180e1baa013375c601660386ea800901a1806180d9baa0018b2032323259800980a180d9baa0018992cc004c020c070dd5000c4c8cc0200044004c080c074dd5000c5901b1804980e1baa301f301c3754003164068660126466446600400400244b30010018801c4cc080c084004cc008008c08800501f198029bac300b301c375402646018603a6ea8004cc010dd6180f180d9baa0122300b301c3754002464b30013012301c375400315980099b8f375c6040603a6ea800406a264b30013009301d37540031323300900113371e6eb8c088c07cdd50008029810980f1baa0018a5040706014603a6ea800a294101b45282036301f301c3754603e60386ea8004dd7180c800c59018180e000c5901a10140c02cdd50031bae300d300a37540066e1d20028b2010180480098021baa0098a4d13656400801", + "hash": "30268933aae8a060641fa0fb2417b675159dd2e74cc6eae65ad3b813" + }, + { + "title": "invoice_mint.invoice_mint.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "escrow_script_hash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + } + ], + "compiledCode": "590607010100229800aba2aba1aba0aab9faab9eaab9dab9a9bae0024888888896600264653001300900198049805000cdc3a400130090024888966002600460126ea800e266446644b300130060018acc004c038dd5004400a2c807a2b300130030018acc004c038dd5004400a2c807a2c806100c0cc0048c040c044c044c044c04400644646600200200644b30010018a508acc004cdc79bae30130010038a51899801001180a000a01c4045230103011301130113011301130113011301100191808180898089808800c8c040c044c044c044c044c0440064602060220032232330010010032259800800c5300103d87a80008992cc004c010006266e952000330130014bd7044cc00c00cc05400900f1809800a0229180818089808800c88c8c8cc004004010896600200300389919912cc004cdc8803801456600266e3c01c00a20030064049133005005301800440486eb8c044004dd59809000980a000a02414bd6f7b6304dc3a400891111111112cc004c038c054dd500844c9660026036003132325980098071bad30190028992cc004cc034dd61806180d1baa011375c601260346ea80062b30013371090001bad3007301a375400315980099b8832598009808180d1baa00189bad301e301b3754003148001019180e980d1baa301d301a3754603a603c603c603c603c603c603c603c60346ea8044dd69805980d1baa0018acc006600266e3cdd71805180d1baa001488100a50a51406115980099b8f375c603a60346ea800400a29462c80c22c80c22c80c22c80c22c80c0c966002602460326ea8006264b30013006301a375400313259800980a180d9baa0018991919191919194c004dd69813000cdd71813003cdd718130034dd69813002cdd698130024dd71813001cdd7181300124444444b3001302e0088807c5902b0c098004c094004c090004c08c004c088004c084004c070dd5000c5901a180f180d9baa0018b20323007301a3754603a60346ea80062c80c0cc01cdd61803180c9baa0102300f323322330020020012259800800c00e2646644b30013372201000515980099b8f0080028800c01901e44cc014014c09001101e1bae301d001375a603c002604000280f0cc01cdd59805180d9baa0020111480022c80b8dd7180b800980d000c59018198011bab300a3016375401a01919800912cc004c040c05cdd500144c8c8c8c8c8ca60026042003375c604200d375c604200b375a6042009375a6042004911112cc004c09c01a2646644b3001301e002899192cc004c0b000a0071640a46eb8c0a8004c098dd5001c566002603600515980098131baa003800c59027459024204830233754002264b3001301d0018acc004c094dd5003c03a2c81322b3001301a0018acc004c094dd5003c03a2c81322b300130100018acc004c094dd5003c03a2c81322c811902320463023375400c604c01116409030210013020001301f001301e001301d001301837540051640592232330010010032259800800c52f5c113301c3003301d00133002002301e001406d3300237566014602c6ea803403122259800980e800c4c96600266e1cdd6980d000a400313259800acc004cdd79805980d9baa0014c0103d87a80008a51899baf300b301b375400298103d87b8000406513259800980a180d9baa0018998079bac300e301c37540266eb8c07cc070dd5000c4cc03cdd61807180e1baa013375c601660386ea800901a1806180d9baa0018b2032323259800980a180d9baa0018992cc004c020c070dd5000c4c8cc0200044004c080c074dd5000c5901b1804980e1baa301f301c3754003164068660126466446600400400244b30010018801c4cc080c084004cc008008c08800501f198029bac300b301c375402646018603a6ea8004cc010dd6180f180d9baa0122300b301c3754002464b30013012301c375400315980099b8f375c6040603a6ea800406a264b30013009301d37540031323300900113371e6eb8c088c07cdd50008029810980f1baa0018a5040706014603a6ea800a294101b45282036301f301c3754603e60386ea8004dd7180c800c59018180e000c5901a10140c02cdd50031bae300d300a37540066e1d20028b2010180480098021baa0098a4d13656400801", + "hash": "30268933aae8a060641fa0fb2417b675159dd2e74cc6eae65ad3b813" + }, + { + "title": "invoice_mint_demo.invoice_mint_demo.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "compiledCode": "58f601010029800aba2aba1aab9faab9eaab9dab9a48888896600264653001300700198039804000cc01c0092225980099b8748000c01cdd500144c8c966002601a00313259800acc004cdc3800a400514a313370e0029000a0128992513758601a601c601c601c601c601c601c601c601c60166ea80122c8048dd698051806000c5900b1919198008009bab300d300e300e300e300e300b375400844b3001001801c4c8cc896600266e4401c00a2b30013371e00e0051001803201a8998028029809002201a375c60180026eacc034004c03800500d0a5eb7bdb180dd7180518041baa0028b200c180380098019baa0078a4d1365640041", + "hash": "279bffe6dc48291aa974d387b9ae36e3c4977b28b27726e998049971" + }, + { + "title": "invoice_mint_demo.invoice_mint_demo.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "58f601010029800aba2aba1aab9faab9eaab9dab9a48888896600264653001300700198039804000cc01c0092225980099b8748000c01cdd500144c8c966002601a00313259800acc004cdc3800a400514a313370e0029000a0128992513758601a601c601c601c601c601c601c601c601c60166ea80122c8048dd698051806000c5900b1919198008009bab300d300e300e300e300e300b375400844b3001001801c4c8cc896600266e4401c00a2b30013371e00e0051001803201a8998028029809002201a375c60180026eacc034004c03800500d0a5eb7bdb180dd7180518041baa0028b200c180380098019baa0078a4d1365640041", + "hash": "279bffe6dc48291aa974d387b9ae36e3c4977b28b27726e998049971" + }, + { + "title": "marketplace.marketplace.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1ListingDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1MarketplaceRedeemer" + } + }, + "parameters": [ + { + "title": "invoice_policy_id", + "schema": { + "$ref": "#/definitions/ByteArray" + } + }, + { + "title": "escrow_script_hash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + } + ], + "compiledCode": "5905290101002229800aba2aba1aba0aab9faab9eaab9dab9a9bae0039bae00248888888896600264653001300a00198051805800cdc3a4005300a0024888966002600460146ea800e26466453001159800980098069baa0028cc004c044c038dd500148c048c04cc04cc04cc04cc04cc04cc04c00646024602660266026602660266026602660260032232330010010032259800800c52845660026006602a00314a313300200230160014040809a4602460266026003222323322330020020012259800800c00e2646644b30013372200e00515980099b8f0070028800c01901544cc014014c06c0110151bae3014001375a602a002602e00280a8c8c8cc004004018896600200300389919912cc004cdc8804801456600266e3c02400a20030064059133005005301c00440586eb8c054004dd5980b000980c000a02c14bd6f7b6300a40012301230130019baf4c103d8798000488888888c9660026014602c6ea80422b300130023008301737540131598009991198041bac30073019375401e464b3001300e301a375400315980099b8f375c603c60366ea8004012266e2400e60026eacc01cc06cdd50015220100a44100402114a080ca2941019180e980d1baa301d301a37540026eb8c00cc05cdd50049bad300530173754013132598009805980b9baa0018992cc006600201f0169bae301c30193754017001400d15980099198049bac3008301a3754020464b30013013301b375400315980099b8f375c603e60386ea8004062264b30013370e9002180e1baa001899192cc004c048c078dd500144c8c8c8c8c8ca60026050003375c605000d375c605000b375a6050009375a6050004911112cc004c0b801a2646644b30013020002899192cc004c0cc00a0071640c06eb8c0c4004c0b4dd5001c566002604800515980098169baa003800c5902e45902b2056302a3754002264b3001301f0018acc004c0b0dd5003c03a2c816a2b300130230018acc004c0b0dd5003c03a2c816a2b30013370e9002000c56600260586ea801e01d1640b51640a8815102a18151baa006302d0088b205618140009813800981300098128009812000980f9baa0028b203a15980099b8f375c6042603c6ea80040162b30013375e6042604460446044603c6ea8004cdd2a4000660406ea40192f5c113009302130223022302230223022301e375400314a080e2294101c1810180e9baa0018a50406c601460386ea800a294101a45282034301e301b3754603c60366ea8004dd7180e180c9baa00b8a518b202e8b202e375c603660306ea80062c80b0c8cc004004dd61804180c1baa00e2259800800c5300103d87a80008992cc006600266e3c004dd71803180d1baa00ca50a51406113374a90001980e1ba90014bd7044cc00c00cc0780090181bae301c001406916405516405515980098011804180b9baa0098acc004c8cc88cc008008004896600200314a115980099b8f375c603a00200714a3133002002301e001406080d8dd61804180c1baa00e375c6006602e6ea80262b30019800806c0526eb8c068c05cdd5004cdd71801980b9baa009400514a316405516405516405480a88888cc024dd61804180d1baa004232598009807980d9baa0018acc004cdc79bae301f301c37540020071301398009bab3008301c375400500580220128a50406914a080d0c078c06cdd5180f180d9baa00145900c4c034dd5003cc04400d222598009802001456600260226ea802a0071640491598009804001456600260226ea802a00716404916403c8078601e60200026e1d2000300b3754007164024300a00130053754015149a26cac80181", + "hash": "2f18f39c41ea3cfa94d3e0cbbc0021d53b0ed0ae7392e428e00ad402" + }, + { + "title": "marketplace.marketplace.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "invoice_policy_id", + "schema": { + "$ref": "#/definitions/ByteArray" + } + }, + { + "title": "escrow_script_hash", + "schema": { + "$ref": "#/definitions/ByteArray" + } + } + ], + "compiledCode": "5905290101002229800aba2aba1aba0aab9faab9eaab9dab9a9bae0039bae00248888888896600264653001300a00198051805800cdc3a4005300a0024888966002600460146ea800e26466453001159800980098069baa0028cc004c044c038dd500148c048c04cc04cc04cc04cc04cc04cc04c00646024602660266026602660266026602660260032232330010010032259800800c52845660026006602a00314a313300200230160014040809a4602460266026003222323322330020020012259800800c00e2646644b30013372200e00515980099b8f0070028800c01901544cc014014c06c0110151bae3014001375a602a002602e00280a8c8c8cc004004018896600200300389919912cc004cdc8804801456600266e3c02400a20030064059133005005301c00440586eb8c054004dd5980b000980c000a02c14bd6f7b6300a40012301230130019baf4c103d8798000488888888c9660026014602c6ea80422b300130023008301737540131598009991198041bac30073019375401e464b3001300e301a375400315980099b8f375c603c60366ea8004012266e2400e60026eacc01cc06cdd50015220100a44100402114a080ca2941019180e980d1baa301d301a37540026eb8c00cc05cdd50049bad300530173754013132598009805980b9baa0018992cc006600201f0169bae301c30193754017001400d15980099198049bac3008301a3754020464b30013013301b375400315980099b8f375c603e60386ea8004062264b30013370e9002180e1baa001899192cc004c048c078dd500144c8c8c8c8c8ca60026050003375c605000d375c605000b375a6050009375a6050004911112cc004c0b801a2646644b30013020002899192cc004c0cc00a0071640c06eb8c0c4004c0b4dd5001c566002604800515980098169baa003800c5902e45902b2056302a3754002264b3001301f0018acc004c0b0dd5003c03a2c816a2b300130230018acc004c0b0dd5003c03a2c816a2b30013370e9002000c56600260586ea801e01d1640b51640a8815102a18151baa006302d0088b205618140009813800981300098128009812000980f9baa0028b203a15980099b8f375c6042603c6ea80040162b30013375e6042604460446044603c6ea8004cdd2a4000660406ea40192f5c113009302130223022302230223022301e375400314a080e2294101c1810180e9baa0018a50406c601460386ea800a294101a45282034301e301b3754603c60366ea8004dd7180e180c9baa00b8a518b202e8b202e375c603660306ea80062c80b0c8cc004004dd61804180c1baa00e2259800800c5300103d87a80008992cc006600266e3c004dd71803180d1baa00ca50a51406113374a90001980e1ba90014bd7044cc00c00cc0780090181bae301c001406916405516405515980098011804180b9baa0098acc004c8cc88cc008008004896600200314a115980099b8f375c603a00200714a3133002002301e001406080d8dd61804180c1baa00e375c6006602e6ea80262b30019800806c0526eb8c068c05cdd5004cdd71801980b9baa009400514a316405516405516405480a88888cc024dd61804180d1baa004232598009807980d9baa0018acc004cdc79bae301f301c37540020071301398009bab3008301c375400500580220128a50406914a080d0c078c06cdd5180f180d9baa00145900c4c034dd5003cc04400d222598009802001456600260226ea802a0071640491598009804001456600260226ea802a00716404916403c8078601e60200026e1d2000300b3754007164024300a00130053754015149a26cac80181", + "hash": "2f18f39c41ea3cfa94d3e0cbbc0021d53b0ed0ae7392e428e00ad402" + } + ], + "definitions": { + "ByteArray": { + "dataType": "bytes" + }, + "Data": { + "title": "Data", + "description": "Any Plutus data." + }, + "Int": { + "dataType": "integer" + }, + "Option": { + "title": "Option", + "anyOf": [ + { + "title": "Some", + "description": "An optional value.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + }, + { + "title": "None", + "description": "Nothing.", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "types/EscrowDatum": { + "title": "EscrowDatum", + "anyOf": [ + { + "title": "EscrowDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "invoice_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "seller", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "collateral_lovelace", + "$ref": "#/definitions/Int" + }, + { + "title": "buyer", + "$ref": "#/definitions/Option" + }, + { + "title": "due_date_posix_ms", + "$ref": "#/definitions/Int" + }, + { + "title": "status", + "$ref": "#/definitions/types~1EscrowStatus" + } + ] + } + ] + }, + "types/EscrowRedeemer": { + "title": "EscrowRedeemer", + "anyOf": [ + { + "title": "Release", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Forfeit", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "types/EscrowStatus": { + "title": "EscrowStatus", + "anyOf": [ + { + "title": "Locked", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Released", + "dataType": "constructor", + "index": 1, + "fields": [] + }, + { + "title": "Forfeited", + "dataType": "constructor", + "index": 2, + "fields": [] + } + ] + }, + "types/InvoiceMintRedeemer": { + "title": "InvoiceMintRedeemer", + "anyOf": [ + { + "title": "MintInvoice", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "BurnInvoice", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "types/ListingDatum": { + "title": "ListingDatum", + "anyOf": [ + { + "title": "ListingDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "invoice_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "seller", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "asking_price_lovelace", + "$ref": "#/definitions/Int" + }, + { + "title": "original_amount_usd", + "$ref": "#/definitions/Int" + }, + { + "title": "pyth_price_at_listing", + "$ref": "#/definitions/Int" + }, + { + "title": "pyth_exponent_at_listing", + "$ref": "#/definitions/Int" + }, + { + "title": "due_date_posix_ms", + "$ref": "#/definitions/Int" + }, + { + "title": "status", + "$ref": "#/definitions/types~1ListingStatus" + } + ] + } + ] + }, + "types/ListingStatus": { + "title": "ListingStatus", + "anyOf": [ + { + "title": "Listed", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Sold", + "dataType": "constructor", + "index": 1, + "fields": [] + }, + { + "title": "Delisted", + "dataType": "constructor", + "index": 2, + "fields": [] + } + ] + }, + "types/MarketplaceRedeemer": { + "title": "MarketplaceRedeemer", + "anyOf": [ + { + "title": "Purchase", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Delist", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + } + } +} \ No newline at end of file