From 8a3b78f5f26369b63370bb4ad6fcdf23c7ebd1a7 Mon Sep 17 00:00:00 2001 From: nhenin Date: Mon, 15 Jan 2024 12:42:13 +0100 Subject: [PATCH] Test Atomic Swap Nominal Use Case --- packages/language/examples/src/atomicSwap.ts | 67 +++-- .../rest/src/contract/rolesConfigurations.ts | 2 +- packages/runtime/lifecycle/src/api.ts | 1 + .../test/examples/swap.ada.token.e2e.spec.ts | 258 +++++++++++------- .../test/generic/contracts.e2e.spec.ts | 2 +- .../testing-kit/src/wallet/lucid/index.ts | 5 +- 6 files changed, 211 insertions(+), 124 deletions(-) diff --git a/packages/language/examples/src/atomicSwap.ts b/packages/language/examples/src/atomicSwap.ts index 2c9689e6..97dd9f60 100644 --- a/packages/language/examples/src/atomicSwap.ts +++ b/packages/language/examples/src/atomicSwap.ts @@ -69,9 +69,7 @@ import { datetoTimeout, } from "@marlowe.io/language-core-v1"; import * as G from "@marlowe.io/language-core-v1/guards"; - -export type IReduce = void; -const iReduce: void = undefined; +import { SingleInputTx } from "@marlowe.io/language-core-v1/transaction.js"; /** * Atomic Swap Scheme, canonical information to define the contract. @@ -160,7 +158,6 @@ export type ActionParticipant = "buyer" | "seller" | "anybody"; export type RetrieveMinimumLovelaceAdded = { typeName: "RetrieveMinimumLovelaceAdded"; owner: ActionParticipant; - input: IReduce; }; export type ProvisionOffer = { @@ -207,14 +204,31 @@ export type Swapped = { typeName: "Swapped" }; /* #endregion */ -export class UnexpectedSwapContractState extends Error { - public type = "UnexpectedSwapContractState" as const; +export class UnexpectedActiveSwapContractState extends Error { + public type = "UnexpectedActiveSwapContractState" as const; public scheme: Scheme; - public state?: MarloweState; - constructor(scheme: Scheme, state?: MarloweState) { - super("Swap Contract / Unexpected State"); + public state: MarloweState; + public inputHistory: SingleInputTx[]; + constructor( + scheme: Scheme, + inputHistory: SingleInputTx[], + state: MarloweState + ) { + super("Swap Contract / Unexpected Active State"); this.scheme = scheme; this.state = state; + this.inputHistory = inputHistory; + } +} + +export class UnexpectedClosedSwapContractState extends Error { + public type = "UnexpectedClosedSwapContractState" as const; + public scheme: Scheme; + public inputHistory: SingleInputTx[]; + constructor(scheme: Scheme, inputHistory: SingleInputTx[]) { + super("Swap Contract / Unexpected closed State"); + this.scheme = scheme; + this.inputHistory = inputHistory; } } @@ -241,7 +255,6 @@ export const getAvailableActions = ( { typeName: "RetrieveMinimumLovelaceAdded", owner: "anybody", - input: iReduce, }, ]; case "WaitingForAnswer": @@ -281,7 +294,7 @@ export const getAvailableActions = ( export const getState = ( scheme: Scheme, - inputHistory: Input[], + inputHistory: SingleInputTx[], state?: MarloweState ): State => { return state @@ -291,7 +304,7 @@ export const getState = ( export const getClosedState = ( scheme: Scheme, - inputHistory: Input[] + inputHistory: SingleInputTx[] ): Closed => { switch (inputHistory.length) { // Offer Provision Deadline has passed and there is one reduced applied to close the contract @@ -307,10 +320,10 @@ export const getClosedState = ( }; case 2: { const isRetracted = - G.IChoice.is(inputHistory[1]) && - inputHistory[1].for_choice_id.choice_name == "retract"; - const nbDeposits = inputHistory.filter((input) => - G.IDeposit.is(input) + G.IChoice.is(inputHistory[1].input) && + inputHistory[1].input.for_choice_id.choice_name == "retract"; + const nbDeposits = inputHistory.filter((singleInputTx) => + G.IDeposit.is(singleInputTx.input) ).length; if (isRetracted && nbDeposits === 1) { return { @@ -327,11 +340,11 @@ export const getClosedState = ( break; } case 3: { - const nbDeposits = inputHistory.filter((input) => - G.IDeposit.is(input) + const nbDeposits = inputHistory.filter((singleInputTx) => + G.IDeposit.is(singleInputTx.input) ).length; - const nbNotify = inputHistory.filter((input) => - G.INotify.is(input) + const nbNotify = inputHistory.filter((singleInputTx) => + G.INotify.is(singleInputTx.input) ).length; if (nbDeposits === 2 && nbNotify === 1) { return { @@ -341,12 +354,12 @@ export const getClosedState = ( } } } - throw new UnexpectedSwapContractState(scheme); + throw new UnexpectedClosedSwapContractState(scheme, inputHistory); }; export const getActiveState = ( scheme: Scheme, - inputHistory: Input[], + inputHistory: SingleInputTx[], state: MarloweState ): ActiveState => { const now: Timeout = datetoTimeout(new Date()); @@ -361,8 +374,8 @@ export const getActiveState = ( } break; case 2: { - const nbDeposits = inputHistory.filter((input) => - G.IDeposit.is(input) + const nbDeposits = inputHistory.filter((singleInputTx) => + G.IDeposit.is(singleInputTx.input) ).length; if (nbDeposits === 2 && now < scheme.swapConfirmation.deadline) { return { typeName: "WaitingForSwapConfirmation" }; @@ -371,7 +384,7 @@ export const getActiveState = ( } } - throw new UnexpectedSwapContractState(scheme, state); + throw new UnexpectedActiveSwapContractState(scheme, inputHistory, state); }; export function mkContract(scheme: Scheme): Contract { @@ -408,12 +421,12 @@ export function mkContract(scheme: Scheme): Contract { pay: scheme.offer.asset.amount, token: scheme.offer.asset.token, from_account: scheme.offer.seller, - to: { party: scheme.ask.buyer }, + to: { account: scheme.ask.buyer }, then: { pay: scheme.ask.asset.amount, token: scheme.ask.asset.token, from_account: scheme.ask.buyer, - to: { party: scheme.offer.seller }, + to: { account: scheme.offer.seller }, then: confirmSwap, }, }; diff --git a/packages/runtime/client/rest/src/contract/rolesConfigurations.ts b/packages/runtime/client/rest/src/contract/rolesConfigurations.ts index 31c11669..37c16c51 100644 --- a/packages/runtime/client/rest/src/contract/rolesConfigurations.ts +++ b/packages/runtime/client/rest/src/contract/rolesConfigurations.ts @@ -209,7 +209,7 @@ export const useMintedRoles = ( : (policyId as UsePolicyWithClosedRoleTokens); /** - * Configure the minting of a Closed Role Token. + * Configure the minting of a Role Token. * @param openness where to distribute the token (Either openly or closedly) * @param quantity Quantity of the Closed Role Token (by Default an NFT (==1)) * @param metadata Token Metadata of the Token diff --git a/packages/runtime/lifecycle/src/api.ts b/packages/runtime/lifecycle/src/api.ts index 6d5c431f..1aac6c7b 100644 --- a/packages/runtime/lifecycle/src/api.ts +++ b/packages/runtime/lifecycle/src/api.ts @@ -282,6 +282,7 @@ export interface PayoutsAPI { */ available(filters?: Filters): Promise; + // TODO : Withdraw should not `waitConfirmation` behind the scene and it should return a `TxId` (https://github.com/input-output-hk/marlowe-ts-sdk/issues/170) /** * TODO: comment * @throws DecodingError 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 58eb058e..5259067f 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 @@ -10,6 +10,7 @@ import { import console from "console"; import { ContractId, + payoutId, runtimeTokenToMarloweTokenValue, } from "@marlowe.io/runtime-core"; import { MINUTES } from "@marlowe.io/adapter/time"; @@ -22,9 +23,23 @@ import { logWalletInfo, readEnvConfigurationFile, mkTestEnvironment, + logError, } from "@marlowe.io/testing-kit"; import { AxiosError } from "axios"; import { MarloweJSON } from "@marlowe.io/adapter/codec"; +import { + onlyByContractIds, + RuntimeLifecycle, +} from "@marlowe.io/runtime-lifecycle/api"; +import { + Action, + Closed, + ConfirmSwap, + ProvisionOffer, + Scheme, + Swap, +} from "@marlowe.io/language-examples/atomicSwap.js"; +import { mintRole } from "@marlowe.io/runtime-rest-client/contract"; global.console = console; @@ -58,10 +73,9 @@ describe("swap", () => { await logWalletInfo("seller", seller.wallet); await logWalletInfo("buyer", buyer.wallet); - const sellerAddress = await seller.wallet.getChangeAddress(); const scheme: AtomicSwap.Scheme = { offer: { - seller: { address: sellerAddress }, + seller: { address: await seller.wallet.getChangeAddress() }, deadline: pipe(addDays(Date.now(), 1), datetoTimeout), asset: runtimeTokenToMarloweTokenValue( seller.assetsProvisioned.tokens[0] @@ -82,13 +96,16 @@ describe("swap", () => { const swapContract = AtomicSwap.mkContract(scheme); logDebug(`contract: ${MarloweJSON.stringify(swapContract, null, 4)}`); + logInfo("Contract Creation"); + const openroleCOnfig = mintRole("OpenRole"); + logInfo(`Config ${MarloweJSON.stringify(openroleCOnfig, null, 4)}`); const [contractId, txCreatedContract] = await runtime .mkLifecycle(seller.wallet) .contracts.createContract({ contract: swapContract, minimumLovelaceUTxODeposit: 3_000_000, roles: { - [scheme.ask.buyer.role_token]: sellerAddress, + [scheme.ask.buyer.role_token]: openroleCOnfig, }, }); logInfo("Contract Created"); @@ -96,76 +113,104 @@ describe("swap", () => { await seller.wallet.waitRuntimeSyncingTillCurrentWalletTip( runtime.client ); - // TODO : Completing the Test - // const inputHistory = await getInputHistory(runtime.client, contractId); - // const marloweState = await getMarloweStatefromAnActiveContract( - // runtime.client, - // contractId - // ); - // const contractState = AtomicSwap.getActiveState( - // scheme, - // inputHistory, - // marloweState - // ); - // const availableActions = AtomicSwap.getAvailableActions( - // scheme, - // contractState - // ); - // expect(contractState.typeName).toBe("WaitingSellerOffer"); - // expect(availableActions.length).toBe(1); - - // // // Applying the first Deposit - // let next = await runtime(adaProvider).contracts.getApplicableInputs( - // contractId - // ); - // const txFirstTokensDeposited = await runtime( - // adaProvider - // ).contracts.applyInputs(contractId, { - // inputs: [pipe(next.applicable_inputs.deposits[0], Deposit.toInput)], - // }); - // await runtime(adaProvider).wallet.waitConfirmation( - // txFirstTokensDeposited - // ); - - // // Applying the second Deposit - // next = await runtime(tokenProvider).contracts.getApplicableInputs( - // contractId - // ); - // await runtime(tokenProvider).contracts.applyInputs(contractId, { - // inputs: [pipe(next.applicable_inputs.deposits[0], Deposit.toInput)], - // }); - // await runtime(tokenProvider).wallet.waitConfirmation( - // txFirstTokensDeposited - // ); - - // // Retrieving Payouts - // const adaProviderAvalaiblePayouts = await runtime( - // adaProvider - // ).payouts.available(onlyByContractIds([contractId])); - // expect(adaProviderAvalaiblePayouts.length).toBe(1); - // await runtime(adaProvider).payouts.withdraw([ - // adaProviderAvalaiblePayouts[0].payoutId, - // ]); - // const adaProviderWithdrawn = await runtime(adaProvider).payouts.withdrawn( - // onlyByContractIds([contractId]) - // ); - // expect(adaProviderWithdrawn.length).toBe(1); - - // const tokenProviderAvalaiblePayouts = await runtime( - // tokenProvider - // ).payouts.available(onlyByContractIds([contractId])); - // expect(tokenProviderAvalaiblePayouts.length).toBe(1); - // await runtime(tokenProvider).payouts.withdraw([ - // tokenProviderAvalaiblePayouts[0].payoutId, - // ]); - // const tokenProviderWithdrawn = await runtime( - // tokenProvider - // ).payouts.withdrawn(onlyByContractIds([contractId])); - // expect(tokenProviderWithdrawn.length).toBe(1); + + logInfo(`Seller > Provision Offer`); + + let actions = await getAvailableActions( + runtime.client, + runtime.mkLifecycle(seller.wallet), + scheme, + contractId + ); + + expect(actions.length).toBe(1); + expect(actions[0].typeName).toBe("ProvisionOffer"); + const provisionOffer = actions[0] as ProvisionOffer; + const offerProvisionedTx = await runtime + .mkLifecycle(seller.wallet) + .contracts.applyInputs(contractId, { + inputs: [provisionOffer.input], + }); + + await seller.wallet.waitConfirmation(offerProvisionedTx); + await seller.wallet.waitRuntimeSyncingTillCurrentWalletTip( + runtime.client + ); + + logInfo(`Buyer > Swap`); + + actions = await getAvailableActions( + runtime.client, + runtime.mkLifecycle(seller.wallet), + scheme, + contractId + ); + + expect(actions.length).toBe(2); + expect(actions[0].typeName).toBe("Swap"); + expect(actions[1].typeName).toBe("Retract"); + const swap = actions[0] as Swap; + const swappedTx = await runtime + .mkLifecycle(buyer.wallet) + .contracts.applyInputs(contractId, { inputs: [swap.input] }); + + await buyer.wallet.waitConfirmation(swappedTx); + await buyer.wallet.waitRuntimeSyncingTillCurrentWalletTip( + runtime.client + ); + + logInfo(`Anyone > Confirm Swap`); + + actions = await getAvailableActions( + runtime.client, + runtime.mkLifecycle(bank), + scheme, + contractId + ); + + expect(actions.length).toBe(1); + expect(actions[0].typeName).toBe("ConfirmSwap"); + const confirmSwap = actions[0] as ConfirmSwap; + const swapConfirmedTx = await runtime + .mkLifecycle(bank) + .contracts.applyInputs(contractId, { inputs: [confirmSwap.input] }); + + await bank.waitConfirmation(swapConfirmedTx); + await bank.waitRuntimeSyncingTillCurrentWalletTip(runtime.client); + + const closedState = await getClosedState( + runtime.client, + runtime.mkLifecycle(bank), + scheme, + contractId + ); + + expect(closedState.reason.typeName).toBe("Swapped"); + + logInfo(`Buyer > Retrieve Payout`); + + const buyerPayouts = await runtime + .mkLifecycle(buyer.wallet) + .payouts.available(onlyByContractIds([contractId])); + expect(buyerPayouts.length).toBe(1); + await runtime + .mkLifecycle(buyer.wallet) + .payouts.withdraw([buyerPayouts[0].payoutId]); + + await logWalletInfo("seller", seller.wallet); + await logWalletInfo("buyer", buyer.wallet); } catch (e) { - console.log(`caught : ${JSON.stringify(e)}`); + logError( + `Error occured while Executing the Tests : ${MarloweJSON.stringify( + e, + null, + 4 + )}` + ); const error = e as AxiosError; - console.log(`caught : ${JSON.stringify(error.response?.data)}`); + logError( + `Details : ${MarloweJSON.stringify(error.response?.data, null, 4)}` + ); expect(true).toBe(false); } }, @@ -173,6 +218,55 @@ describe("swap", () => { ); }); +const getClosedState = async ( + runtimeClient: RestClient, + runtimeLifecycle: RuntimeLifecycle, + scheme: Scheme, + contractId: ContractId +): Promise => { + const inputHistory = + await runtimeLifecycle.contracts.getInputHistory(contractId); + + await shouldBeAClosedContract(runtimeClient, contractId); + + return AtomicSwap.getClosedState(scheme, inputHistory); +}; + +const getAvailableActions = async ( + runtimeClient: RestClient, + runtimeLifecycle: RuntimeLifecycle, + scheme: Scheme, + contractId: ContractId +): Promise => { + const inputHistory = + await runtimeLifecycle.contracts.getInputHistory(contractId); + + const marloweState = await getMarloweStatefromAnActiveContract( + runtimeClient, + contractId + ); + const contractState = AtomicSwap.getActiveState( + scheme, + inputHistory, + marloweState + ); + return AtomicSwap.getAvailableActions(scheme, contractState); +}; + +const shouldBeAClosedContract = async ( + restClient: RestClient, + contractId: ContractId +): Promise => { + const state = await restClient + .getContractById(contractId) + .then((contractDetails) => contractDetails.state); + if (state) { + throw new Error("Contract retrieved is not Closed"); + } else { + return; + } +}; + const shouldBeAnActiveContract = (state?: MarloweState): MarloweState => { if (state) return state; else throw new Error("Contract retrieved is not Active"); @@ -185,25 +279,3 @@ const getMarloweStatefromAnActiveContract = ( restClient .getContractById(contractId) .then((contractDetails) => shouldBeAnActiveContract(contractDetails.state)); - -// new data type that timeInterval + single input -// > array[SingleInputTx] -const getInputHistory = ( - restClient: RestClient, - contractId: ContractId -): Promise => - restClient - .getTransactionsForContract(contractId) - .then((result) => - Promise.all( - result.transactions.map((transaction) => - restClient.getContractTransactionById( - contractId, - transaction.transactionId - ) - ) - ) - ) - .then((txsDetails) => - txsDetails.map((txDetails) => txDetails.inputs).flat() - ); diff --git a/packages/runtime/lifecycle/test/generic/contracts.e2e.spec.ts b/packages/runtime/lifecycle/test/generic/contracts.e2e.spec.ts index d808322f..36ea81b7 100644 --- a/packages/runtime/lifecycle/test/generic/contracts.e2e.spec.ts +++ b/packages/runtime/lifecycle/test/generic/contracts.e2e.spec.ts @@ -19,7 +19,7 @@ import { global.console = console; -describe.skip("Runtime Contract Lifecycle ", () => { +describe("Runtime Contract Lifecycle ", () => { it( "can create a Marlowe Contract ", async () => { diff --git a/packages/testing-kit/src/wallet/lucid/index.ts b/packages/testing-kit/src/wallet/lucid/index.ts index 6fff8e13..62dc9dd6 100644 --- a/packages/testing-kit/src/wallet/lucid/index.ts +++ b/packages/testing-kit/src/wallet/lucid/index.ts @@ -63,7 +63,8 @@ const waitRuntimeSyncingTillCurrentWalletTip = await waitForPredicatePromise( isRuntimeChainMoreAdvancedThan(client, currentLucidSlot) ); - return sleep(5); + process.stdout.write("\n"); + return sleep(15); }; /** @@ -79,7 +80,7 @@ export const isRuntimeChainMoreAdvancedThan = return true; } else { const delta = aSlotNo - status.tips.runtimeChain.blockHeader.slotNo; - logWarning( + process.stdout.write( `Waiting Runtime to reach that point (${delta} slots behind (~${delta}s)) ` ); return false;