diff --git a/changelog.d/20231215_173732_nicolas.henin.md b/changelog.d/20231215_173732_nicolas.henin.md index bdfc2602..c2601687 100644 --- a/changelog.d/20231215_173732_nicolas.henin.md +++ b/changelog.d/20231215_173732_nicolas.henin.md @@ -1,3 +1,3 @@ ### @marlowe.io/language-examples -- enhanced swap contract (retract command, open role capabilities and `getState`) ([PR](https://github.com/input-output-hk/marlowe-ts-sdk/pull/131)) +- New swap contract version added: A simple Swap was initially implemented to test the runtime-lifecycle APIs. We have replaced this version with a more elaborated one that will be used in the [Order Book Swap Prototype](https://github.com/input-output-hk/marlowe-order-book-swap). For more details see [@marlowe.io/language-examples](https://input-output-hk.github.io/marlowe-ts-sdk/modules/_marlowe_io_language_examples.html) ([PR](https://github.com/input-output-hk/marlowe-ts-sdk/pull/131)) diff --git a/packages/language/examples/src/atomicSwap.ts b/packages/language/examples/src/atomicSwap.ts index fbabd275..af4ea3a1 100644 --- a/packages/language/examples/src/atomicSwap.ts +++ b/packages/language/examples/src/atomicSwap.ts @@ -2,22 +2,18 @@ *
* This module offers capabalities for running an Atomic Swap Contract. Atomic swaps, - * offer a way to swap cryptocurrencies peer-to-peer from different blockchains directly + * offer a way to swap cryptocurrencies peer-to-peer directly * without the requirement for a third party, such as an exchange.
** This Marlowe Contract has 2 participants (A `Seller` and a `Buyer`) that will atomically exchange - * some tokens A against some token B. Sellers can retract their offer and every state of this contract - * are timeboxed. The Seller is known at the contract creation but this contract is specifically designed - * to allow Buyers to be unknowm at the creation of the contract over Cardano (Open Role Feature). - * Consequently, an extra Notify input is added after the swap to avoid double-satisfaction attack (see below) - *
- *- * Marlowe's prevention of double-satisfaction attacks requires that no external payments be made from a Marlowe contract - * if another Plutus script runs in the transaction. Thus, if an open-role is distributed in a transaction, the transaction - * cannot do Pay to parties or implicit payments upon Close. Typically, the distribution of an open-role in a Marlowe contract - * will be followed by a Notify TrueObs case so that further execution of the contract does not proceed in that transactions. - * External payments can be made in subsequent transactions. + * some tokens A for some tokens B. Sellers can retract their offer and every state of this contract + * are timeboxed. Sellers are known at the contract creation (fixed Address) and Buyers are unknown + * (This showcases a feature of marlowe that is called Open Roles.). + * There are 3 main stages : + * - The Offer : The Sellers deposit their tokens. + * - The Ask : The Buyers deposit their tokens + * - The Swap Confirmation : an extra Notify input is added after the swap to avoid double-satisfaction attack (see link attached). + * (Any third participant could perform this action) *
* @see * - https://github.com/input-output-hk/marlowe-cardano/blob/main/marlowe-runtime/doc/open-roles.md @@ -35,15 +31,13 @@ * const tokenB = token("1f7a58a1aa1e6b047a42109ade331ce26c9c2cce027d043ff264fb1f","B") * * const scheme: AtomicSwap.Scheme = { - * participants: { - * seller: { address: aSellerAddressBech32 }, - * buyer: { role_token: "buyer" }, - * }, * offer: { + * seller: { address: aSellerAddressBech32 }, * deadline: datetoTimeout(addDays(Date.now(), 1)), * asset: tokenValue(10n)(tokenA), * }, * ask: { + * buyer: { role_token: "buyer" }, * deadline: datetoTimeout(addDays(Date.now(), 1)), * asset: tokenValue(10n)(tokenB), * }, @@ -65,8 +59,6 @@ import { close, TokenValue, Timeout, - Party, - PayeeParty, Input, MarloweState, IChoice, @@ -81,67 +73,77 @@ import * as G from "@marlowe.io/language-core-v1/guards"; type IReduce = void; const iReduce: void = undefined; -/* #region Scheme */ - +/** + * Atomic Swap Scheme, canonical information to define the contract. + * The contract can be generated by its scheme. + */ export type Scheme = { - participants: { - seller: Address; - buyer: Role; - }; offer: { - deadline: Timeout; + seller: Address; asset: TokenValue; + deadline: Timeout; }; ask: { + buyer: Role; deadline: Timeout; asset: TokenValue; }; + // Extra phase for security reasons (a Notify input is added after the swap to avoid double-satisfaction attack + // and therefore a timeout is associated with it) swapConfirmation: { deadline: Timeout; }; }; -/* #endregion */ - /* #region State */ -export type State = +export type State = ActiveState | Closed; + +export type ActiveState = | WaitingSellerOffer | NoSellerOfferInTime | WaitingForAnswer - | WaitingForSwapConfirmation - | Closed; + | WaitingForSwapConfirmation; export type WaitingSellerOffer = { typeName: "WaitingSellerOffer"; - scheme: Scheme; - action: ProvisionOffer; }; export type NoSellerOfferInTime = { typeName: "NoSellerOfferInTime"; - scheme: Scheme; - action: RetrieveMinimumLovelaceAdded; }; export type WaitingForAnswer = { typeName: "WaitingForAnswer"; - scheme: Scheme; - actions: [Swap, Retract]; }; +/* + *+ * The buyer has provided a deposit, the swapped is theoritically done, but to avoid double-satisfaction attack an + * extra Notify input is added after the swap. This Notify can be done by anybody.
+ *+ * Marlowe's prevention of double-satisfaction attacks requires that no external payments be made from a Marlowe contract + * if another Plutus script runs in the transaction. Thus, if an open-role is distributed in a transaction, the transaction + * cannot do Pay to parties or implicit payments upon Close. Typically, the distribution of an open-role in a Marlowe contract + * will be followed by a Notify TrueObs case so that further execution of the contract does not proceed in that transactions. + * External payments can be made in subsequent transactions. + *
+ */ export type WaitingForSwapConfirmation = { typeName: "WaitingForSwapConfirmation"; - scheme: Scheme; - action: ConfirmSwap; }; +/** + * when the contract is closed. + */ export type Closed = { typeName: "Closed"; - scheme: Scheme; reason: CloseReason; }; /* #region Action */ +/** + * Action List available for the contract lifecycle. + */ export type Action = /* When Contract Created (timed out > NoOfferProvisionnedOnTime) */ | ProvisionOffer // > OfferProvisionned @@ -216,57 +218,115 @@ export class UnexpectedSwapContractState extends Error { } } -/* #endregion */ +export const getAvailableActions = ( + scheme: Scheme, + state: ActiveState +): Action[] => { + switch (state.typeName) { + case "WaitingSellerOffer": + return [ + { + typeName: "ProvisionOffer", + owner: "seller", + input: { + input_from_party: scheme.offer.seller, + that_deposits: scheme.offer.asset.amount, + of_token: scheme.offer.asset.token, + into_account: scheme.offer.seller, + }, + }, + ]; + case "NoSellerOfferInTime": + return [ + { + typeName: "RetrieveMinimumLovelaceAdded", + owner: "anybody", + input: iReduce, + }, + ]; + case "WaitingForAnswer": + return [ + { + typeName: "Swap", + owner: "buyer", + input: { + input_from_party: scheme.ask.buyer, + that_deposits: scheme.ask.asset.amount, + of_token: scheme.ask.asset.token, + into_account: scheme.ask.buyer, + }, + }, + { + typeName: "Retract", + owner: "seller", + input: { + for_choice_id: { + choice_name: "retract", + choice_owner: scheme.offer.seller, + }, + input_that_chooses_num: 0n, + }, + }, + ]; + case "WaitingForSwapConfirmation": + return [ + { + typeName: "ConfirmSwap", + owner: "anybody", + input: "input_notify", + }, + ]; + } +}; + export const getState = ( scheme: Scheme, inputHistory: Input[], state?: MarloweState ): State => { - /* #region Closed State */ - if (state === null) { - // The Contract is closed when the State is null - if (inputHistory.length === 0) { - // Offer Provision Deadline has passed and there is one reduced applied to close the contract + return state + ? getActiveState(scheme, inputHistory, state) + : getClosedState(scheme, inputHistory); +}; + +export const getClosedState = ( + scheme: Scheme, + inputHistory: Input[] +): Closed => { + switch (inputHistory.length) { + // Offer Provision Deadline has passed and there is one reduced applied to close the contract + case 0: return { typeName: "Closed", - scheme: scheme, reason: { typeName: "NoOfferProvisionnedOnTime" }, }; - } - if (inputHistory.length === 1) { + case 1: return { typeName: "Closed", - scheme: scheme, reason: { typeName: "NotAnsweredOnTime" }, }; - } - if (inputHistory.length === 2) { + case 2: { const isRetracted = - 1 === - inputHistory - .filter((input) => G.IChoice.is(input)) - .map((input) => input as IChoice) - .filter((choice) => choice.for_choice_id.choice_name === "retract") - .length; + G.IChoice.is(inputHistory[1]) && + inputHistory[1].for_choice_id.choice_name == "retract"; const nbDeposits = inputHistory.filter((input) => G.IDeposit.is(input) ).length; if (isRetracted && nbDeposits === 1) { return { typeName: "Closed", - scheme: scheme, reason: { typeName: "SellerRetracted" }, }; } if (nbDeposits === 2) { return { typeName: "Closed", - scheme: scheme, reason: { typeName: "NotNotifiedOnTime" }, }; } + break; } - if (inputHistory.length === 3) { + case 3: { const nbDeposits = inputHistory.filter((input) => G.IDeposit.is(input) ).length; @@ -276,110 +336,51 @@ export const getState = ( if (nbDeposits === 2 && nbNotify === 1) { return { typeName: "Closed", - scheme: scheme, reason: { typeName: "Swapped" }, }; } } } - /* #endregion */ + throw new UnexpectedSwapContractState(scheme); +}; +export const getActiveState = ( + scheme: Scheme, + inputHistory: Input[], + state: MarloweState +): ActiveState => { const now: Timeout = datetoTimeout(new Date()); - - if (inputHistory.length === 0) { - if (now < scheme.offer.deadline) { - const offerInput: IDeposit = { - input_from_party: scheme.participants.seller, - that_deposits: scheme.offer.asset.amount, - of_token: scheme.offer.asset.token, - into_account: scheme.participants.seller, - }; - return { - typeName: "WaitingSellerOffer", - scheme, - action: { - typeName: "ProvisionOffer", - owner: "seller", - input: offerInput, - }, - }; - } else { - return { - typeName: "NoSellerOfferInTime", - scheme, - action: { - typeName: "RetrieveMinimumLovelaceAdded", - owner: "anybody", - input: iReduce, - }, - }; - } - } - - if (inputHistory.length === 1) { - if (now < scheme.ask.deadline) { - const askInput: IDeposit = { - input_from_party: scheme.participants.buyer, - that_deposits: scheme.ask.asset.amount, - of_token: scheme.ask.asset.token, - into_account: scheme.participants.buyer, - }; - const retractInput: IChoice = { - for_choice_id: { - choice_name: "retract", - choice_owner: scheme.participants.seller, - }, - input_that_chooses_num: 0n, - }; - return { - typeName: "WaitingForAnswer", - scheme: scheme, - actions: [ - { - typeName: "Swap", - owner: "buyer", - input: askInput, - }, - { - typeName: "Retract", - owner: "seller", - input: retractInput, - }, - ], - }; - } else { - // Closed (handled upstream) - } - } - - if (inputHistory.length === 2) { - const nbDeposits = inputHistory.filter((input) => - G.IDeposit.is(input) - ).length; - if (nbDeposits === 2 && now < scheme.swapConfirmation.deadline) { - return { - typeName: "WaitingForSwapConfirmation", - scheme: scheme, - action: { - typeName: "ConfirmSwap", - owner: "anybody", - input: "input_notify", - }, - }; - } else { - // Closed (handled upstream) + switch (inputHistory.length) { + case 0: + return now < scheme.offer.deadline + ? { typeName: "WaitingSellerOffer" } + : { typeName: "NoSellerOfferInTime" }; + case 1: + if (now < scheme.ask.deadline) { + return { typeName: "WaitingForAnswer" }; + } + break; + case 2: { + const nbDeposits = inputHistory.filter((input) => + G.IDeposit.is(input) + ).length; + if (nbDeposits === 2 && now < scheme.swapConfirmation.deadline) { + return { typeName: "WaitingForSwapConfirmation" }; + } + break; } } throw new UnexpectedSwapContractState(scheme, state); }; + export function mkContract(scheme: Scheme): Contract { const mkOffer = (ask: Contract): Contract => { const depositOffer = { - party: scheme.participants.seller, + party: scheme.offer.seller, deposits: scheme.offer.asset.amount, of_token: scheme.offer.asset.token, - into_account: scheme.participants.seller, + into_account: scheme.offer.seller, }; return { @@ -390,37 +391,24 @@ export function mkContract(scheme: Scheme): Contract { }; const mkAsk = (confirmSwap: Contract): Contract => { - const asPayee = (party: Party): PayeeParty => ({ party: party }); const depositAsk = { - party: scheme.participants.buyer, + party: scheme.ask.buyer, deposits: scheme.ask.asset.amount, of_token: scheme.ask.asset.token, - into_account: scheme.participants.buyer, + into_account: scheme.ask.buyer, }; const chooseToRetract = { choose_between: [{ from: 0n, to: 0n }], for_choice: { choice_name: "retract", - choice_owner: scheme.participants.seller, + choice_owner: scheme.offer.seller, }, }; return { when: [ { case: depositAsk, - then: { - pay: scheme.offer.asset.amount, - token: scheme.offer.asset.token, - from_account: scheme.participants.seller, - to: asPayee(scheme.participants.buyer), - then: { - pay: scheme.ask.asset.amount, - token: scheme.ask.asset.token, - from_account: scheme.participants.buyer, - to: asPayee(scheme.participants.seller), - then: confirmSwap, - }, - }, + then: confirmSwap, }, { case: chooseToRetract, @@ -432,6 +420,16 @@ export function mkContract(scheme: Scheme): Contract { }; }; + /* + * The buyer has provided a deposit, the swapped is theoritically done, but to avoid double-satisfaction attack an + * extra Notify input is added after the swap. This Notify can be done by anybody. + * + * Marlowe's prevention of double-satisfaction attacks requires that no external payments be made from a Marlowe contract + * if another Plutus script runs in the transaction. Thus, if an open-role is distributed in a transaction, the transaction + * cannot do Pay to parties or implicit payments upon Close. Typically, the distribution of an open-role in a Marlowe contract + * will be followed by a Notify TrueObs case so that further execution of the contract does not proceed in that transactions. + * External payments can be made in subsequent transactions. + */ const mkSwapConfirmation = (): Contract => { return { when: [{ case: { notify_if: true }, then: close }], diff --git a/packages/language/examples/src/index.ts b/packages/language/examples/src/index.ts index 1a245ad6..1b94a50b 100644 --- a/packages/language/examples/src/index.ts +++ b/packages/language/examples/src/index.ts @@ -1,9 +1,7 @@ /** *- * Here are examples of contracts that you can reuse/modify at your free will.
- * - * Some of them are used in prototypes, others only in tests or in our examples folder at the root of this project: + * This package contains some examples that demonstrate how to create Marlowe contracts. * - Vesting : https://github.com/input-output-hk/marlowe-token-plans * - Swap : https://github.com/input-output-hk/marlowe-order-book-swap * - Survey : https://github.com/input-output-hk/marlowe-ts-sdk/tree/main/examples/survey-workshop diff --git a/packages/language/examples/src/vesting.ts b/packages/language/examples/src/vesting.ts index c84e0ae8..2c1821e2 100644 --- a/packages/language/examples/src/vesting.ts +++ b/packages/language/examples/src/vesting.ts @@ -140,8 +140,7 @@ export type VestingState = | UnknownState; /** - * `WaitingDepositByProvider` State : - * The contract has been created. But no inputs has been applied yet. + * {@link VestingState:type | Vesting State} where The contract has been created. But no inputs has been applied yet. * Inputs are predefined, as a user of this contract, you don't need to create these inputs yourself. * You can provide this input directly to `applyInputs` on the `ContractLifeCycleAPI` : * 1. `depositInput` is availaible if the connected wallet is the Provider. diff --git a/packages/runtime/lifecycle/test/examples/swap.ada.token.e2e.spec.ts b/packages/runtime/lifecycle/test/examples/swap.ada.token.e2e.spec.ts index 52661b1e..7b0e703c 100644 --- a/packages/runtime/lifecycle/test/examples/swap.ada.token.e2e.spec.ts +++ b/packages/runtime/lifecycle/test/examples/swap.ada.token.e2e.spec.ts @@ -41,15 +41,13 @@ describe("swap", () => { provisionScheme ); const scheme: AtomicSwap.Scheme = { - participants: { - seller: { address: adaProvider.address }, - buyer: { role_token: "buyer" }, - }, offer: { + seller: { address: adaProvider.address }, deadline: pipe(addDays(Date.now(), 1), datetoTimeout), asset: adaValue(2n), }, ask: { + buyer: { role_token: "buyer" }, deadline: pipe(addDays(Date.now(), 1), datetoTimeout), asset: runtimeTokenToMarloweTokenValue(tokenValueMinted), }, @@ -65,9 +63,7 @@ describe("swap", () => { ).contracts.createContract({ contract: swapContract, roles: { - [scheme.participants.buyer.role_token]: mintRole( - tokenProvider.address - ), + [scheme.ask.buyer.role_token]: mintRole(tokenProvider.address), }, }); diff --git a/packages/runtime/lifecycle/test/generic/payouts.e2e.spec.ts b/packages/runtime/lifecycle/test/generic/payouts.e2e.spec.ts index e489d80a..ab942a36 100644 --- a/packages/runtime/lifecycle/test/generic/payouts.e2e.spec.ts +++ b/packages/runtime/lifecycle/test/generic/payouts.e2e.spec.ts @@ -1,7 +1,7 @@ import { pipe } from "fp-ts/lib/function.js"; import { addDays } from "date-fns"; -import { Swap } from "@marlowe.io/language-examples"; +import { AtomicSwap } from "@marlowe.io/language-examples"; import { datetoTimeout, adaValue } from "@marlowe.io/language-core-v1"; import { Deposit } from "@marlowe.io/language-core-v1/next"; import { mkFPTSRestClient } from "@marlowe.io/runtime-rest-client/index.js"; @@ -35,16 +35,14 @@ describe("Payouts", () => { getBankPrivateKey(), provisionScheme ); - const scheme: Swap.Scheme = { - participants: { - seller: { address: adaProvider.address }, - buyer: { role_token: "buyer" }, - }, + const scheme: AtomicSwap.Scheme = { offer: { + seller: { address: adaProvider.address }, deadline: pipe(addDays(Date.now(), 1), datetoTimeout), asset: adaValue(2n), }, ask: { + buyer: { role_token: "buyer" }, deadline: pipe(addDays(Date.now(), 1), datetoTimeout), asset: runtimeTokenToMarloweTokenValue(tokenValueMinted), }, @@ -53,13 +51,13 @@ describe("Payouts", () => { }, }; - const swapContract = Swap.mkAtomicSwap(scheme); + const swapContract = AtomicSwap.mkContract(scheme); const [contractId, txCreatedContract] = await runtime( adaProvider ).contracts.createContract({ contract: swapContract, roles: { - [scheme.participants.buyer.role_token]: mintRole(tokenProvider.address), + [scheme.ask.buyer.role_token]: mintRole(tokenProvider.address), }, });