From ef15c80741aa4c65b6094762040fdd071497eb8d Mon Sep 17 00:00:00 2001 From: nhenin Date: Fri, 15 Dec 2023 11:49:29 +0100 Subject: [PATCH 1/3] Atomic Swap Example --- .../survey-workshop/participant/contract.js | 6 +- packages/language/examples/src/atomicSwap.ts | 444 ++++++++++++++++++ .../examples/src/contract-one-notify.ts | 3 +- packages/language/examples/src/index.ts | 20 +- packages/language/examples/src/survey.ts | 4 +- .../examples/src/swaps/swap-token-token.ts | 71 --- packages/language/examples/src/vesting.ts | 17 +- packages/language/examples/typedoc.json | 7 +- .../test/examples/swap.ada.token.e2e.spec.ts | 47 +- .../test/generic/payouts.e2e.spec.ts | 39 +- 10 files changed, 528 insertions(+), 130 deletions(-) create mode 100644 packages/language/examples/src/atomicSwap.ts delete mode 100644 packages/language/examples/src/swaps/swap-token-token.ts diff --git a/examples/survey-workshop/participant/contract.js b/examples/survey-workshop/participant/contract.js index 22dd12c5..6daf19c6 100644 --- a/examples/survey-workshop/participant/contract.js +++ b/examples/survey-workshop/participant/contract.js @@ -1,4 +1,4 @@ -import { survey, verifySurvey } from "@marlowe.io/language-examples"; +import { Survey } from "@marlowe.io/language-examples"; import * as H from "../../js/poc-helpers.js"; import { datetoTimeout, timeoutToDate } from "@marlowe.io/language-core-v1"; @@ -45,7 +45,7 @@ const expectedQuestions = [ ]; export const mkWorkshopSurvey = (options) => - survey({ + Survey.survey({ surveyParticipant: options.surveyParticipant, custodian: custodianParty, questions: expectedQuestions, @@ -55,7 +55,7 @@ export const mkWorkshopSurvey = (options) => }); export function verifySurveyContract(actual, optionalSurveyParticipant = null) { - const result = verifySurvey(expectedQuestions, actual); + const result = Survey.verifySurvey(expectedQuestions, actual); let match = result.match; if (!match) { result.logs.forEach((entry) => diff --git a/packages/language/examples/src/atomicSwap.ts b/packages/language/examples/src/atomicSwap.ts new file mode 100644 index 00000000..fbabd275 --- /dev/null +++ b/packages/language/examples/src/atomicSwap.ts @@ -0,0 +1,444 @@ +/** + *

Description

+ *

+ * 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 + * 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) + *

+ *

Security restriction for open roles

+ *

+ * 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. + *

+ * @see + * - https://github.com/input-output-hk/marlowe-cardano/blob/main/marlowe-runtime/doc/open-roles.md + * + * @example + * + * ```ts + * import { AtomicSwap } from "@marlowe.io/language-examples"; + * import { datetoTimeout, tokenValue } from "@marlowe.io/language-core-v1"; + * import { addDays } from "date-fns"; + * + * const aSellerAddressBech32 = "addr_test1qqe342swyfn75mp2anj45f8ythjyxg6m7pu0pznptl6f2d84kwuzrh8c83gzhrq5zcw7ytmqc863z5rhhwst3w4x87eq0td9ja" + * + * const tokenA = token("1f7a58a1aa1e6b047a42109ade331ce26c9c2cce027d043ff264fb1f","A") + * const tokenB = token("1f7a58a1aa1e6b047a42109ade331ce26c9c2cce027d043ff264fb1f","B") + * + * const scheme: AtomicSwap.Scheme = { + * participants: { + * seller: { address: aSellerAddressBech32 }, + * buyer: { role_token: "buyer" }, + * }, + * offer: { + * deadline: datetoTimeout(addDays(Date.now(), 1)), + * asset: tokenValue(10n)(tokenA), + * }, + * ask: { + * deadline: datetoTimeout(addDays(Date.now(), 1)), + * asset: tokenValue(10n)(tokenB), + * }, + * swapConfirmation: { + * deadline: datetoTimeout(addDays(Date.now(), 1)), + * }, + * }; + * + * const myAtomicSwap = AtomicSwap.mkContract(scheme) + * + * // .. Then you can use the runtime to pilot this contract over Cardano using `getState` ... + * ``` + * + * @packageDocumentation + */ + +import { + Contract, + close, + TokenValue, + Timeout, + Party, + PayeeParty, + Input, + MarloweState, + IChoice, + IDeposit, + INotify, + Address, + datetoTimeout, + Role, +} from "@marlowe.io/language-core-v1"; +import * as G from "@marlowe.io/language-core-v1/guards"; + +type IReduce = void; +const iReduce: void = undefined; + +/* #region Scheme */ + +export type Scheme = { + participants: { + seller: Address; + buyer: Role; + }; + offer: { + deadline: Timeout; + asset: TokenValue; + }; + ask: { + deadline: Timeout; + asset: TokenValue; + }; + swapConfirmation: { + deadline: Timeout; + }; +}; + +/* #endregion */ + +/* #region State */ +export type State = + | WaitingSellerOffer + | NoSellerOfferInTime + | WaitingForAnswer + | WaitingForSwapConfirmation + | Closed; + +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]; +}; + +export type WaitingForSwapConfirmation = { + typeName: "WaitingForSwapConfirmation"; + scheme: Scheme; + action: ConfirmSwap; +}; + +export type Closed = { + typeName: "Closed"; + scheme: Scheme; + reason: CloseReason; +}; + +/* #region Action */ +export type Action = + /* When Contract Created (timed out > NoOfferProvisionnedOnTime) */ + | ProvisionOffer // > OfferProvisionned + /* When NoOfferProvisionnedOnTime (timed out > no timeout (need to be reduced to be closed))*/ + | RetrieveMinimumLovelaceAdded // > closed + /* When OfferProvisionned (timed out > NotNotifiedOnTime) */ + | Retract // > closed + | Swap // > Swapped + /* When Swapped (timed out > NotNotifiedOnTime) */ + | ConfirmSwap; // > closed + +export type ActionParticipant = "buyer" | "seller" | "anybody"; + +export type RetrieveMinimumLovelaceAdded = { + typeName: "RetrieveMinimumLovelaceAdded"; + owner: ActionParticipant; + input: IReduce; +}; + +export type ProvisionOffer = { + typeName: "ProvisionOffer"; + owner: ActionParticipant; + input: IDeposit; +}; + +export type Swap = { + typeName: "Swap"; + owner: ActionParticipant; + input: IDeposit; +}; + +export type ConfirmSwap = { + typeName: "ConfirmSwap"; + owner: ActionParticipant; + input: INotify; +}; + +export type Retract = { + typeName: "Retract"; + owner: ActionParticipant; + input: IChoice; +}; + +/* #endregion */ + +/* #region Close Reason */ +export type CloseReason = + | NoOfferProvisionnedOnTime + | SellerRetracted + | NotAnsweredOnTime + | Swapped + | NotNotifiedOnTime; + +export type NoOfferProvisionnedOnTime = { + typeName: "NoOfferProvisionnedOnTime"; +}; +export type SellerRetracted = { typeName: "SellerRetracted" }; +export type NotAnsweredOnTime = { typeName: "NotAnsweredOnTime" }; +export type NotNotifiedOnTime = { typeName: "NotNotifiedOnTime" }; +export type Swapped = { typeName: "Swapped" }; + +/* #endregion */ + +export class UnexpectedSwapContractState extends Error { + public type = "UnexpectedSwapContractState" as const; + public scheme: Scheme; + public state?: MarloweState; + constructor(scheme: Scheme, state?: MarloweState) { + super("Swap Contract / Unexpected State"); + this.scheme = scheme; + this.state = state; + } +} + +/* #endregion */ +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 { + typeName: "Closed", + scheme: scheme, + reason: { typeName: "NoOfferProvisionnedOnTime" }, + }; + } + if (inputHistory.length === 1) { + return { + typeName: "Closed", + scheme: scheme, + reason: { typeName: "NotAnsweredOnTime" }, + }; + } + if (inputHistory.length === 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; + 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" }, + }; + } + } + if (inputHistory.length === 3) { + const nbDeposits = inputHistory.filter((input) => + G.IDeposit.is(input) + ).length; + const nbNotify = inputHistory.filter((input) => + G.INotify.is(input) + ).length; + if (nbDeposits === 2 && nbNotify === 1) { + return { + typeName: "Closed", + scheme: scheme, + reason: { typeName: "Swapped" }, + }; + } + } + } + /* #endregion */ + + 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) + } + } + + throw new UnexpectedSwapContractState(scheme, state); +}; +export function mkContract(scheme: Scheme): Contract { + const mkOffer = (ask: Contract): Contract => { + const depositOffer = { + party: scheme.participants.seller, + deposits: scheme.offer.asset.amount, + of_token: scheme.offer.asset.token, + into_account: scheme.participants.seller, + }; + + return { + when: [{ case: depositOffer, then: ask }], + timeout: scheme.offer.deadline, + timeout_continuation: close, + }; + }; + + const mkAsk = (confirmSwap: Contract): Contract => { + const asPayee = (party: Party): PayeeParty => ({ party: party }); + const depositAsk = { + party: scheme.participants.buyer, + deposits: scheme.ask.asset.amount, + of_token: scheme.ask.asset.token, + into_account: scheme.participants.buyer, + }; + const chooseToRetract = { + choose_between: [{ from: 0n, to: 0n }], + for_choice: { + choice_name: "retract", + choice_owner: scheme.participants.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, + }, + }, + }, + { + case: chooseToRetract, + then: close, + }, + ], + timeout: scheme.ask.deadline, + timeout_continuation: close, + }; + }; + + const mkSwapConfirmation = (): Contract => { + return { + when: [{ case: { notify_if: true }, then: close }], + timeout: scheme.swapConfirmation.deadline, + timeout_continuation: close, + }; + }; + + return mkOffer(mkAsk(mkSwapConfirmation())); +} diff --git a/packages/language/examples/src/contract-one-notify.ts b/packages/language/examples/src/contract-one-notify.ts index 569c8769..38eb3d46 100644 --- a/packages/language/examples/src/contract-one-notify.ts +++ b/packages/language/examples/src/contract-one-notify.ts @@ -1,9 +1,8 @@ -/* eslint-disable sort-keys-fix/sort-keys-fix */ - import { Contract, close, Timeout } from "@marlowe.io/language-core-v1"; /** * Marlowe Example : A contract with One Step (one true notify) + * @packageDocumentation */ export const oneNotifyTrue: (notifyTimeout: Timeout) => Contract = ( diff --git a/packages/language/examples/src/index.ts b/packages/language/examples/src/index.ts index 75dd690d..1a245ad6 100644 --- a/packages/language/examples/src/index.ts +++ b/packages/language/examples/src/index.ts @@ -1,6 +1,20 @@ -export * as SwapADAToken from "./swaps/swap-token-token.js"; -export { oneNotifyTrue } from "./contract-one-notify.js"; +/** + *

Contract Examples

+ *

+ * 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: + * - 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 + * + * + * @packageDocumentation + */ + export * as Vesting from "./vesting.js"; +export * as AtomicSwap from "./atomicSwap.js"; +export * as Survey from "./survey.js"; +export { oneNotifyTrue } from "./contract-one-notify.js"; export { escrow } from "./playground-v1/escrow.js"; export { escrowWithCollateral } from "./playground-v1/escrow-with-collateral.js"; -export { survey, verifySurvey } from "./survey.js"; diff --git a/packages/language/examples/src/survey.ts b/packages/language/examples/src/survey.ts index afd9bfcb..deee8fed 100644 --- a/packages/language/examples/src/survey.ts +++ b/packages/language/examples/src/survey.ts @@ -7,7 +7,7 @@ import { } from "@marlowe.io/language-core-v1"; import * as G from "@marlowe.io/language-core-v1/guards"; -type SurveyOptions = { +export type SurveyOptions = { surveyParticipant: Address; custodian: Address; questions: Question[]; @@ -16,7 +16,7 @@ type SurveyOptions = { rewardToken: Token; }; -type Question = { +export type Question = { choiceName: string; bounds: Bound; }; diff --git a/packages/language/examples/src/swaps/swap-token-token.ts b/packages/language/examples/src/swaps/swap-token-token.ts deleted file mode 100644 index ca052ea9..00000000 --- a/packages/language/examples/src/swaps/swap-token-token.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - Contract, - close, - role, - TokenValue, - Timeout, -} from "@marlowe.io/language-core-v1"; - -/** - * Marlowe Example : Swap - * Description : - * Takes Tokens A from one party and tokens B from another party, and it swaps them atomically. - */ - -export type SwapRequest = { provider: SwapParty; swapper: SwapParty }; - -export type SwapParty = { - roleName: string; - depositTimeout: Timeout; - value: TokenValue; -}; - -export const mkSwapContract: (request: SwapRequest) => Contract = ( - request -) => ({ - when: [ - { - case: { - party: role(request.provider.roleName), - deposits: request.provider.value.amount, - of_token: request.provider.value.token, - into_account: role(request.provider.roleName), - }, - then: { - when: [ - { - case: { - party: role(request.swapper.roleName), - deposits: request.swapper.value.amount, - of_token: request.swapper.value.token, - into_account: role(request.swapper.roleName), - }, - then: { - pay: request.provider.value.amount, - token: request.provider.value.token, - from_account: role(request.provider.roleName), - to: { party: role(request.swapper.roleName) }, - then: { - pay: request.swapper.value.amount, - token: request.swapper.value.token, - from_account: role(request.swapper.roleName), - to: { party: role(request.provider.roleName) }, - then: close, - }, - }, - }, - ], - timeout: request.swapper.depositTimeout, - timeout_continuation: { - pay: request.provider.value.amount, - token: request.provider.value.token, - from_account: role(request.provider.roleName), - to: { party: role(request.provider.roleName) }, - then: close, - }, - }, - }, - ], - timeout: request.provider.depositTimeout, - timeout_continuation: close, -}); diff --git a/packages/language/examples/src/vesting.ts b/packages/language/examples/src/vesting.ts index d1b7f667..c84e0ae8 100644 --- a/packages/language/examples/src/vesting.ts +++ b/packages/language/examples/src/vesting.ts @@ -1,6 +1,7 @@ /** - * This module offers capabalities for running a Vesting contract, this contract is defined - * as follows : + *

Description

+ *

+ * This module offers capabalities for running a Vesting contract : * 1. There are `N` vesting periods. * 2. Each vesting period involves `P` tokens. * 3. The Provider initially deposits `N * P` tokens into the contract. @@ -19,9 +20,16 @@ * * 8. The Provider may cancel the contract during the first vesting period. * 9. The Provider may not cancel the contract after all funds have been vested. + *

+ * + *

Limitations

+ *

+ * + * Without Merkleization, this contract can't be deployed above 3 periods of time without reaching the Plutus constraints + * when running on chain our Marlowe/Plutus Validators.

+ * * @packageDocumentation */ - import { Contract, Case, @@ -35,7 +43,6 @@ import { Environment, mkEnvironment, Input, - NormalInput, IChoice, } from "@marlowe.io/language-core-v1"; @@ -529,7 +536,7 @@ const claimerDepositDistribution = function ( /** NOTE: Currently this logic presents the withdrawal and cancel for the last period, even though it doesn't make sense * because there is nothing to cancel, and even if the claimer does a partial withdrawal, they receive the balance in their account. */ -const recursiveClaimerDepositDistribution = function ( +export const recursiveClaimerDepositDistribution = function ( request: VestingRequest, periodIndex: bigint ): Contract { diff --git a/packages/language/examples/typedoc.json b/packages/language/examples/typedoc.json index 6156dbd9..c4e0b06c 100644 --- a/packages/language/examples/typedoc.json +++ b/packages/language/examples/typedoc.json @@ -1,6 +1,11 @@ { "entryPointStrategy": "expand", - "entryPoints": ["./src/vesting.ts", "./src/swaps/swap-token-token.ts"], + "entryPoints": [ + "./src/index.ts", + "./src/vesting.ts", + "./src/atomicSwap.ts", + "./src/survey.ts" + ], "tsconfig": "./src/tsconfig.json", "categorizeByGroup": false } 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 3ae99b06..52661b1e 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 @@ -2,7 +2,6 @@ import { pipe } from "fp-ts/lib/function.js"; import { addDays } from "date-fns"; import { Deposit } from "@marlowe.io/language-core-v1/next"; -import * as Examples from "@marlowe.io/language-examples"; import { datetoTimeout, adaValue } from "@marlowe.io/language-core-v1"; import { mkFPTSRestClient } from "@marlowe.io/runtime-rest-client/index.js"; @@ -16,11 +15,8 @@ import console from "console"; import { runtimeTokenToMarloweTokenValue } from "@marlowe.io/runtime-core"; import { onlyByContractIds } from "@marlowe.io/runtime-lifecycle/api"; import { MINUTES } from "@marlowe.io/adapter/time"; -import { mintRole, openRole } from "@marlowe.io/runtime-rest-client/contract"; -import { - AddressBech32, - AddressBech32Guard, -} from "@marlowe.io/runtime-rest-client/contract/rolesConfigurations.js"; +import { mintRole } from "@marlowe.io/runtime-rest-client/contract"; +import { AtomicSwap } from "@marlowe.io/language-examples"; global.console = console; @@ -44,35 +40,38 @@ describe("swap", () => { getBankPrivateKey(), provisionScheme ); - const swapRequest = { - provider: { - roleName: "Ada provider", - depositTimeout: pipe(addDays(Date.now(), 1), datetoTimeout), - value: adaValue(2n), + const scheme: AtomicSwap.Scheme = { + participants: { + seller: { address: adaProvider.address }, + buyer: { role_token: "buyer" }, }, - swapper: { - roleName: "Token provider", - depositTimeout: pipe(addDays(Date.now(), 2), datetoTimeout), - value: runtimeTokenToMarloweTokenValue(tokenValueMinted), + offer: { + deadline: pipe(addDays(Date.now(), 1), datetoTimeout), + asset: adaValue(2n), + }, + ask: { + deadline: pipe(addDays(Date.now(), 1), datetoTimeout), + asset: runtimeTokenToMarloweTokenValue(tokenValueMinted), + }, + swapConfirmation: { + deadline: pipe(addDays(Date.now(), 1), datetoTimeout), }, }; - const swapContract = Examples.SwapADAToken.mkSwapContract(swapRequest); - // Creation of the Contract - const [contractId, txIdContractCreated] = await runtime( + const swapContract = AtomicSwap.mkContract(scheme); + + const [contractId, txCreatedContract] = await runtime( adaProvider ).contracts.createContract({ contract: swapContract, roles: { - [swapRequest.provider.roleName]: mintRole( - adaProvider.address as unknown as AddressBech32 - ), - [swapRequest.swapper.roleName]: mintRole( - tokenProvider.address as unknown as AddressBech32 + [scheme.participants.buyer.role_token]: mintRole( + tokenProvider.address ), }, }); - await runtime(adaProvider).wallet.waitConfirmation(txIdContractCreated); + + await runtime(adaProvider).wallet.waitConfirmation(txCreatedContract); // Applying the first Deposit let next = await runtime(adaProvider).contracts.getApplicableInputs( contractId diff --git a/packages/runtime/lifecycle/test/generic/payouts.e2e.spec.ts b/packages/runtime/lifecycle/test/generic/payouts.e2e.spec.ts index c9fcb6cf..e489d80a 100644 --- a/packages/runtime/lifecycle/test/generic/payouts.e2e.spec.ts +++ b/packages/runtime/lifecycle/test/generic/payouts.e2e.spec.ts @@ -1,9 +1,9 @@ import { pipe } from "fp-ts/lib/function.js"; import { addDays } from "date-fns"; -import * as Examples from "@marlowe.io/language-examples"; +import { Swap } from "@marlowe.io/language-examples"; import { datetoTimeout, adaValue } from "@marlowe.io/language-core-v1"; -import { Next, Deposit } from "@marlowe.io/language-core-v1/next"; +import { Deposit } from "@marlowe.io/language-core-v1/next"; import { mkFPTSRestClient } from "@marlowe.io/runtime-rest-client/index.js"; import { @@ -17,7 +17,6 @@ import { runtimeTokenToMarloweTokenValue } from "@marlowe.io/runtime-core"; import { onlyByContractIds } from "@marlowe.io/runtime-lifecycle/api"; import { MINUTES } from "@marlowe.io/adapter/time"; import { mintRole } from "@marlowe.io/runtime-rest-client/contract"; -import { AddressBech32 } from "@marlowe.io/runtime-rest-client/contract/rolesConfigurations.js"; global.console = console; @@ -36,32 +35,34 @@ describe("Payouts", () => { getBankPrivateKey(), provisionScheme ); - const swapRequest = { - provider: { - roleName: "Ada provider", - depositTimeout: pipe(addDays(Date.now(), 1), datetoTimeout), - value: adaValue(2n), + const scheme: Swap.Scheme = { + participants: { + seller: { address: adaProvider.address }, + buyer: { role_token: "buyer" }, }, - swapper: { - roleName: "Token provider", - depositTimeout: pipe(addDays(Date.now(), 2), datetoTimeout), - value: runtimeTokenToMarloweTokenValue(tokenValueMinted), + offer: { + deadline: pipe(addDays(Date.now(), 1), datetoTimeout), + asset: adaValue(2n), + }, + ask: { + deadline: pipe(addDays(Date.now(), 1), datetoTimeout), + asset: runtimeTokenToMarloweTokenValue(tokenValueMinted), + }, + swapConfirmation: { + deadline: pipe(addDays(Date.now(), 1), datetoTimeout), }, }; - const swapContract = Examples.SwapADAToken.mkSwapContract(swapRequest); + + const swapContract = Swap.mkAtomicSwap(scheme); const [contractId, txCreatedContract] = await runtime( adaProvider ).contracts.createContract({ contract: swapContract, roles: { - [swapRequest.provider.roleName]: mintRole( - adaProvider.address as unknown as AddressBech32 - ), - [swapRequest.swapper.roleName]: mintRole( - tokenProvider.address as unknown as AddressBech32 - ), + [scheme.participants.buyer.role_token]: mintRole(tokenProvider.address), }, }); + await runtime(adaProvider).wallet.waitConfirmation(txCreatedContract); // Applying the first Deposit From dadbf255bf7f772b297ca11e3bd75195bc4eebeb Mon Sep 17 00:00:00 2001 From: nhenin Date: Fri, 15 Dec 2023 17:39:57 +0100 Subject: [PATCH 2/3] updated changelog --- changelog.d/20231215_173732_nicolas.henin.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/20231215_173732_nicolas.henin.md diff --git a/changelog.d/20231215_173732_nicolas.henin.md b/changelog.d/20231215_173732_nicolas.henin.md new file mode 100644 index 00000000..bdfc2602 --- /dev/null +++ b/changelog.d/20231215_173732_nicolas.henin.md @@ -0,0 +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)) From c5795ea35f07f3313f214d1d5c83cbbc8888765f Mon Sep 17 00:00:00 2001 From: nhenin Date: Tue, 19 Dec 2023 14:05:28 +0100 Subject: [PATCH 3/3] PR Feedback --- changelog.d/20231215_173732_nicolas.henin.md | 2 +- packages/language/examples/src/atomicSwap.ts | 328 +++++++++--------- packages/language/examples/src/index.ts | 4 +- packages/language/examples/src/vesting.ts | 3 +- .../test/examples/swap.ada.token.e2e.spec.ts | 10 +- .../test/generic/payouts.e2e.spec.ts | 14 +- 6 files changed, 175 insertions(+), 186 deletions(-) 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 @@ *

Description

*

* 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) - *

- *

Security restriction for open roles

- *

- * 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 @@ /** *

Contract Examples

*

- * 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), }, });