This document describes the trustless patent sale mechanism in AtomicIP.
An atomic swap allows a seller to exchange an IP decryption key for payment in a single transaction — if the key is invalid, the payment fails automatically. No escrow, no intermediary, no counterparty risk.
┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐
│ Pending │ --> │Accepted │ --> │Completed │ │ Cancelled │
└─────────┘ └─────────┘ └──────────┘ └───────────┘
│ │ ▲
│ └──────────────────────────────────────┘
└────────────────────────────────────────────────────────┘
| State | Description |
|---|---|
| Pending | Seller has initiated the swap; buyer has not yet accepted |
| Accepted | Buyer has sent payment; waiting for seller to reveal key |
| Completed | Seller revealed valid key; payment released; IP transferred |
| Cancelled | Swap aborted by seller (if Pending) or buyer (if Accepted + expired) |
Seller AtomicSwap Contract IpRegistry Buyer
│ │ │ │
│ 1. initiate_swap() │ │ │
├───────────────────────────>│ │ │
│ │ verify IP ownership │ │
│ ├───────────────────────────>│ │
│ │<───────────────────────────┤ │
│ │ create SwapRecord │ │
│ │ status = Pending │ │
│<───────────────────────────┤ │ │
│ │ │ │
│ │ 2. accept_swap() │ │
│ │<───────────────────────────┼──────────────────────┤
│ │ transfer payment to contract │
│ │ status = Accepted │ │
│ ├────────────────────────────┼──────────────────────>│
│ │ │ │
│ 3. reveal_key() │ │ │
├───────────────────────────>│ │ │
│ │ verify_commitment() │ │
│ ├───────────────────────────>│ │
│ │<───────────────────────────┤ │
│ │ if valid: │ │
│ │ transfer payment to seller │
│ │ transfer IP to buyer │ │
│ │ status = Completed │ │
│<───────────────────────────┤ │ │
│ │ │ │
│ │ if invalid: │ │
│ │ refund buyer │ │
│ │ status = Cancelled │ │
│ ├────────────────────────────┼──────────────────────>│
let swap_id = atomic_swap.initiate_swap(
token, // Payment token address (e.g., XLM)
ip_id, // The IP to sell
seller, // Seller's address (requires auth)
price, // Price in stroops (1 XLM = 10^7 stroops)
buyer, // Buyer's address
);Checks:
- Seller must own the IP (
IpRegistry.get_ip(ip_id).owner == seller) - IP must not be revoked
- No other active swap exists for this
ip_id - Price must be > 0
Result:
- Swap created with
status = Pending - Expiry set to ~7 days from now
atomic_swap.accept_swap(swap_id);Checks:
- Swap must be in
Pendingstate - Buyer must authorize the transaction
- Buyer must have sufficient token balance
Result:
- Payment transferred from buyer to contract
- Swap status updated to
Accepted accept_timestamprecorded
atomic_swap.reveal_key(swap_id, secret, blinding_factor);Checks:
- Swap must be in
Acceptedstate - Only seller can call this
verify_commitment(ip_id, secret, blinding_factor)must returntrue
Result if key is valid:
- Payment released to seller
- IP ownership transferred to buyer
- Swap status updated to
Completed
Result if key is invalid:
- Payment refunded to buyer
- Swap status updated to
Cancelled
atomic_swap.cancel_swap(swap_id);Only allowed if swap is still Pending (buyer has not yet accepted).
atomic_swap.cancel_swap(swap_id);Only allowed if:
- Swap is in
Acceptedstate - Current time >
expirytimestamp - Seller has not called
reveal_key
This protects buyers from sellers who accept payment but never reveal the key.
| Property | Enforcement |
|---|---|
| Atomicity | Payment and key exchange happen in the same transaction — no partial completion |
| Trustlessness | Smart contract verifies the key; no human arbitrator needed |
| No Escrow Risk | Payment held by contract, not a third party |
| Expiry Protection | Buyers can reclaim funds if seller abandons the swap |
| Invalid Key Refund | If verify_commitment fails, buyer is automatically refunded |
// 1. Seller initiates
let swap_id = swap_contract.initiate_swap(
xlm_token_address,
ip_id,
seller_address,
100_000_000, // 10 XLM
buyer_address,
);
// 2. Buyer accepts (sends 10 XLM to contract)
swap_contract.accept_swap(swap_id);
// 3. Seller reveals key
swap_contract.reveal_key(swap_id, secret, blinding_factor);
// If key is valid:
// - Seller receives 10 XLM
// - Buyer receives IP ownership
// - Swap status = Completed| Scenario | Outcome |
|---|---|
| Seller reveals invalid key | Buyer refunded; swap cancelled |
| Seller never reveals key | Buyer cancels after expiry; refunded |
| Buyer never accepts | Seller cancels; no payment involved |
| IP is revoked before swap completes | initiate_swap panics; swap cannot be created |
The following test modules currently have compile errors or merge conflicts that prevent them from being enabled. Integration and end-to-end test coverage is limited until these are resolved:
| Test Module | Status | Issue | Notes |
|---|---|---|---|
tests |
Disabled | — | General contract unit tests; merge conflict compile errors pending resolution |
regression_tests |
Disabled | — | Regression test suite; merge conflict compile errors pending resolution |
benchmarks |
Disabled | #FIXME | Pre-existing merge conflict compile errors; performance benchmarking not yet available |
Enabled test modules with recent fixes:
batch_swap_features_tests— re-enabled after treasury trustline fix (#825)batch_approval_tests— re-enabled after compile errors fixed (#831)batch_history_tests— re-enabled after compile errors fixed (#832)arbitration_tests— re-enabled after compile errors fixed (#781)escrow_tests— re-enabled after compile errors fixed (#830)prop_tests— re-enabled after compile errors fixed (#828)chaos_tests— re-enabled after compile errors fixed (#829)
Implications for integrators:
- The contract's atomic swap guarantees have been verified by the enabled test suite
- Batch operations and dispute resolution paths are covered
- Performance characteristics should be re-validated once benchmark tests are re-enabled
- Readers should trust the documented guarantees as implemented, but be aware that some regression and edge-case coverage is currently incomplete
- Use
initiate_swaponce per IP sale (not per negotiation attempt) - Batch multiple IP sales if selling to the same buyer
- Cancel pending swaps promptly to free storage
Cancel multiple pending swaps in a single transaction with per-swap reason tracking.
let swap_ids = vec![1, 2, 3];
let reasons = vec![
Bytes::from_slice(&env, b"no_longer_needed"),
Bytes::from_slice(&env, b"price_changed"),
Bytes::from_slice(&env, b"buyer_requested"),
];
let cancelled_ids = atomic_swap.batch_cancel_swaps(swap_ids, canceller, reasons);Constraints:
reasons.len()must equalswap_ids.len()or the call panics withInvalidKey- Each swap must be in
Pendingstate - The caller must be either the seller or buyer of each swap
- Each swap receives its own
CancelReasonstored on-chain (retrievable viaget_cancellation_reason) - The canceller's reputation is decreased by 10 points
- A
BatchCancelledEventis emitted withswap_ids,canceller, andreasons
Returns: A Vec<u64> of the successfully cancelled swap IDs.
When batch-revealing keys via batch_reveal_keys, the contract now emits a BatchFeeBreakdownEvent alongside the standard BatchKeysRevealedEvent. This event contains per-swap fee details:
pub struct SwapFeeBreakdown {
pub swap_id: u64,
pub price: i128,
pub protocol_fee: i128,
pub referral_fee: i128,
pub seller_amount: i128,
}The BatchFeeBreakdownEvent includes:
swap_ids: The list of swap IDsseller: The seller's addressfees: AVec<SwapFeeBreakdown>with fee details for each swap
This allows off-chain indexers and frontends to display exact fee amounts per swap without replaying protocol fee logic.
- Commitment Scheme — How to construct valid secrets
- Security Considerations — Best practices for key management
- Threat Model — Attack vectors and mitigations
Batch functions allow a seller or buyer to initiate, accept, or complete multiple swaps in a single transaction, reducing fees and round-trips.
Seller initiates multiple patent sales at once. All swaps share the same buyer and payment token.
let swap_ids: Vec<u64> = swap_contract.batch_initiate_swap(
token, // Payment token (same for all swaps)
ip_ids, // Vec of IP IDs to sell
seller, // Seller address (requires auth)
prices, // Vec of prices — prices[i] corresponds to ip_ids[i]
buyer, // Buyer address
0, // required_approvals (0 = none)
None, // referrer
);Constraints:
ip_ids.len() == prices.len()- Seller must own every IP in
ip_ids - No active swap may exist for any of the IPs
- All prices must be > 0
Result: Returns a Vec<u64> of the newly created swap IDs, one per IP.
Buyer accepts multiple Pending swaps in one call. Payment for each swap is transferred to the contract.
swap_contract.batch_accept_swaps(
swap_ids, // Vec of swap IDs to accept
buyer, // Buyer address (requires auth)
);Constraints:
- Every swap must be in
Pendingstate buyermust match thebuyerfield on each swap- Required approvals (if any) must already be collected
Result: All swaps move to Accepted. A single BatchAccepted event is emitted.
Seller reveals decryption keys for multiple Accepted swaps in one call. Each key is verified; payment is released per swap.
swap_contract.batch_reveal_keys(
swap_ids, // Vec of swap IDs
secrets, // Vec of secrets — secrets[i] for swap_ids[i]
blinding_factors, // Vec of blinding factors
seller, // Seller address (requires auth)
);Constraints:
swap_ids,secrets, andblinding_factorsmust all have the same length- Every swap must be in
Acceptedstate - Seller must be the initiator of every swap
- Every
verify_commitment(ip_id, secret, blinding_factor)must returntrue
Result: All swaps move to Completed. Protocol fees are deducted per swap. A single BatchKeysRevealed event is emitted.
// 1. Seller lists three IPs for sale in one transaction
let swap_ids = swap_contract.batch_initiate_swap(
xlm_token,
vec![ip_id_1, ip_id_2, ip_id_3],
seller,
vec![100_000_000, 200_000_000, 50_000_000],
buyer,
0,
None,
);
// 2. Buyer accepts all three (sends total payment in one call)
swap_contract.batch_accept_swaps(swap_ids.clone(), buyer);
// 3. Seller reveals all three keys (completes all swaps in one call)
swap_contract.batch_reveal_keys(
swap_ids,
vec![secret_1, secret_2, secret_3],
vec![blinding_1, blinding_2, blinding_3],
seller,
);| Event | Symbol | Emitted by |
|---|---|---|
BatchAcceptedEvent |
btch_acp |
batch_accept_swaps |
BatchKeysRevealedEvent |
btch_key |
batch_reveal_keys |
Individual SwapInitiatedEvent events are still emitted per swap inside batch_initiate_swap.
The following JavaScript utilities live in src/batch/ and operate entirely off-chain. They are used to prepare or process batch swap data before submitting to the contract or after reading from it.
Module: src/batch/batchCompressor.js
Compresses an array of swap record objects into a compact Buffer using deflate (Node built-in zlib), and decompresses it back. Useful for reducing payload size when transmitting or storing batches off-chain.
const { compressBatchSwaps, decompressBatchSwaps } = require("./src/batch/batchCompressor");
const swaps = [
{ swapId: "s1", state: "PENDING", amount: 1000 },
{ swapId: "s2", state: "PENDING", amount: 2000 },
];
const compressed = compressBatchSwaps(swaps); // Buffer
const restored = decompressBatchSwaps(compressed); // original arrayConstraints:
swapsmust be a non-empty array of objects, max 100 entriescompressedmust be aBufferproduced bycompressBatchSwaps- Throws
TypeErrorfor invalid input types,RangeErrorif batch exceeds the limit
Module: src/batch/batchEncryptor.js
Encrypts a Buffer of swap data with AES-256-GCM (Node built-in crypto) and decrypts it back. The GCM auth tag ensures tampered ciphertext is rejected automatically.
Wire format: [ 12-byte IV | 16-byte auth-tag | ciphertext ]
const crypto = require("crypto");
const { encryptBatchSwaps, decryptBatchSwaps } = require("./src/batch/batchEncryptor");
const key = crypto.randomBytes(32); // 256-bit key — store securely
const data = Buffer.from(JSON.stringify(swaps));
const encrypted = encryptBatchSwaps(data, key); // Buffer
const plaintext = decryptBatchSwaps(encrypted, key); // original BufferConstraints:
keymust be a 32-byteBuffer(AES-256)data/encryptedmust beBufferorUint8Array- Throws
Error("Decryption failed: invalid key or tampered data.")on auth failure - Each call to
encryptBatchSwapsuses a fresh random IV, so identical inputs produce different ciphertexts
Compose with compression:
const compressed = compressBatchSwaps(swaps);
const encrypted = encryptBatchSwaps(compressed, key);
// transmit / store `encrypted` ...
const decrypted = decryptBatchSwaps(encrypted, key);
const restored = decompressBatchSwaps(decrypted);Module: IpRegistry contract (batch_escrow_commitments, get_batch_escrow, release_batch_escrow, cancel_batch_escrow)
Batch Escrow allows a depositor to hold multiple IP commitments in trust and release them conditionally to a beneficiary after a timeout or manual authorization. This is useful for:
- Conditional IP transfers (e.g., release designs only after payment clears)
- Time-locked IP releases (e.g., inheritance, delayed asset transfers)
- Multi-party transactions with contingencies
┌────────┐ ┌──────────┐
│ Active │ --> │ Released │
└────────┘ └──────────┘
│
└─────────────> ┌──────────┐
│Cancelled │
└──────────┘
| State | Description |
|---|---|
| Active | Escrow created; IPs held by contract; no transfers yet |
| Released | Depositor authorized release; all IPs transferred to beneficiary |
| Cancelled | Timeout elapsed; depositor cancelled; IPs returned to depositor (no transfer on cancel) |
let escrow_id: BytesN<32> = ip_registry.batch_escrow_commitments(
depositor, // Address that owns the IPs (requires auth)
ip_ids, // Vec<u64> of IP IDs to hold in escrow
release_to, // Address that will receive IPs upon release
timeout, // Ledger timestamp after which cancellation is allowed
);Security Checks:
- Depositor must authorize the call (signature verification)
- Depositor must own all IPs in
ip_ids timeoutmust be in the future (contract enforces at release/cancel time)- Escrow ID is deterministically derived from
ip_ids + timestampto prevent collisions
Result:
EscrowRecordcreated withstatus = Activeescrow_idreturned (deterministic hash for lookups)
Events:
(symbol_short!("escrow"), depositor)published with(escrow_id, ip_ids.len())
let escrow: Option<EscrowRecord> = ip_registry.get_batch_escrow(escrow_id);Returns the escrow record or None if not found.
ip_registry.release_batch_escrow(escrow_id);Security Checks:
- Escrow must be in
Activestatus - Caller must be the depositor (signature verification)
- No timeout check (depositor can release at any time)
Result:
- All IPs transferred from depositor to
release_to - Escrow status updated to
Released
Events:
(symbol_short!("esc_rel"), depositor)published withescrow_id
Example:
// Seller deposits 3 design IPs into escrow for buyer
let escrow_id = ip_registry.batch_escrow_commitments(
seller,
vec![design_v1, design_v2, design_v3],
buyer,
timeout_ts,
);
// After payment verification, seller manually releases
ip_registry.release_batch_escrow(escrow_id);
// → All 3 designs now owned by buyerip_registry.cancel_batch_escrow(escrow_id);Security Checks:
- Escrow must be in
Activestatus - Caller must be the depositor (signature verification)
- Current ledger timestamp must be ≥
escrow.timeout
Result:
- Escrow status updated to
Cancelled - IPs remain owned by depositor (no transfer occurs on cancel)
Events:
(symbol_short!("esc_cnl"), depositor)published withescrow_id
Example (Inheritance Use Case):
// Parent deposits family designs to child, with 20-year timeout
let escrow_id = ip_registry.batch_escrow_commitments(
parent,
family_designs,
child,
now + 20_years_in_seconds,
);
// After 20 years, child automatically cancels and takes ownership
ip_registry.cancel_batch_escrow(escrow_id);
// → All designs now owned by child| Attack | Defense |
|---|---|
| Unauthorized early release | Only depositor can release; requires signature verification |
| Timeout replay | Ledger timestamp checked at release/cancel time; cannot be spoofed |
| Colliding escrow IDs | ID derived from all ip_ids + timestamp; collision risk negligible (2^256 space) |
| Double-release | Status check; escrow must be Active to transition to Released or Cancelled |
| Orphaned IPs | Cancelled escrow does not transfer; IPs revert to depositor ownership |
Multiple escrows can exist simultaneously. Each has its own status, timeout, and release_to address.
// Create 2 separate escrows for different beneficiaries
let escrow_1 = ip_registry.batch_escrow_commitments(owner, vec![ip_1, ip_2], alice, t1);
let escrow_2 = ip_registry.batch_escrow_commitments(owner, vec![ip_3, ip_4], bob, t2);
// Release to Alice, cancel to Bob
ip_registry.release_batch_escrow(escrow_1); // → ip_1, ip_2 to Alice
ip_registry.cancel_batch_escrow(escrow_2); // → ip_3, ip_4 stay with owner- Batch multiple IPs into a single escrow to reduce transaction overhead
- Use escrow for conditional transfers only (standard swaps do not require escrow)
- Cancel expired escrows promptly to free storage
Batch Escrow in the IP Registry is complementary to atomic swaps in the AtomicSwap contract:
- Atomic Swaps: Trustless, instant IP transfer in exchange for payment
- Batch Escrow: Conditional IP transfer with time-lock; no payment involved
Example: Payment-Contingent Design Transfer
// 1. Seller deposits designs to escrow (payment not yet confirmed)
let escrow_id = ip_registry.batch_escrow_commitments(
seller,
designs,
buyer,
now + 7_days,
);
// 2. After payment verified (off-chain or via oracle), seller releases
ip_registry.release_batch_escrow(escrow_id);
// 3. If payment never confirms, after 7 days buyer can cancel
// (or seller cancels to reclaim)
ip_registry.cancel_batch_escrow(escrow_id);In contrast, atomic swaps verify payment and key in the same transaction (fully trustless).
AtomicSwap can price a swap dynamically from an external oracle contract via
initiate_swap_with_oracle_price / get_oracle_price, instead of the seller
naming a fixed price. Because the settlement price directly determines how
much value changes hands, the oracle integration is designed so that an
outside party can verify a price was authentically produced by a specific,
admin-designated publisher — not merely trust whatever oracle_address
happens to return.
set_oracle(caller, oracle_address, oracle_pubkey, enabled, max_deviation_bps)
is admin-only and stores three things together:
oracle_address— the contract to call for price data.oracle_pubkey— the Ed25519 public key the oracle publisher signs price attestations with. This, notoracle_address, is the actual trust anchor.max_deviation_bps— the maximum a single accepted update may move from the last accepted price, in basis points (0= no bound).
Enabling the oracle does not itself fetch or verify a price (there is no
specific token to price at configuration time); it seeds
last_update_timestamp to now so the next real per-token fetch is treated as
fresh.
An oracle contract used with AtomicSwap must expose:
get_price_attestation(token: Address) -> SignedPrice
// SignedPrice { price: i128, timestamp: u64, signature: BytesN<64> }
signature is an Ed25519 signature, produced off-chain by the oracle
publisher's private key, over the XDR encoding of
PriceAttestation { token, price, timestamp }. fetch_oracle_price and
fetch_oracle_price_with_staleness_check verify this signature against the
oracle_pubkey on file before the price is used for anything — an invalid,
missing, or wrong-key signature is rejected regardless of whether the
staleness check would otherwise have accepted it.
- Authenticity — a price is only ever accepted if it carries a valid
Ed25519 signature from the registered
oracle_pubkey.oracle_addressalone proves nothing. - Bounded movement — once a price has been accepted, a subsequent signed
update that deviates from it by more than
max_deviation_bpsis rejected, independently of the signature check. This limits the damage a single compromised or buggy publisher update can do. - Freshness with a verified fallback — prices older than 5 minutes
(
ORACLE_STALENESS_THRESHOLD_SECS) trigger a fallback to the cached price instead of a new oracle call. That cached price was itself signature-checked when it was originally fetched, so the fallback carries the same authenticity guarantee, not a weaker one.
- The admin's choice of
oracle_pubkey/oracle_addresspair itself.set_oracleis an authorized admin action; nothing on-chain proves the key the admin registered belongs to a reputable, honest price publisher. This is a governance/operational trust assumption, the same way admin-set parameters elsewhere in this contract (fees, dispute windows) are trusted. - The publisher not signing a false-but-plausible price. Signature
verification proves who produced a price, not that the price is
economically accurate.
max_deviation_bpslimits how much damage a single bad signed update can do, but a publisher who is compromised or malicious over multiple updates can still walk a price away from reality within the bound. - Oracle contract availability. If
oracle_addressis unreachable or returns malformed data, fetches fail closed (the call panics); there is no automatic failover to a secondary oracle.
Module: src/insurance/swapInsurance.js (evaluateDisputePayout, processDisputeResolutions)
swapInsurance.js and the dispute/arbitration flow (arbitration_tests.rs,
resolve_dispute, and src/batch/batchDisputeResolver.js) used to be two
disconnected systems — a dispute could resolve on-chain with no awareness
that the swap it concerned was insured. evaluateDisputePayout closes that
gap as an explicit API call: feed it a policy and a dispute resolution result
and it decides whether a payout should fire, and if so files (and
adjudicates) the claim itself.
Given a resolveOne() / resolveBatchDisputes() result item for the same
swap as the policy:
| Resolution | Buyer recovers on-chain | Payout triggered? |
|---|---|---|
REFUND |
Full swap amount, via escrow | No — the buyer is already made whole. |
RELEASE |
Nothing (counterparty keeps it all) | Yes — for the full swap amount. |
SPLIT |
initiatorAmount (a partial share) |
Yes — for the shortfall (counterpartyAmount). |
ESCALATE |
Not yet determined | No — re-evaluate once escalation reaches a final ruling. |
The claim is filed against COVERAGE_EVENTS.NON_DELIVERY by default (the
event every policy tier covers), with the arbitration ruling itself supplied
as the claim's evidence — a buyer who already went through on-chain/
committee arbitration should not have to separately re-litigate the same
facts to satisfy fileClaim's evidence requirement.
const { evaluateDisputePayout } = require("./src/insurance/swapInsurance");
const { resolveBatchDisputes } = require("./src/batch/batchDisputeResolver");
const { results } = resolveBatchDisputes([dispute], [resolution]);
const outcome = evaluateDisputePayout(policy, results[0]);
// { triggered: true, shortfall: 10000, claim: { status: "APPROVED", payout, ... } }A dispute-resolution consumer — an on-chain event listener for
resolve_dispute, a webhook handler, or a direct caller of
batchDisputeResolver.js — is expected to call evaluateDisputePayout (or
its batch form, processDisputeResolutions, which matches resolutions to
policies by swapId) as soon as a resolution is final.
Module: src/matching/swapMatchingEngine.js (reconcileMatchBeforeSubmission)
findMatchesForBuyer/batchMatch score matches off a point-in-time
snapshot of listings. If the swap a seller listing represents is cancelled
(or otherwise leaves a matchable state) on-chain after that snapshot but
before the match is submitted, submitting against it would fail on-chain —
or race a legitimate state change. reconcileMatchBeforeSubmission
re-validates a match immediately before submission so a stale match can be
dropped instead.
Every match produced by findMatchesForBuyer is stamped with a matchedAt
timestamp. MATCH_STALENESS_WINDOW_MS (30 seconds by default) bounds how
long a caller may hold onto a match before submitting it:
- Within the window: the match is re-validated against current on-chain
state via an injected
getSwapState(swapId)lookup (sync or async, so the check stays testable without a real RPC client). It's submittable only if the swap is still inMATCHABLE_STATES(PENDING/ACTIVE). - Past the window: the match is rejected as stale on timing grounds alone, without even making the on-chain call — a match this old should be regenerated from fresh listings rather than trusted at all.
This means a match can go stale for either of two independent reasons: it
sat too long unsubmitted (timing), or the swap it targets moved on-chain in
the interim (state). reconcileMatchBeforeSubmission checks both, in that
order, since a timing failure makes the on-chain check moot.
const { findMatchesForBuyer, reconcileMatchBeforeSubmission } = require("./src/matching/swapMatchingEngine");
const matches = findMatchesForBuyer(buyer, sellers);
const best = matches[0];
// getSwapState is supplied by the caller — e.g. an AtomicSwapClient.get_swap_status call.
const { submittable, reason } = await reconcileMatchBeforeSubmission(best, getSwapState);
if (!submittable) {
// re-match instead of submitting against `best`
}