diff --git a/package.json b/package.json index 849238d..f34c95a 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,10 @@ "@types/node": "^20.10.8", "@typescript-eslint/eslint-plugin": "^6.18.1", "@typescript-eslint/parser": "^6.18.1", + "effect": "^2.2.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", + "fast-check": "^3.15.0", "ts-node": "^10.9.2", "tsup": "^8.0.1", "typescript": "^5.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f82213b..e7b57bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + dependencies: '@anastasia-labs/lucid-cardano-fork': specifier: ^0.10.7 @@ -18,12 +22,18 @@ devDependencies: '@typescript-eslint/parser': specifier: ^6.18.1 version: 6.18.1(eslint@8.56.0)(typescript@5.3.3) + effect: + specifier: ^2.2.0 + version: 2.2.0 eslint: specifier: ^8.56.0 version: 8.56.0 eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@8.56.0) + fast-check: + specifier: ^3.15.0 + version: 3.15.0 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.10.8)(typescript@5.3.3) @@ -1040,6 +1050,10 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true + /effect@2.2.0: + resolution: {integrity: sha512-ymp4cEa231FtoVDA+FZM6BR6acEXwWTz43jO1qbOspZPK170l4DfYwPCNI9FZm8tRSQgmqVp5Z+ACQUUnTlsjw==} + dev: true + /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: true @@ -1201,6 +1215,13 @@ packages: strip-final-newline: 2.0.0 dev: true + /fast-check@3.15.0: + resolution: {integrity: sha512-iBz6c+EXL6+nI931x/sbZs1JYTZtLG6Cko0ouS8LRTikhDR7+wZk4TYzdRavlnByBs2G6+nuuJ7NYL9QplNt8Q==} + engines: {node: '>=8.0.0'} + dependencies: + pure-rand: 6.0.4 + dev: true + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -1823,6 +1844,10 @@ packages: engines: {node: '>=6'} dev: true + /pure-rand@6.0.4: + resolution: {integrity: sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==} + dev: true + /pvtsutils@1.3.5: resolution: {integrity: sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==} dependencies: diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..9247115 --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,16 @@ +import {Data} from "effect" +export class NoUTXOsInScriptError { + readonly _tag = "NoUTXOsInScriptError"; +} +export class MissingDatumError { + readonly _tag = "NoUTXOsInScriptError"; +} + +export class InvalidDatumError { + readonly _tag = "InvalidDatumError"; +} + +export class TransactionError extends Data.TaggedError("TransactionError")<{ + message: string +}> {} + diff --git a/src/core/utils/utils.ts b/src/core/utils/utils.ts index 2c1ea5c..e222d3c 100644 --- a/src/core/utils/utils.ts +++ b/src/core/utils/utils.ts @@ -8,9 +8,11 @@ import { getAddressDetails, Lucid, SpendingValidator, -} from "@anastasia-labs/lucid-cardano-fork" +} from "@anastasia-labs/lucid-cardano-fork"; import { AddressD } from "../contract.types.js"; import { Either, ReadableUTxO, Result } from "../types.js"; +import { Either as E, pipe } from "effect"; +import { InvalidDatumError, MissingDatumError } from "../errors.js"; export const utxosAtScript = async ( lucid: Lucid, @@ -32,6 +34,23 @@ export const utxosAtScript = async ( return lucid.utxosAt(scriptValidatorAddr); }; +export const safeParseDatum = ( + datum: string | null | undefined, + datumType: T +) => + pipe( + datum, + E.fromNullable(() => new MissingDatumError()), + E.flatMap((d) => + E.try({ + try: () => { + return Data.from(d, datumType); + }, + catch: (_e) => new InvalidDatumError() + }) + ) + ); + export const parseSafeDatum = ( lucid: Lucid, datum: string | null | undefined, diff --git a/src/endpoints/Contract.ts b/src/endpoints/Contract.ts index 64898ea..06a42ae 100644 --- a/src/endpoints/Contract.ts +++ b/src/endpoints/Contract.ts @@ -1,35 +1,52 @@ import { ContractConfig, + LockTokensConfig, Lucid, + Result, + TxComplete, + VestingDatum, collectVestingTokens, getVestingByAddress, lockTokens, + parseUTxOsAtScript, } from "../index.js"; import linearVesting from "../../test/linearVesting.json" assert { type: "json" }; export const withMesh = (_scripts: ContractConfig) => (mesh: string) => { return { lockTokens: () => { - console.log("Implement lockTokens" ) + console.log("Implement lockTokens"); }, - collectVestingTokens: - () =>{ + collectVestingTokens: () => { console.log("Implement collectVestingTokens"); }, - getVestedTokens: () =>{ + getVestedTokens: () => { console.log("Implement getVestedTokens"); }, mesh: mesh, }; }; +// //NOTE: add generic function signatures +// type ContractActions = { +// lock: (config: LockTokensConfig) => { +// build: () => Promise>; +// }; +// collect: (config: LockTokensConfig) => { +// build: () => Promise>; +// }; +// getVestedTokens: () => +// }; export const withLucid = (scripts: ContractConfig) => async (lucid: Lucid) => { const userAddress = await lucid.wallet.address(); + //TODO: add object freeze or a better immutable function return { lockTokens: lockTokens(scripts)(lucid), collectVestingTokens: collectVestingTokens(scripts)(lucid), getVestedTokens: () => getVestingByAddress(lucid, userAddress, scripts.vesting), - lucid: lucid + lucid: lucid, + getScriptUTxOs: () => + parseUTxOsAtScript(lucid, scripts.vesting, VestingDatum), }; }; @@ -41,8 +58,6 @@ export const createContract = (config: ContractConfig) => { }; }; - export const Contract = createContract({ vesting: linearVesting.cborHex, }); - diff --git a/src/endpoints/collectVestingTokens.ts b/src/endpoints/collectVestingTokens.ts index 93eaa64..fcc3ce3 100644 --- a/src/endpoints/collectVestingTokens.ts +++ b/src/endpoints/collectVestingTokens.ts @@ -3,133 +3,127 @@ import { Lucid, SpendingValidator, toUnit, - TxComplete, } from "@anastasia-labs/lucid-cardano-fork"; -import { divCeil, parseSafeDatum, toAddress } from "../core/utils/utils.js"; -import { - CollectPartialConfig, - CollectPartialScripts, - Result, -} from "../core/types.js"; +import { divCeil, safeParseDatum, toAddress } from "../core/utils/utils.js"; +import { CollectPartialConfig, CollectPartialScripts } from "../core/types.js"; import { VestingRedeemer, VestingDatum } from "../core/contract.types.js"; import { TIME_TOLERANCE_MS } from "../index.js"; +import { Effect, Either } from "effect"; +import { TransactionError } from "../core/errors.js"; export const collectVestingTokens = (scripts: CollectPartialScripts) => (lucid: Lucid) => (config: CollectPartialConfig) => { + const program = Effect.gen(function* ($) { + config.currentTime ??= Date.now(); + const vestingValidator: SpendingValidator = { + type: "PlutusV2", + script: scripts.vesting, + }; + const vestingValidatorAddress = + lucid.utils.validatorToAddress(vestingValidator); + + const [vestedUTXO] = yield* $( + Effect.fromNullable( + yield* $( + Effect.promise(() => lucid.utxosByOutRef([config.vestingOutRef])) + ) + ) + ); + const datum = yield* $(safeParseDatum(vestedUTXO.datum, VestingDatum)); + + const vestingPeriodLength = + datum.vestingPeriodEnd - datum.vestingPeriodStart; + + datum.vestingPeriodEnd - datum.vestingPeriodStart; + + const vestingTimeRemaining = + datum.vestingPeriodEnd - BigInt(config.currentTime); + // console.log("vestingTimeRemaining", vestingTimeRemaining); + + const timeBetweenTwoInstallments = divCeil( + vestingPeriodLength, + datum.totalInstallments + ); + // console.log("timeBetweenTwoInstallments", timeBetweenTwoInstallments); + + const futureInstallments = divCeil( + vestingTimeRemaining, + timeBetweenTwoInstallments + ); + // console.log("futureInstallments", futureInstallments); + + const expectedRemainingQty = divCeil( + futureInstallments * datum.totalVestingQty, + datum.totalInstallments + ); + // console.log("expectedRemainingQty", expectedRemainingQty); + + const vestingTokenUnit = datum.assetClass.symbol + ? toUnit(datum.assetClass.symbol, datum.assetClass.name) + : "lovelace"; + // console.log("vestingTokenUnit", vestingTokenUnit) + + const vestingTokenAmount = + vestingTimeRemaining < 0n + ? vestedUTXO.assets[vestingTokenUnit] + : vestedUTXO.assets[vestingTokenUnit] - expectedRemainingQty; + // console.log("vestingTokenAmount", vestingTokenAmount); + + const beneficiaryAddress = toAddress(datum.beneficiary, lucid); + + const vestingRedeemer = + vestingTimeRemaining < 0n + ? Data.to("FullUnlock", VestingRedeemer) + : Data.to("PartialUnlock", VestingRedeemer); + + const upperBound = Number(config.currentTime + TIME_TOLERANCE_MS); + const lowerBound = Number(config.currentTime - TIME_TOLERANCE_MS); + + return yield* $( + Effect.tryPromise({ + try: () => { + if (vestingTimeRemaining < 0n) { + const tx = lucid + .newTx() + .collectFrom([vestedUTXO], vestingRedeemer) + .attachSpendingValidator(vestingValidator) + .payToAddress(beneficiaryAddress, { + [vestingTokenUnit]: vestingTokenAmount, + }) + .addSigner(beneficiaryAddress) + .validFrom(lowerBound) + .validTo(upperBound) + .complete(); + return tx; + } else { + const tx = lucid + .newTx() + .collectFrom([vestedUTXO], vestingRedeemer) + .attachSpendingValidator(vestingValidator) + .payToAddress(beneficiaryAddress, { + [vestingTokenUnit]: vestingTokenAmount, + }) + .payToContract( + vestingValidatorAddress, + { inline: Data.to(datum, VestingDatum) }, + { [vestingTokenUnit]: expectedRemainingQty } + ) + .addSigner(beneficiaryAddress) + .validFrom(lowerBound) + .validTo(upperBound) + .complete(); + return tx; + } + }, + catch: (e) => new TransactionError({ message: String(e) }), + }) + ); + }); return { - build: async (): Promise> => { - config.currentTime ??= Date.now(); - - const vestingValidator: SpendingValidator = { - type: "PlutusV2", - script: scripts.vesting, - }; - - const vestingValidatorAddress = - lucid.utils.validatorToAddress(vestingValidator); - - const vestingUTXO = ( - await lucid.utxosByOutRef([config.vestingOutRef]) - )[0]; - - if (!vestingUTXO) - return { type: "error", error: new Error("No Utxo in Script") }; - - if (!vestingUTXO.datum) - return { type: "error", error: new Error("Missing Datum") }; - - const datum = parseSafeDatum(lucid, vestingUTXO.datum, VestingDatum); - if (datum.type == "left") - return { type: "error", error: new Error(datum.value) }; - - const vestingPeriodLength = - datum.value.vestingPeriodEnd - datum.value.vestingPeriodStart; - - const vestingTimeRemaining = - datum.value.vestingPeriodEnd - BigInt(config.currentTime); - // console.log("vestingTimeRemaining", vestingTimeRemaining); - - const timeBetweenTwoInstallments = divCeil( - vestingPeriodLength, - datum.value.totalInstallments - ); - // console.log("timeBetweenTwoInstallments", timeBetweenTwoInstallments); - - const futureInstallments = divCeil( - vestingTimeRemaining, - timeBetweenTwoInstallments - ); - // console.log("futureInstallments", futureInstallments); - - const expectedRemainingQty = divCeil( - futureInstallments * datum.value.totalVestingQty, - datum.value.totalInstallments - ); - // console.log("expectedRemainingQty", expectedRemainingQty); - - const vestingTokenUnit = datum.value.assetClass.symbol - ? toUnit(datum.value.assetClass.symbol, datum.value.assetClass.name) - : "lovelace"; - // console.log("vestingTokenUnit", vestingTokenUnit) - - const vestingTokenAmount = - vestingTimeRemaining < 0n - ? vestingUTXO.assets[vestingTokenUnit] - : vestingUTXO.assets[vestingTokenUnit] - expectedRemainingQty; - // console.log("vestingTokenAmount", vestingTokenAmount); - - const beneficiaryAddress = toAddress(datum.value.beneficiary, lucid); - - const vestingRedeemer = - vestingTimeRemaining < 0n - ? Data.to("FullUnlock", VestingRedeemer) - : Data.to("PartialUnlock", VestingRedeemer); - - const upperBound = Number(config.currentTime + TIME_TOLERANCE_MS); - const lowerBound = Number(config.currentTime - TIME_TOLERANCE_MS); - - try { - if (vestingTimeRemaining < 0n) { - const tx = await lucid - .newTx() - .collectFrom([vestingUTXO], vestingRedeemer) - .attachSpendingValidator(vestingValidator) - .payToAddress(beneficiaryAddress, { - [vestingTokenUnit]: vestingTokenAmount, - }) - .addSigner(beneficiaryAddress) - .validFrom(lowerBound) - .validTo(upperBound) - .complete(); - return { type: "ok", data: tx }; - } else { - const tx = await lucid - .newTx() - .collectFrom([vestingUTXO], vestingRedeemer) - .attachSpendingValidator(vestingValidator) - .payToAddress(beneficiaryAddress, { - [vestingTokenUnit]: vestingTokenAmount, - }) - .payToContract( - vestingValidatorAddress, - { inline: Data.to(datum.value, VestingDatum) }, - { [vestingTokenUnit]: expectedRemainingQty } - ) - .addSigner(beneficiaryAddress) - .validFrom(lowerBound) - .validTo(upperBound) - .complete(); - return { type: "ok", data: tx }; - } - } catch (error) { - if (error instanceof Error) return { type: "error", error: error }; - return { - type: "error", - error: new Error(`${JSON.stringify(error)}`), - }; - } - }, + program : () => program, + build: () => program.pipe(Effect.either, Effect.runPromise), + unsafeBuild: () => program.pipe(Effect.runPromise) }; }; diff --git a/test/lock-unlock.test.ts b/test/lock-unlock.test.ts index 51ec7e9..7fd143c 100644 --- a/test/lock-unlock.test.ts +++ b/test/lock-unlock.test.ts @@ -7,6 +7,9 @@ import { } from "../src/index.js"; import { beforeEach, expect, test } from "vitest"; import { Contract } from "../src/endpoints/Contract.js"; +import { pipe } from "effect"; +import { submitAction } from "./helpers.js"; +import { Effect, Either } from "effect"; type LucidContext = { lucid: Lucid; @@ -40,6 +43,8 @@ beforeEach(async (context) => { context.emulator = new Emulator(accounts); + context.lucid = await Lucid.new(context.emulator); + context.users = await Promise.all( accounts.map(async (account) => { return Contract.withLucid( @@ -52,125 +57,94 @@ beforeEach(async (context) => { }); test("Test - LockTokens, Unlock Tokens", async ({ + lucid, users, emulator, }) => { - const lockVestingUnSigned = await users[0] - .lockTokens({ - beneficiary: await users[1].lucid.wallet.address(), - vestingAsset: { - policyId: "2c04fa26b36a376440b0615a7cdf1a0c2df061df89c8c055e2650505", - tokenName: "63425443", - }, - totalVestingQty: 10_000_000, - vestingPeriodStart: emulator.now(), - vestingPeriodEnd: emulator.now() + TWENTY_FOUR_HOURS_MS, - firstUnlockPossibleAfter: emulator.now(), - totalInstallments: 4, - }) - .build(); - - expect(lockVestingUnSigned.type).toBe("ok"); - if (lockVestingUnSigned.type == "ok") { - await (await lockVestingUnSigned.data.sign().complete()).submit(); - } - - //NOTE: INSTALLMENT 1 - emulator.awaitBlock(1080); - - const utxosAtVesting1 = await users[1].getVestedTokens(); - // console.log("utxosAtVesting1", utxosAtVesting1); - // console.log("utxos at wallet", await lucid.utxosAt(users.account2.address)); - // console.log("INSTALLMENT 1"); - - const collectPartialUnsigned1 = await users[1] - .collectVestingTokens({ - vestingOutRef: utxosAtVesting1[0].outRef, - currentTime: emulator.now(), - }) - .build(); - - // console.log(collectPartialUnsigned1); - expect(collectPartialUnsigned1.type).toBe("ok"); - - if (collectPartialUnsigned1.type == "error") return; - // console.log(tx.data.txComplete.to_json()) - await (await collectPartialUnsigned1.data.sign().complete()).submit(); + await pipe( + await users[0] + .lockTokens({ + beneficiary: await users[1].lucid.wallet.address(), + vestingAsset: { + policyId: "2c04fa26b36a376440b0615a7cdf1a0c2df061df89c8c055e2650505", + tokenName: "63425443", + }, + totalVestingQty: 10_000_000, + vestingPeriodStart: emulator.now(), + vestingPeriodEnd: emulator.now() + TWENTY_FOUR_HOURS_MS, + firstUnlockPossibleAfter: emulator.now(), + totalInstallments: 4, + }) + .build(), + submitAction + ); - //NOTE: INSTALLMENT 2 + // //NOTE: INSTALLMENT 1 emulator.awaitBlock(1080); - const utxosAtVesting2 = await users[1].getVestedTokens(); - // console.log("utxosAtVesting2", utxosAtVesting2); - - // console.log("utxos at wallet", await lucid.utxosAt(users.account2.address)); - // console.log("INSTALLMENT 2"); - - const collectPartialUnsigned2 = await users[1] - .collectVestingTokens({ - vestingOutRef: utxosAtVesting2[0].outRef, - currentTime: emulator.now(), - }) - .build(); - - // console.log(collectPartialUnsigned); - expect(collectPartialUnsigned2.type).toBe("ok"); - - if (collectPartialUnsigned2.type == "error") return; - // console.log(tx.data.txComplete.to_json()) - await (await collectPartialUnsigned2.data.sign().complete()).submit(); + // emulator.awaitBlock(10); + // console.log( + // await pipe( + // users[1] + // .collectVestingTokens({ + // vestingOutRef: (await users[1].getVestedTokens())[0].outRef, + // currentTime: emulator.now(), + // }) + // .unsafeBuild() + // ) + // ); - //NOTE: INSTALLMENT 3 emulator.awaitBlock(1080); + const tx1 = await pipe( + users[1] + .collectVestingTokens({ + vestingOutRef: (await users[1].getVestedTokens())[0].outRef, + currentTime: emulator.now(), + }) + .program(), + Effect.andThen((tx) => Effect.promise(() => tx.sign().complete())), + Effect.andThen((tx) => Effect.promise(() => tx.submit())), + Effect.either, + Effect.runPromise + ); + expect(Either.isRight(tx1)).toBe(true); - const utxosAtVesting3 = await users[1].getVestedTokens(); - // console.log("utxosAtVesting3", utxosAtVesting3); - - // console.log("utxos at wallet", await lucid.utxosAt(users.account2.address)); - // console.log("INSTALLMENT 3"); - - const collectPartialUnsigned3 = await users[1] - .collectVestingTokens({ - vestingOutRef: utxosAtVesting3[0].outRef, - currentTime: emulator.now(), - }) - .build(); - - // console.log(collectPartialUnsigned); - expect(collectPartialUnsigned3.type).toBe("ok"); - - if (collectPartialUnsigned3.type == "error") return; - // console.log(tx.data.txComplete.to_json()) - await (await collectPartialUnsigned3.data.sign().complete()).submit(); - - //NOTE: INSTALLMENT 4 emulator.awaitBlock(1081); - const utxosAtVesting4 = await users[1].getVestedTokens(); - // console.log("utxosAtVesting4", utxosAtVesting4); - - // console.log("utxos at wallet", await lucid.utxosAt(users.account2.address)); - // console.log("INSTALLMENT 4"); + const tx2 = await pipe( + users[1] + .collectVestingTokens({ + vestingOutRef: (await users[1].getVestedTokens())[0].outRef, + currentTime: emulator.now(), + }) + .program(), + Effect.andThen((tx) => Effect.promise(() => tx.sign().complete())), + Effect.andThen((tx) => Effect.promise(() => tx.submit())), + Effect.either, + Effect.runPromise + ); + expect(Either.isRight(tx2)).toBe(true); - const collectPartialUnsigned4 = await users[1] - .collectVestingTokens({ - vestingOutRef: utxosAtVesting4[0].outRef, - currentTime: emulator.now(), - }) - .build(); + emulator.awaitBlock(1080); + const tx3 = await pipe( + users[1] + .collectVestingTokens({ + vestingOutRef: (await users[1].getVestedTokens())[0].outRef, + currentTime: emulator.now(), + }) + .program(), + Effect.andThen((tx) => Effect.promise(() => tx.sign().complete())), + Effect.andThen((tx) => Effect.promise(() => tx.submit())), + Effect.either, + Effect.runPromise + ); + expect(Either.isRight(tx3)).toBe(true); - // console.log(collectPartialUnsigned4); - expect(collectPartialUnsigned4.type).toBe("ok"); - if (collectPartialUnsigned4.type == "error") return; - // console.log(tx.data.txComplete.to_json()) - await (await collectPartialUnsigned4.data.sign().complete()).submit(); + emulator.awaitBlock(10); - emulator.awaitBlock(180); + console.log(await users[1].lucid.wallet.getUtxos()); - // console.log( - // "utxosAtVesting", - // await parseUTxOsAtScript(lucid, linearVesting.cborHex, VestingDatum) - // ); + // expect(await users[1].getScriptUTxOs()).toStrictEqual([]); // console.log("utxos at wallet", await lucid.utxosAt(users.account2.address)); // console.log( // "utxos at protocol wallet",