diff --git a/README.md b/README.md index 0e08e1d5..3b7c8071 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Regulated stablecoin POC -This is a proof-of-concept for a regulated stablecoin. It is NOT a finished product. +This is a proof-of-concept for a regulated token with freeze and seize capabilities. + +![Screenshot of the UI showing the minting authority.](image.png) # Overview @@ -20,6 +22,7 @@ This repository contains * A user interface that implements the use cases using browser-based wallets. Based on next.js and lucid. * An OCI container image with the on-chain code, the off-chain code and the UI + With the container image it is possible to run the complete system locally with just a single command. There is no need to install the build toolchain or to operate a cardano node or related infrastructure. The image can even be used to interact with existing deployments of the POC. diff --git a/cabal.project b/cabal.project index 82879c2c..b06ef13b 100644 --- a/cabal.project +++ b/cabal.project @@ -41,9 +41,8 @@ source-repository-package source-repository-package type: git - -- location: https://github.com/j-mueller/sc-tools - location: https://github.com/amirmrad/sc-tools - tag: 6c63efe07015e87719d77fa3fabfe07f959c7227 + location: https://github.com/j-mueller/sc-tools + tag: a3662e093f40082dd6fa525bb0640a10caa1bd70 subdir: src/devnet src/blockfrost diff --git a/frontend/src/app/[username]/index.tsx b/frontend/src/app/[username]/index.tsx index c9852bcb..4f74aeca 100644 --- a/frontend/src/app/[username]/index.tsx +++ b/frontend/src/app/[username]/index.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import axios from 'axios'; //Mui imports -import { Box, Typography } from '@mui/material'; +import { Box, Checkbox, FormControlLabel, Typography } from '@mui/material'; //Local components import useStore from '../store/store'; @@ -19,6 +19,7 @@ import CopyTextField from '../components/CopyTextField'; export default function Profile() { const { lucid, currentUser, mintAccount, changeAlertInfo, changeWalletAccountDetails } = useStore(); const accounts = useStore((state) => state.accounts); + const [overrideTx, setOverrideTx] = useState(false); useEffect(() => { useStore.getState(); @@ -27,8 +28,8 @@ export default function Profile() { const getUserAccountDetails = () => { switch (currentUser) { - case "User A": return accounts.userA; - case "User B": return accounts.userB; + case "Alice": return accounts.alice; + case "Bob": return accounts.bob; case "Connected Wallet": return accounts.walletUser; }; }; @@ -38,10 +39,10 @@ export default function Profile() { const [sendRecipientAddress, setsendRecipientAddress] = useState('address'); const onSend = async () => { - if (getUserAccountDetails()?.status === 'Frozen') { + if (getUserAccountDetails()?.status === 'Frozen' && !overrideTx) { changeAlertInfo({ severity: 'error', - message: 'Cannot send WST with frozen account.', + message: 'Cannot send WST with frozen address.', open: true, link: '' }); @@ -51,7 +52,7 @@ export default function Profile() { changeAlertInfo({severity: 'info', message: 'Transaction processing', open: true, link: ''}); const accountInfo = getUserAccountDetails(); if (!accountInfo) { - console.error("No valid send account found! Cannot send."); + console.error("No valid send address found! Cannot send."); return; } lucid.selectWallet.fromSeed(accountInfo.mnemonic); @@ -61,6 +62,7 @@ export default function Profile() { quantity: sendTokenAmount, recipient: sendRecipientAddress, sender: accountInfo.address, + submit_failing_tx: overrideTx }; try { const response = await axios.post( @@ -77,7 +79,7 @@ export default function Profile() { const txId = await signAndSentTx(lucid, tx); await updateAccountBalance(sendRecipientAddress); await updateAccountBalance(accountInfo.address); - changeAlertInfo({severity: 'success', message: 'Transaction sent successfully!', open: true, link: `https://preview.cardanoscan.io/transaction/${txId.inputs[0].transaction_id}`}); + changeAlertInfo({severity: 'success', message: 'Transaction sent successfully!', open: true, link: `https://preview.cexplorer.io/tx/${txId}`}); } catch (error) { console.error('Send failed:', error); } @@ -111,6 +113,12 @@ export default function Profile() { label="Recipient’s Address" fullWidth={true} /> + setOverrideTx(x.target.checked)} />} + label="⚠️ Force send failing transaction" + sx={{ mb: 2 }} + /> + ; const receiveContent = @@ -125,8 +133,9 @@ export default function Profile() {
- Account Balance - {getUserAccountDetails()?.balance} WST + Address Balance + {getUserAccountDetails()?.balance.wst} WST + {getUserAccountDetails()?.balance.ada} Ada {getUserAccountDetails()?.address.slice(0,15)} diff --git a/frontend/src/app/[username]/page.tsx b/frontend/src/app/[username]/page.tsx index 018517fe..5e533e85 100644 --- a/frontend/src/app/[username]/page.tsx +++ b/frontend/src/app/[username]/page.tsx @@ -2,8 +2,8 @@ import Profile from '.'; export async function generateStaticParams() { return [ - { username: 'user-a' }, - { username: 'user-b' }, + { username: 'alice' }, + { username: 'bob' }, { username: 'connected-wallet' } // connected wallet ] } diff --git a/frontend/src/app/clientLayout.tsx b/frontend/src/app/clientLayout.tsx index 4a0f4820..de97958f 100644 --- a/frontend/src/app/clientLayout.tsx +++ b/frontend/src/app/clientLayout.tsx @@ -22,13 +22,13 @@ export default function ClientLayout({ children }: { children: React.ReactNode } try { // retrieve wallet info const mintAuthorityWallet = await getWalletFromSeed(mintAccount.mnemonic); - const walletA = await getWalletFromSeed(accounts.userA.mnemonic); - const walletB = await getWalletFromSeed(accounts.userB.mnemonic); + const walletA = await getWalletFromSeed(accounts.alice.mnemonic); + const walletB = await getWalletFromSeed(accounts.bob.mnemonic); // Update Zustand store with the initialized wallet information changeMintAccountDetails({ ...mintAccount, address: mintAuthorityWallet.address}); - changeWalletAccountDetails('userA', { ...accounts.userA, address: walletA.address},); - changeWalletAccountDetails('userB', { ...accounts.userB, address: walletB.address}); + changeWalletAccountDetails('alice', { ...accounts.alice, address: walletA.address},); + changeWalletAccountDetails('bob', { ...accounts.bob, address: walletB.address}); const initialLucid = await makeLucid(); setLucidInstance(initialLucid); @@ -41,7 +41,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode } fetchUserWallets(); },[]); - if(accounts.userB.address === '') { + if(accounts.bob.address === '') { return
; diff --git a/frontend/src/app/components/NavDrawer.tsx b/frontend/src/app/components/NavDrawer.tsx index eb1129ea..2215027b 100644 --- a/frontend/src/app/components/NavDrawer.tsx +++ b/frontend/src/app/components/NavDrawer.tsx @@ -21,7 +21,7 @@ const drawerWidth = 200; const iconMapping = { 'Mint Actions': , - 'Accounts': , + 'Addresses': , 'Wallet': }; @@ -30,7 +30,7 @@ export default function NavDrawer() { // Define list items based on the current user const listItems: MenuTab[] = currentUser === 'Mint Authority' ? - ['Mint Actions', 'Accounts'] : + ['Mint Actions', 'Addresses'] : ['Wallet']; const handleListItemClick = (item: MenuTab) => { diff --git a/frontend/src/app/components/ProfileSwitcher.tsx b/frontend/src/app/components/ProfileSwitcher.tsx index c1823b95..85ceeb1b 100644 --- a/frontend/src/app/components/ProfileSwitcher.tsx +++ b/frontend/src/app/components/ProfileSwitcher.tsx @@ -86,8 +86,8 @@ export default function ProfileSwitcher() { onClose={handleClose} > handleSelect('Mint Authority')}>Mint Authority - handleSelect('User A')}>User A - handleSelect('User B')}>User B + handleSelect('Alice')}>Alice + handleSelect('Bob')}>Bob handleWalletConnect('Connected Wallet')}>Lace diff --git a/frontend/src/app/components/WSTTable.tsx b/frontend/src/app/components/WSTTable.tsx index 018fc285..d496630d 100644 --- a/frontend/src/app/components/WSTTable.tsx +++ b/frontend/src/app/components/WSTTable.tsx @@ -12,10 +12,12 @@ import TableCell from "@mui/material/TableCell"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import Paper from "@mui/material/Paper"; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; //Local Imports import useStore from '../store/store'; import { useEffect } from "react"; +import IconButton from './WSTIconButton'; const progLogicBase : LucidCredential = { type: "Script", @@ -43,6 +45,9 @@ export default function WSTTable() { getAccounts(); }, []); + const copyToClipboard = (str: string) => { + navigator.clipboard.writeText(str); + } return ( @@ -51,8 +56,8 @@ export default function WSTTable() { Address - Account Status - Account Balance + Address Status + Address Balance @@ -61,12 +66,13 @@ export default function WSTTable() { {`${acct?.address.slice(0,15)}...${acct?.address.slice(104,108)}`} + copyToClipboard(acct.address)} icon={}/> {acct.status} - {`${acct?.balance} WST`} + {`${acct?.balance.wst} WST`} )) diff --git a/frontend/src/app/mint-authority/page.tsx b/frontend/src/app/mint-authority/page.tsx index b08b0fd2..7f4df5e8 100644 --- a/frontend/src/app/mint-authority/page.tsx +++ b/frontend/src/app/mint-authority/page.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import axios from 'axios'; //Lucid imports -import { CML, makeTxSignBuilder, paymentCredentialOf } from '@lucid-evolution/lucid'; +import { paymentCredentialOf } from '@lucid-evolution/lucid'; import type { Credential as LucidCredential } from "@lucid-evolution/core-types"; //Mui imports @@ -33,10 +33,10 @@ export default function Home() { const [sendTokensAmount, setSendTokens] = useState(0); const [mintRecipientAddress, setMintRecipientAddress] = useState('mint recipient address'); const [sendRecipientAddress, setsendRecipientAddress] = useState('send recipient address'); - const [freezeAccountNumber, setFreezeAccountNumber] = useState('account to freeze'); - const [unfreezeAccountNumber, setUnfreezeAccountNumber] = useState('account to unfreeze'); + const [freezeAccountNumber, setFreezeAccountNumber] = useState('address to freeze'); + const [unfreezeAccountNumber, setUnfreezeAccountNumber] = useState('address to unfreeze'); const [freezeReason, setFreezeReason] = useState('Enter reason here'); - const [seizeAccountNumber, setSeizeAccountNumber] = useState('account to seize'); + const [seizeAccountNumber, setSeizeAccountNumber] = useState('address to seize'); const [seizeReason, setSeizeReason] = useState('Enter reason here'); useEffect(() => { @@ -49,13 +49,13 @@ export default function Home() { const fetchUserDetails = async () => { const mintBalance = await getWalletBalance(mintAccount.address); - const userABalance = await getWalletBalance(accounts.userA.address); - const userBBalance = await getWalletBalance(accounts.userB.address); + const userABalance = await getWalletBalance(accounts.alice.address); + const userBBalance = await getWalletBalance(accounts.bob.address); // Update Zustand store with the initialized wallet information await changeMintAccountDetails({ ...mintAccount, balance: mintBalance}); - await changeWalletAccountDetails('userA', { ...accounts.userA, balance: userABalance}); - await changeWalletAccountDetails('userB', { ...accounts.userB, balance: userBBalance}); + await changeWalletAccountDetails('alice', { ...accounts.alice, balance: userABalance}); + await changeWalletAccountDetails('bob', { ...accounts.bob, balance: userBBalance}); }; const fetchBlacklistStatus = async () => { @@ -106,28 +106,9 @@ export default function Home() { ); console.log('Mint response:', response.data); const tx = await lucid.fromTx(response.data.cborHex); - // await signAndSentTx(lucid, tx); - const txBuilder = await makeTxSignBuilder(lucid.wallet(), tx.toTransaction()).complete(); - const cmlTx = txBuilder.toTransaction() - // console.log("TxBody: " + cmlTx.body().to_json()); - const witnessSet = txBuilder.toTransaction().witness_set(); - const expectedScriptDataHash : CML.ScriptDataHash | undefined = CML.calc_script_data_hash(witnessSet.redeemers()!, CML.PlutusDataList.new(), lucid.config().costModels!, witnessSet.languages()); - // console.log('Calculated Script Data Hash:', expectedScriptDataHash?.to_hex()); - const cmlTxBodyClone = CML.TransactionBody.from_cbor_hex(cmlTx!.body().to_cbor_hex()); - // console.log("TxBody: " + cmlTxBodyClone.to_json()); - const txIDinAlert = await cmlTxBodyClone.to_json(); - const txIDObject = JSON.parse(txIDinAlert); - // console.log('Preclone script hash:', cmlTxBodyClone.script_data_hash()?.to_hex()); - cmlTxBodyClone.set_script_data_hash(expectedScriptDataHash!); - // console.log('Postclone script hash:', cmlTxBodyClone.script_data_hash()?.to_hex()); - const cmlClonedTx = CML.Transaction.new(cmlTxBodyClone, cmlTx!.witness_set(), true, cmlTx!.auxiliary_data()); - const cmlClonedSignedTx = await makeTxSignBuilder(lucid.wallet(), cmlClonedTx).sign.withWallet().complete(); - - const txId = await cmlClonedSignedTx.submit(); - await lucid.awaitTx(txId); - - changeAlertInfo({severity: 'success', message: 'Successful new WST mint. View the transaction here:', open: true, link: `https://preview.cardanoscan.io/transaction/${txIDObject.inputs[0].transaction_id}`}); + const txId = await signAndSentTx(lucid, tx); + changeAlertInfo({severity: 'success', message: 'Successful new WST mint. View the transaction here:', open: true, link: `https://preview.cexplorer.io/tx/${txId}`}); await fetchUserDetails(); } catch (error) { @@ -169,7 +150,7 @@ export default function Home() { balance: newAccountBalance, }); } - changeAlertInfo({severity: 'success', message: 'Transaction sent successfully!', open: true, link: `https://preview.cardanoscan.io/transaction/${txId.inputs[0].transaction_id}`}); + changeAlertInfo({severity: 'success', message: 'Transaction sent successfully!', open: true, link: `https://preview.cexplorer.io/tx/${txId}`}); await fetchUserDetails(); } catch (error) { console.error('Send failed:', error); @@ -177,7 +158,7 @@ export default function Home() { }; const onFreeze = async () => { - console.log('freeze an account'); + console.log('freeze an address'); lucid.selectWallet.fromSeed(mintAccount.mnemonic); changeAlertInfo({severity: 'info', message: 'Freeze request processing', open: true, link: ''}); const requestData = { @@ -198,7 +179,8 @@ export default function Home() { console.log('Freeze response:', response.data); const tx = await lucid.fromTx(response.data.cborHex); const txId = await signAndSentTx(lucid, tx); - changeAlertInfo({severity: 'success', message: 'Account successfully frozen', open: true, link: `https://preview.cardanoscan.io/transaction/${txId.inputs[0].transaction_id}`}); + console.log(txId); + changeAlertInfo({severity: 'success', message: 'Address successfully frozen', open: true, link: `https://preview.cexplorer.io/tx/${txId}`}); const frozenWalletKey = (Object.keys(accounts) as (keyof Accounts)[]).find( (key) => accounts[key].address === freezeAccountNumber ); @@ -229,6 +211,7 @@ export default function Home() { const requestData = { issuer: mintAccount.address, blacklist_address: unfreezeAccountNumber, + reason: "(unfreeze)" }; try { const response = await axios.post( @@ -243,7 +226,7 @@ export default function Home() { console.log('Unfreeze response:', response.data); const tx = await lucid.fromTx(response.data.cborHex); const txId = await signAndSentTx(lucid, tx); - changeAlertInfo({severity: 'success', message: 'Account successfully unfrozen', open: true, link: `https://preview.cardanoscan.io/transaction/${txId.inputs[0].transaction_id}`}); + changeAlertInfo({severity: 'success', message: 'Address successfully unfrozen', open: true, link: `https://preview.cexplorer.io/tx/${txId}`}); const unfrozenWalletKey = (Object.keys(accounts) as (keyof Accounts)[]).find( (key) => accounts[key].address === freezeAccountNumber ); @@ -299,7 +282,7 @@ export default function Home() { balance: newAccountBalance, }); } - changeAlertInfo({severity: 'success', message: 'Funds successfully seized', open: true, link: `https://preview.cardanoscan.io/transaction/${txId.inputs[0].transaction_id}`}); + changeAlertInfo({severity: 'success', message: 'Funds successfully seized', open: true, link: `https://preview.cexplorer.io/tx/${txId}`}); await fetchUserDetails(); } catch (error) { console.error('Seize failed:', error); @@ -330,7 +313,7 @@ export default function Home() { setFreezeAccountNumber(e.target.value)} - label="Account Number" + label="Address" fullWidth={true} /> setUnfreezeAccountNumber(e.target.value)} - label="Account Number" + label="Address" fullWidth={true} /> @@ -357,7 +340,7 @@ const seizeContent = setSeizeAccountNumber(e.target.value)} -label="Account Number" +label="Address" fullWidth={true} /> - Mint Balance - {mintAccount.balance} WST + Mint Authority Balance + {mintAccount.balance.wst} WST + {mintAccount.balance.ada} Ada UserID: {mintAccount.address.slice(0,15)} @@ -446,10 +430,10 @@ maxRows={3} ]}/>
; - case 'Accounts': + case 'Addresses': return <> - User Accounts + Addresses ; diff --git a/frontend/src/app/store/store.tsx b/frontend/src/app/store/store.tsx index 2da39f27..a8c14315 100644 --- a/frontend/src/app/store/store.tsx +++ b/frontend/src/app/store/store.tsx @@ -29,25 +29,25 @@ const useStore = create((set) => ({ name: 'Mint Authority', address: 'addr_test1qq986m3uel86pl674mkzneqtycyg7csrdgdxj6uf7v7kd857kquweuh5kmrj28zs8czrwkl692jm67vna2rf7xtafhpqk3hecm', mnemonic: 'problem alert infant glance toss gospel tonight sheriff match else hover upset chicken desert anxiety cliff moment song large seed purpose chalk loan onion', - balance: 0, + balance: {ada: 0, wst: 0}, }, accounts: { - userA: { + alice: { address: '', mnemonic: 'during dolphin crop lend pizza guilt hen earn easy direct inhale deputy detect season army inject exhaust apple hard front bubble emotion short portion', - balance: 0, + balance: {ada: 0, wst: 0}, status: 'Active', }, - userB: { + bob: { address: '', mnemonic: 'silver legal flame powder fence kiss stable margin refuse hold unknown valid wolf kangaroo zero able waste jewel find salad sadness exhibit hello tape', - balance: 0, + balance: {ada: 0, wst: 0}, status: 'Active', }, walletUser: { address: '', mnemonic: '', - balance: 0, + balance: {ada: 0, wst: 0}, status: 'Active', }, }, @@ -82,8 +82,8 @@ const useStore = create((set) => ({ case 'Mint Authority': firstAccessibleTab = 'Mint Actions'; break; - case 'User A': - case 'User B': + case 'Alice': + case 'Bob': firstAccessibleTab = 'Wallet'; break; case 'Connected Wallet': diff --git a/frontend/src/app/store/types.ts b/frontend/src/app/store/types.ts index 19454b88..d2b2ed3a 100644 --- a/frontend/src/app/store/types.ts +++ b/frontend/src/app/store/types.ts @@ -1,15 +1,15 @@ -export type UserName = 'Mint Authority' | 'User A' | 'User B' | 'Connected Wallet'; -export type MenuTab = 'Mint Actions' | 'Accounts' | 'Wallet'; +export type UserName = 'Mint Authority' | 'Alice' | 'Bob' | 'Connected Wallet'; +export type MenuTab = 'Mint Actions' | 'Addresses' | 'Wallet'; export type AccountInfo = { address: string, mnemonic: string, - balance: number, + balance: WalletBalance, status?: 'Active' | 'Frozen', }; -export type AccountKey = 'userA' | 'userB' | 'walletUser'; +export type AccountKey = 'alice' | 'bob' | 'walletUser'; export type Accounts = { - userA: AccountInfo; - userB: AccountInfo; + alice: AccountInfo; + bob: AccountInfo; walletUser: AccountInfo; }; export type Severity = 'success' | 'error' | 'info' | 'warning'; @@ -18,4 +18,5 @@ export type AlertInfo = { severity: Severity, message: string, link?: string, -}; \ No newline at end of file +}; +export type WalletBalance = { wst: number, ada: number } diff --git a/frontend/src/app/utils/walletUtils.ts b/frontend/src/app/utils/walletUtils.ts index 4992efcd..8db795f5 100644 --- a/frontend/src/app/utils/walletUtils.ts +++ b/frontend/src/app/utils/walletUtils.ts @@ -1,9 +1,10 @@ //Axios imports -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; //Lucis imports import { Address, Assets, Blockfrost, CML, credentialToAddress, Lucid, LucidEvolution, makeTxSignBuilder, paymentCredentialOf, toUnit, TxSignBuilder, Unit, valueToAssets, walletFromSeed } from "@lucid-evolution/lucid"; import type { Credential as LucidCredential } from "@lucid-evolution/core-types"; +import { WalletBalance } from '../store/types'; async function loadKey() { const response = await axios.get("/blockfrost-key", @@ -39,7 +40,7 @@ export async function getWalletFromSeed(mnemonic: string) { } } -export async function getWalletBalance(address: string){ +export async function getWalletBalance(address: string): Promise { try { const response = await axios.get( `/api/v1/query/user-funds/${address}`, @@ -52,14 +53,16 @@ export async function getWalletBalance(address: string){ const balance = "b34a184f1f2871aa4d33544caecefef5242025f45c3fa5213d7662a9"; const stableTokenUnit = "575354"; let stableBalance = 0; + let adaBalance = 0; if (response?.data && response.data[balance] && response.data[balance][stableTokenUnit]) { stableBalance = response.data[balance][stableTokenUnit]; + adaBalance = response.data["lovelace"] / 1000000; } - // console.log('Get wallet balance:', response.data); - return stableBalance; + + return {wst: stableBalance, ada: adaBalance }; } catch (error) { console.error('Failed to get balance', error); - return 0; + return { wst: 0, ada: 0}; } } @@ -82,24 +85,42 @@ export async function getBlacklist(){ } } -export async function signAndSentTx(lucid: LucidEvolution, tx: TxSignBuilder) { +export async function submitTx(tx: string): Promise> { + return axios.post( + '/api/v1/tx/submit', + { + description: "", + type: "Tx ConwayEra", + cborHex: tx + }, + { + headers: { + 'Content-Type': 'application/json;charset=utf-8', + }, + } + ); + } + +export async function signAndSentTx(lucid: LucidEvolution, tx: TxSignBuilder): Promise { + const isValid = tx.toTransaction().is_valid(); const txBuilder = await makeTxSignBuilder(lucid.wallet(), tx.toTransaction()).complete(); const cmlTx = txBuilder.toTransaction(); const witnessSet = txBuilder.toTransaction().witness_set(); const expectedScriptDataHash : CML.ScriptDataHash | undefined = CML.calc_script_data_hash(witnessSet.redeemers()!, CML.PlutusDataList.new(), lucid.config().costModels!, witnessSet.languages()); - // console.log('Calculated Script Data Hash:', expectedScriptDataHash?.to_hex()); const cmlTxBodyClone = CML.TransactionBody.from_cbor_hex(cmlTx!.body().to_cbor_hex()); - const txIDinAlert = await cmlTxBodyClone.to_json(); - const txIDObject = JSON.parse(txIDinAlert); - // console.log('Preclone script hash:', cmlTxBodyClone.script_data_hash()?.to_hex()); cmlTxBodyClone.set_script_data_hash(expectedScriptDataHash!); - // console.log('Postclone script hash:', cmlTxBodyClone.script_data_hash()?.to_hex()); - const cmlClonedTx = CML.Transaction.new(cmlTxBodyClone, cmlTx!.witness_set(), true, cmlTx!.auxiliary_data()); + + const cmlClonedTx = CML.Transaction.new(cmlTxBodyClone, cmlTx!.witness_set(), isValid, cmlTx!.auxiliary_data()); const cmlClonedSignedTx = await makeTxSignBuilder(lucid.wallet(), cmlClonedTx).sign.withWallet().complete(); - const txId = await cmlClonedSignedTx.submit(); - await lucid.awaitTx(txId); - console.log(cmlClonedSignedTx); - return txIDObject + + // We need to reconstruct the transaction from CBOR again, using CML, because the 'cmlClonedSignedTx.toTransaction().is_valid' always + // returns true (overriding what we specified when we created cmlClonedTx) + const clonedTx2 = CML.Transaction.new(CML.TransactionBody.from_cbor_hex(cmlClonedSignedTx.toTransaction().body().to_cbor_hex()), cmlClonedSignedTx!.toTransaction().witness_set(), isValid, cmlClonedSignedTx.toTransaction()!.auxiliary_data()); + const txId = await submitTx(clonedTx2.to_cbor_hex()); + const i = txId.data; + console.log(txId); + await lucid.awaitTx(txId.data); + return i } export type WalletType = "Lace" | "Eternl" | "Nami" | "Yoroi"; diff --git a/generated/openapi/schema.json b/generated/openapi/schema.json index 7dee2492..7545d68e 100644 --- a/generated/openapi/schema.json +++ b/generated/openapi/schema.json @@ -156,6 +156,9 @@ }, "sender": { "$ref": "#/components/schemas/Address" + }, + "submit_failing_tx": { + "type": "boolean" } }, "required": [ @@ -163,7 +166,8 @@ "recipient", "issuer", "asset_name", - "quantity" + "quantity", + "submit_failing_tx" ], "type": "object" }, diff --git a/image.png b/image.png new file mode 100644 index 00000000..6ce5e9e3 Binary files /dev/null and b/image.png differ diff --git a/nix/project.nix b/nix/project.nix index b724edc6..c7fa0f80 100644 --- a/nix/project.nix +++ b/nix/project.nix @@ -3,7 +3,7 @@ let sha256map = { # "https://github.com/j-mueller/sc-tools"."dbff9d50478fbce9ee5c718f0536f4183685edd9" = "sha256-b47wr0xuUZohxPnL3Zi6iAYhkY0K7NFHpsv8TXr9LHM="; - "https://github.com/amirmrad/sc-tools"."6c63efe07015e87719d77fa3fabfe07f959c7227" = "sha256-f1qpkjL0YgK2/k8M1BgFYT7bcE14sm0qucbqRjtCbU8="; + "https://github.com/j-mueller/sc-tools"."a3662e093f40082dd6fa525bb0640a10caa1bd70" = "sha256-4GfNKmbSf1fbBEGmQFFZoSahVssBVFfCqU3tjfR1uYs="; "https://github.com/colll78/plutarch-plutus"."b2379767c7f1c70acf28206bf922f128adc02f28" = "sha256-mhuW2CHxnc6FDWuMcjW/51PKuPOdYc4yxz+W5RmlQew="; "https://github.com/input-output-hk/catalyst-onchain-libs"."650a3435f8efbd4bf36e58768fac266ba5beede4" = "sha256-NUh+l97+eO27Ppd8Bx0yMl0E5EV+p7+7GuFun1B8gRc="; }; diff --git a/src/lib/Wst/Offchain/BuildTx/Failing.hs b/src/lib/Wst/Offchain/BuildTx/Failing.hs new file mode 100644 index 00000000..4688e85c --- /dev/null +++ b/src/lib/Wst/Offchain/BuildTx/Failing.hs @@ -0,0 +1,67 @@ +{-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE UndecidableInstances #-} +{-| Tools for deliberately building a transaction +with "scriptValidity" flag set to "invalid". +-} +module Wst.Offchain.BuildTx.Failing( + IsEra, + BlacklistedTransferPolicy(..), + balanceTxEnvFailing +) where + +import Cardano.Api.Experimental (IsEra) +import Cardano.Api.Shelley qualified as C +import Control.Lens (set) +import Control.Monad.Except (MonadError, throwError) +import Control.Monad.Reader (MonadReader, asks) +import Convex.BuildTx (BuildTxT) +import Convex.BuildTx qualified as BuildTx +import Convex.CardanoApi.Lenses qualified as L +import Convex.Class (MonadBlockchain, queryProtocolParameters) +import Convex.CoinSelection qualified as CoinSelection +import Convex.PlutusLedger.V1 (transCredential) +import Convex.Utils (mapError) +import Convex.Utxos (BalanceChanges) +import Convex.Utxos qualified as Utxos +import Convex.Wallet.Operator (returnOutputFor) +import Data.Aeson (FromJSON, ToJSON) +import GHC.Generics (Generic) +import Wst.AppError (AppError (..)) +import Wst.Offchain.BuildTx.TransferLogic (FindProofResult (..)) +import Wst.Offchain.Env (HasOperatorEnv (..), OperatorEnv (..)) +import Wst.Offchain.Query (UTxODat (..)) + +{-| What to do if a transfer cannot proceed because of blacklisting +-} +data BlacklistedTransferPolicy + = SubmitFailingTx -- ^ Deliberately submit a transaction with "scriptValidity = False". This will result in the collateral input being spent! + | DontSubmitFailingTx -- ^ Don't submit a transaction + deriving stock (Eq, Show, Generic) + deriving anyclass (ToJSON, FromJSON) + +{-| Balance a transaction using the operator's funds and return output +-} +balanceTxEnvFailing :: forall era env m. (MonadBlockchain era m, MonadReader env m, HasOperatorEnv era env, MonadError (AppError era) m, C.IsBabbageBasedEra era) => BlacklistedTransferPolicy -> BuildTxT era m (FindProofResult era) -> m (C.BalancedTxBody era, BalanceChanges) +balanceTxEnvFailing policy btx = do + OperatorEnv{bteOperatorUtxos, bteOperator} <- asks operatorEnv + params <- queryProtocolParameters + (r, txBuilder) <- BuildTx.runBuildTxT $ btx <* BuildTx.setMinAdaDepositAll params + -- TODO: change returnOutputFor to consider the stake address reference + -- (needs to be done in sc-tools) + let credential = C.PaymentCredentialByKey $ fst bteOperator + output <- returnOutputFor credential + (balBody, balChanges) <- case r of + CredentialNotBlacklisted{} -> do + mapError BalancingError (CoinSelection.balanceTx mempty output (Utxos.fromApiUtxo bteOperatorUtxos) txBuilder CoinSelection.TrailingChange) + CredentialBlacklisted UTxODat{} + | policy == SubmitFailingTx -> do + -- deliberately set the script validity flag to false + -- this means we will be losing the collateral! + let builder' = txBuilder <> BuildTx.liftTxBodyEndo (set L.txScriptValidity (C.TxScriptValidity C.alonzoBasedEra C.ScriptInvalid)) + mapError BalancingError (CoinSelection.balanceTx mempty output (Utxos.fromApiUtxo bteOperatorUtxos) builder' CoinSelection.TrailingChange) + | otherwise -> do + throwError (TransferBlacklistedCredential (transCredential credential)) + NoBlacklistNodes -> throwError BlacklistNodeNotFound + pure (balBody, balChanges) diff --git a/src/lib/Wst/Offchain/BuildTx/TransferLogic.hs b/src/lib/Wst/Offchain/BuildTx/TransferLogic.hs index e1ddde37..3aafe53e 100644 --- a/src/lib/Wst/Offchain/BuildTx/TransferLogic.hs +++ b/src/lib/Wst/Offchain/BuildTx/TransferLogic.hs @@ -5,6 +5,7 @@ module Wst.Offchain.BuildTx.TransferLogic ( transferSmartTokens, + FindProofResult(..), issueSmartTokens, SeizeReason(..), seizeSmartTokens, @@ -14,6 +15,7 @@ module Wst.Offchain.BuildTx.TransferLogic removeBlacklistNode, paySmartTokensToDestination, registerTransferScripts, + blacklistInitialNode ) where @@ -54,7 +56,7 @@ import SmartTokens.Contracts.ExampleTransferLogic (BlacklistProof (..)) import SmartTokens.Types.ProtocolParams import SmartTokens.Types.PTokenDirectory (BlacklistNode (..), DirectorySetNode (..)) -import Wst.AppError (AppError (BlacklistNodeNotFound, DuplicateBlacklistNode, TransferBlacklistedCredential)) +import Wst.AppError (AppError (BlacklistNodeNotFound, DuplicateBlacklistNode)) import Wst.Offchain.BuildTx.ProgrammableLogic (issueProgrammableToken, seizeProgrammableToken, transferProgrammableToken) @@ -117,7 +119,7 @@ instance ToSchema BlacklistReason where -} addBlacklistReason :: (C.IsShelleyBasedEra era, MonadBuildTx era m) => BlacklistReason -> m () addBlacklistReason (BlacklistReason reason) = - addBtx (set (L.txMetadata . L._TxMetadata . at 0) (Just (C.TxMetaMap [(C.TxMetaText "blacklist.reason", C.metaTextChunks reason)]))) + addBtx (set (L.txMetadata . L._TxMetadata . at 1) (Just (C.TxMetaMap [(C.TxMetaText "reason", C.metaTextChunks reason)]))) insertBlacklistNode :: forall era env m. (MonadReader env m, Env.HasOperatorEnv era env, Env.HasTransferLogicEnv env, C.IsBabbageBasedEra era, C.HasScriptLanguageInEra C.PlutusScriptV3 era, MonadBuildTx era m, MonadError (AppError era) m) => BlacklistReason -> C.PaymentCredential -> [UTxODat era BlacklistNode]-> m () insertBlacklistNode reason cred blacklistNodes = Utils.inBabbage @era $ do @@ -222,7 +224,7 @@ issueSmartTokens paramsTxOut (an, q) directoryList destinationCred = Utils.inBab paySmartTokensToDestination (an, q) issuedPolicyId destinationCred pure $ C.AssetId issuedPolicyId an -transferSmartTokens :: forall env era a m. (MonadReader env m, Env.HasTransferLogicEnv env, Env.HasDirectoryEnv env, C.IsBabbageBasedEra era, MonadBlockchain era m, C.HasScriptLanguageInEra C.PlutusScriptV3 era, MonadBuildTx era m, Env.HasOperatorEnv era env, MonadError (AppError era) m) => UTxODat era ProgrammableLogicGlobalParams -> [UTxODat era BlacklistNode] -> [UTxODat era DirectorySetNode] -> [UTxODat era a] -> (C.AssetId, C.Quantity) -> C.PaymentCredential -> m () +transferSmartTokens :: forall env era a m. (MonadReader env m, Env.HasTransferLogicEnv env, Env.HasDirectoryEnv env, C.IsBabbageBasedEra era, MonadBlockchain era m, C.HasScriptLanguageInEra C.PlutusScriptV3 era, MonadBuildTx era m, Env.HasOperatorEnv era env, MonadError (AppError era) m) => UTxODat era ProgrammableLogicGlobalParams -> [UTxODat era BlacklistNode] -> [UTxODat era DirectorySetNode] -> [UTxODat era a] -> (C.AssetId, C.Quantity) -> C.PaymentCredential -> m (FindProofResult era) transferSmartTokens paramsTxIn blacklistNodes directoryList spendingUserOutputs (assetId, q) destinationCred = Utils.inBabbage @era $ do nid <- queryNetworkId userCred <- Env.operatorPaymentCredential @@ -238,7 +240,7 @@ transferSmartTokens paramsTxIn blacklistNodes directoryList spendingUserOutputs C.AdaAssetId -> error "Ada is not programmable" transferProgrammableToken paramsTxIn txins (transPolicyId programmablePolicyId) directoryList -- Invoking the programmableBase and global scripts - addTransferWitness blacklistNodes -- Proof of non-membership of the blacklist + result <- addTransferWitness blacklistNodes -- Proof of non-membership of the blacklist -- Send outputs to destinationCred destStakeCred <- either (error . ("Could not unTrans credential: " <>) . show) pure $ unTransStakeCredential $ transCredential destinationCred @@ -255,6 +257,7 @@ transferSmartTokens paramsTxIn blacklistNodes directoryList spendingUserOutputs returnAddr = C.makeShelleyAddressInEra C.shelleyBasedEra nid progLogicBaseCred (C.StakeAddressByValue srcStakeCred) returnOutput = C.TxOut returnAddr returnVal C.TxOutDatumNone C.ReferenceScriptNone prependTxOut returnOutput -- Add the seized output to the transaction + pure result {-| Reason for adding an address to the blacklist -} @@ -272,8 +275,7 @@ instance ToSchema SeizeReason where -} addSeizeReason :: (C.IsShelleyBasedEra era, MonadBuildTx era m) => SeizeReason -> m () addSeizeReason (SeizeReason reason) = - addBtx (set (L.txMetadata . L._TxMetadata . at 1) (Just (C.TxMetaMap [(C.TxMetaText "seize.reason", C.metaTextChunks reason)]))) - + addBtx (set (L.txMetadata . L._TxMetadata . at 1) (Just (C.TxMetaMap [(C.TxMetaText "reason", C.metaTextChunks reason)]))) seizeSmartTokens :: forall env era a m. (MonadReader env m, Env.HasOperatorEnv era env, Env.HasTransferLogicEnv env, Env.HasDirectoryEnv env, C.IsBabbageBasedEra era, MonadBlockchain era m, C.HasScriptLanguageInEra C.PlutusScriptV3 era, MonadBuildTx era m) => SeizeReason -> UTxODat era ProgrammableLogicGlobalParams -> UTxODat era a -> C.PaymentCredential -> [UTxODat era DirectorySetNode] -> m () seizeSmartTokens reason paramsTxIn seizingTxo destinationCred directoryList = Utils.inBabbage @era $ do @@ -339,6 +341,7 @@ tryFindProof :: [UTxODat era BlacklistNode] -> Credential -> UTxODat era Blackli tryFindProof blacklistNodes cred = case findProof blacklistNodes cred of CredentialNotBlacklisted r -> r + CredentialBlacklisted r -> r _ -> error $ "tryFindProof failed for " <> show cred {-| Find the blacklist node that covers the credential. @@ -353,18 +356,10 @@ findProof blacklistNodes cred = then CredentialBlacklisted node else CredentialNotBlacklisted node -{-| Check that the credential is not blacklisted. Throw an error if the - credential is blacklisted. --} -checkNotBlacklisted :: forall era m. MonadError (AppError era) m => [UTxODat era BlacklistNode] -> Credential -> m () -checkNotBlacklisted nodes cred = case findProof nodes cred of - CredentialNotBlacklisted{} -> pure () - _ -> throwError (TransferBlacklistedCredential cred) - {-| Add a proof that the user is allowed to transfer programmable tokens. Uses the user from 'HasOperatorEnv env'. Fails if the user is blacklisted. -} -addTransferWitness :: forall env era m. (MonadError (AppError era) m, MonadReader env m, Env.HasOperatorEnv era env, Env.HasTransferLogicEnv env, C.IsBabbageBasedEra era, MonadBlockchain era m, C.HasScriptLanguageInEra C.PlutusScriptV3 era, MonadBuildTx era m) => [UTxODat era BlacklistNode] -> m () +addTransferWitness :: forall env era m. (MonadError (AppError era) m, MonadReader env m, Env.HasOperatorEnv era env, Env.HasTransferLogicEnv env, C.IsBabbageBasedEra era, MonadBlockchain era m, C.HasScriptLanguageInEra C.PlutusScriptV3 era, MonadBuildTx era m) => [UTxODat era BlacklistNode] -> m (FindProofResult era) addTransferWitness blacklistNodes = Utils.inBabbage @era $ do opPkh <- asks (fst . Env.bteOperator . Env.operatorEnv) -- In this case 'operator' is the user nid <- queryNetworkId @@ -391,7 +386,7 @@ addTransferWitness blacklistNodes = Utils.inBabbage @era $ do -- This means we're traversing the list of blacklist nodes an additional time. -- But here is the only place where we can use MonadError. So we have to do it -- here to allow the client code to handle the error properly. - checkNotBlacklisted blacklistNodes (transCredential $ C.PaymentCredentialByKey opPkh) + let proofResult = findProof blacklistNodes (transCredential $ C.PaymentCredentialByKey opPkh) addRequiredSignature opPkh addReferencesWithTxBody witnessReferences @@ -399,12 +394,12 @@ addTransferWitness blacklistNodes = Utils.inBabbage @era $ do (C.makeStakeAddress nid transferStakeCred) (C.Quantity 0) $ C.ScriptWitness C.ScriptWitnessForStakeAddr . transferStakeWitness + pure proofResult addReferencesWithTxBody :: (MonadBuildTx era m, C.IsBabbageBasedEra era) => (C.TxBodyContent C.BuildTx era -> [C.TxIn]) -> m () addReferencesWithTxBody f = addTxBuilder (TxBuilder $ \body -> over (L.txInsReference . L._TxInsReferenceIso) (nub . (f body <>))) - addSeizeWitness :: forall env era m. (MonadReader env m, Env.HasOperatorEnv era env, Env.HasTransferLogicEnv env, C.IsBabbageBasedEra era, MonadBlockchain era m, C.HasScriptLanguageInEra C.PlutusScriptV3 era, MonadBuildTx era m) => m () addSeizeWitness = Utils.inBabbage @era $ do opPkh <- asks (fst . Env.bteOperator . Env.operatorEnv) diff --git a/src/lib/Wst/Offchain/Endpoints/Deployment.hs b/src/lib/Wst/Offchain/Endpoints/Deployment.hs index 4defc89e..84e639ef 100644 --- a/src/lib/Wst/Offchain/Endpoints/Deployment.hs +++ b/src/lib/Wst/Offchain/Endpoints/Deployment.hs @@ -30,6 +30,8 @@ import SmartTokens.Types.PTokenDirectory (DirectorySetNode (..)) import Wst.AppError (AppError (NoTokensToSeize)) import Wst.Offchain.BuildTx.DirectorySet (InsertNodeArgs (inaNewKey)) import Wst.Offchain.BuildTx.DirectorySet qualified as BuildTx +import Wst.Offchain.BuildTx.Failing (BlacklistedTransferPolicy, + balanceTxEnvFailing) import Wst.Offchain.BuildTx.ProgrammableLogic qualified as BuildTx import Wst.Offchain.BuildTx.ProtocolParams qualified as BuildTx import Wst.Offchain.BuildTx.TransferLogic (BlacklistReason) @@ -39,7 +41,6 @@ import Wst.Offchain.Env qualified as Env import Wst.Offchain.Query (UTxODat (..)) import Wst.Offchain.Query qualified as Query - {-| Build a transaction that deploys the directory and global params. Returns the transaction and the 'TxIn' that was selected for the one-shot NFTs. -} @@ -177,16 +178,17 @@ transferSmartTokensTx :: forall era env m. , C.HasScriptLanguageInEra C.PlutusScriptV3 era , MonadUtxoQuery m ) - => C.AssetId -- ^ AssetId to transfer + => BlacklistedTransferPolicy + -> C.AssetId -- ^ AssetId to transfer -> Quantity -- ^ Amount of tokens to be minted -> C.PaymentCredential -- ^ Destination credential -> m (C.Tx era) -transferSmartTokensTx assetId quantity destCred = do +transferSmartTokensTx policy assetId quantity destCred = do directory <- Query.registryNodes @era blacklist <- Query.blacklistNodes @era userOutputsAtProgrammable <- Env.operatorPaymentCredential >>= Query.userProgrammableOutputs paramsTxIn <- Query.globalParamsNode @era - (tx, _) <- Env.balanceTxEnv_ $ do + (tx, _) <- balanceTxEnvFailing policy $ do BuildTx.transferSmartTokens paramsTxIn blacklist directory userOutputsAtProgrammable (assetId, quantity) destCred pure (Convex.CoinSelection.signBalancedTxBody [] tx) diff --git a/src/lib/Wst/Server.hs b/src/lib/Wst/Server.hs index f03b5a37..2624a750 100644 --- a/src/lib/Wst/Server.hs +++ b/src/lib/Wst/Server.hs @@ -12,6 +12,8 @@ module Wst.Server( defaultServerArgs ) where +import Blammo.Logging.Simple (HasLogger, Message ((:#)), MonadLogger, logInfo, + (.=)) import Blockfrost.Client.Types qualified as Blockfrost import Cardano.Api.Shelley qualified as C import Control.Lens qualified as L @@ -20,6 +22,7 @@ import Control.Monad.IO.Class (MonadIO (..)) import Control.Monad.Reader (MonadReader, asks) import Convex.CardanoApi.Lenses qualified as L import Convex.Class (MonadBlockchain (sendTx), MonadUtxoQuery) +import Data.Aeson.Types (KeyValue) import Data.Data (Proxy (..)) import Data.List (nub) import Network.Wai.Handler.Warp qualified as Warp @@ -33,6 +36,7 @@ import SmartTokens.Types.PTokenDirectory (blnKey) import System.Environment qualified import Wst.App (WstApp, runWstAppServant) import Wst.AppError (AppError (..)) +import Wst.Offchain.BuildTx.Failing (BlacklistedTransferPolicy (..)) import Wst.Offchain.Endpoints.Deployment qualified as Endpoints import Wst.Offchain.Env qualified as Env import Wst.Offchain.Query (UTxODat (uDatum)) @@ -76,7 +80,7 @@ defaultServerArgs = , saStaticFiles = Nothing } -runServer :: (Env.HasRuntimeEnv env, Env.HasDirectoryEnv env) => env -> ServerArgs -> IO () +runServer :: (Env.HasRuntimeEnv env, Env.HasDirectoryEnv env, HasLogger env) => env -> ServerArgs -> IO () runServer env ServerArgs{saPort, saStaticFiles} = do let bf = Blockfrost.projectId $ Env.envBlockfrost $ Env.runtimeEnv env app = cors (const $ Just simpleCorsResourcePolicy) @@ -86,7 +90,7 @@ runServer env ServerArgs{saPort, saStaticFiles} = do port = saPort Warp.run port app -server :: forall env. (Env.HasRuntimeEnv env, Env.HasDirectoryEnv env) => env -> Server APIInEra +server :: forall env. (Env.HasRuntimeEnv env, Env.HasDirectoryEnv env, HasLogger env) => env -> Server APIInEra server env = hoistServer (Proxy @APIInEra) (runWstAppServant env) $ healthcheck :<|> queryApi @env @@ -103,7 +107,7 @@ queryApi = :<|> queryAllFunds @C.ConwayEra @env (Proxy @C.ConwayEra) :<|> computeUserAddress (Proxy @C.ConwayEra) -txApi :: forall env. (Env.HasDirectoryEnv env) => ServerT (BuildTxAPI C.ConwayEra) (WstApp env C.ConwayEra) +txApi :: forall env. (Env.HasDirectoryEnv env, HasLogger env) => ServerT (BuildTxAPI C.ConwayEra) (WstApp env C.ConwayEra) txApi = (issueProgrammableTokenEndpoint @C.ConwayEra @env :<|> transferProgrammableTokenEndpoint @C.ConwayEra @env @@ -215,15 +219,18 @@ transferProgrammableTokenEndpoint :: forall era env m. , C.IsBabbageBasedEra era , C.HasScriptLanguageInEra C.PlutusScriptV3 era , MonadUtxoQuery m + , MonadLogger m ) => TransferProgrammableTokenArgs -> m (TextEnvelopeJSON (C.Tx era)) -transferProgrammableTokenEndpoint TransferProgrammableTokenArgs{ttaSender, ttaRecipient, ttaAssetName, ttaQuantity, ttaIssuer} = do +transferProgrammableTokenEndpoint TransferProgrammableTokenArgs{ttaSender, ttaRecipient, ttaAssetName, ttaQuantity, ttaIssuer, ttaSubmitFailingTx} = do operatorEnv <- Env.loadOperatorEnvFromAddress ttaSender dirEnv <- asks Env.directoryEnv logic <- Env.transferLogicForDirectory (paymentKeyHashFromAddress ttaIssuer) assetId <- Env.programmableTokenAssetId dirEnv <$> Env.transferLogicForDirectory (paymentKeyHashFromAddress ttaIssuer) <*> pure ttaAssetName + let policy = if ttaSubmitFailingTx then SubmitFailingTx else DontSubmitFailingTx + logInfo $ "Transfer programmable tokens" :# [logPolicy policy, logSender ttaSender, logRecipient ttaRecipient] Env.withEnv $ Env.withOperator operatorEnv $ Env.withDirectory dirEnv $ Env.withTransfer logic $ do - TextEnvelopeJSON <$> Endpoints.transferSmartTokensTx assetId ttaQuantity (paymentCredentialFromAddress ttaRecipient) + TextEnvelopeJSON <$> Endpoints.transferSmartTokensTx policy assetId ttaQuantity (paymentCredentialFromAddress ttaRecipient) addToBlacklistEndpoint :: forall era env m. ( MonadReader env m @@ -293,3 +300,14 @@ submitTxEndpoint :: forall era m. => TextEnvelopeJSON (C.Tx era) -> m C.TxId submitTxEndpoint (TextEnvelopeJSON tx) = do either (throwError . SubmitError) pure =<< sendTx tx + +-- structured Logging + +logPolicy :: (KeyValue e kv) => BlacklistedTransferPolicy -> kv +logPolicy p = "policy" .= p + +logSender :: (KeyValue e kv) => C.Address C.ShelleyAddr -> kv +logSender p = "sender" .= p + +logRecipient :: (KeyValue e kv) => C.Address C.ShelleyAddr -> kv +logRecipient p = "recipient" .= p diff --git a/src/lib/Wst/Server/Types.hs b/src/lib/Wst/Server/Types.hs index 28fdaacc..315e4891 100644 --- a/src/lib/Wst/Server/Types.hs +++ b/src/lib/Wst/Server/Types.hs @@ -136,6 +136,7 @@ data TransferProgrammableTokenArgs = , ttaIssuer :: C.Address C.ShelleyAddr , ttaAssetName :: AssetName , ttaQuantity :: Quantity + , ttaSubmitFailingTx :: Bool } deriving stock (Eq, Show, Generic) diff --git a/src/test/unit/Wst/Test/UnitTest.hs b/src/test/unit/Wst/Test/UnitTest.hs index 391adebd..c052f1d3 100644 --- a/src/test/unit/Wst/Test/UnitTest.hs +++ b/src/test/unit/Wst/Test/UnitTest.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Wst.Test.UnitTest( tests @@ -9,10 +10,11 @@ import Cardano.Ledger.Api qualified as Ledger import Cardano.Ledger.Plutus.ExUnits (ExUnits (..)) import Control.Lens ((%~), (&), (^.)) import Control.Monad (void) +import Control.Monad.IO.Class (MonadIO (..)) import Control.Monad.Reader (MonadReader (ask), ReaderT (runReaderT), asks) import Convex.BuildTx qualified as BuildTx import Convex.Class (MonadBlockchain (queryProtocolParameters, sendTx), - MonadMockchain, MonadUtxoQuery) + MonadMockchain, MonadUtxoQuery, ValidationError, getTxById) import Convex.CoinSelection (ChangeOutputPosition (TrailingChange)) import Convex.MockChain (MockchainT) import Convex.MockChain.CoinSelection (tryBalanceAndSubmit) @@ -29,8 +31,9 @@ import Data.String (IsString (..)) import GHC.Exception (SomeException, throw) import SmartTokens.Core.Scripts (ScriptTarget (Debug, Production)) import Test.Tasty (TestTree, testGroup) -import Test.Tasty.HUnit (Assertion, testCase) +import Test.Tasty.HUnit (Assertion, assertEqual, testCase) import Wst.Offchain.BuildTx.DirectorySet (InsertNodeArgs (..)) +import Wst.Offchain.BuildTx.Failing (BlacklistedTransferPolicy (..)) import Wst.Offchain.BuildTx.Utils (addConwayStakeCredentialCertificate) import Wst.Offchain.Endpoints.Deployment qualified as Endpoints import Wst.Offchain.Env (DirectoryScriptRoot) @@ -56,7 +59,8 @@ scriptTargetTests target = , testCase "smart token transfer" (mockchainSucceedsWithTarget target $ deployDirectorySet >>= transferSmartTokens) , testCase "blacklist credential" (mockchainSucceedsWithTarget target $ void $ deployDirectorySet >>= blacklistCredential) , testCase "unblacklist credential" (mockchainSucceedsWithTarget target $ void $ deployDirectorySet >>= unblacklistCredential) - , testCase "blacklisted transfer" (mockchainFails blacklistTransfer assertBlacklistedAddressException) + , testCase "blacklisted transfer" (mockchainFails (blacklistTransfer DontSubmitFailingTx) assertBlacklistedAddressException) + , testCase "blacklisted transfer (failing tx)" (mockchainSucceedsWithTarget target (blacklistTransfer SubmitFailingTx >>= assertFailingTx)) , testCase "seize user output" (mockchainSucceedsWithTarget target $ deployDirectorySet >>= seizeUserOutput) , testCase "deploy all" (mockchainSucceedsWithTarget target deployAll) ] @@ -152,7 +156,7 @@ transferSmartTokens scriptRoot = failOnError $ Env.withEnv $ do asAdmin @C.ConwayEra $ Env.withDirectoryFor scriptRoot $ Env.withTransferFromOperator $ do opPkh <- asks (fst . Env.bteOperator . Env.operatorEnv) - Endpoints.transferSmartTokensTx aid 80 (C.PaymentCredentialByKey userPkh) + Endpoints.transferSmartTokensTx DontSubmitFailingTx aid 80 (C.PaymentCredentialByKey userPkh) >>= void . sendTx . signTxOperator admin Query.programmableLogicOutputs @C.ConwayEra @@ -208,8 +212,8 @@ unblacklistCredential scriptRoot = failOnError $ Env.withEnv $ do pure paymentCred -blacklistTransfer :: (MonadUtxoQuery m, MonadFail m, MonadMockchain C.ConwayEra m) => m () -blacklistTransfer = failOnError $ Env.withEnv $ do +blacklistTransfer :: (MonadUtxoQuery m, MonadFail m, MonadMockchain C.ConwayEra m) => BlacklistedTransferPolicy -> m (Either (ValidationError C.ConwayEra) C.TxId) +blacklistTransfer policy = failOnError $ Env.withEnv $ do scriptRoot <- runReaderT deployDirectorySet Production userPkh <- asWallet Wallet.w2 $ asks (fst . Env.bteOperator . Env.operatorEnv) let userPaymentCred = C.PaymentCredentialByKey userPkh @@ -221,7 +225,7 @@ blacklistTransfer = failOnError $ Env.withEnv $ do opPkh <- asAdmin @C.ConwayEra $ Env.withDirectoryFor scriptRoot $ Env.withTransferFromOperator $ do opPkh <- asks (fst . Env.bteOperator . Env.operatorEnv) - Endpoints.transferSmartTokensTx aid 50 (C.PaymentCredentialByKey userPkh) + Endpoints.transferSmartTokensTx policy aid 50 (C.PaymentCredentialByKey userPkh) >>= void . sendTx . signTxOperator admin pure opPkh @@ -230,8 +234,8 @@ blacklistTransfer = failOnError $ Env.withEnv $ do asAdmin @C.ConwayEra $ Env.withDirectoryFor scriptRoot $ Env.withTransferFromOperator $ Endpoints.insertBlacklistNodeTx "" userPaymentCred >>= void . sendTx . signTxOperator admin - asWallet Wallet.w2 $ Env.withDirectoryFor scriptRoot $ Env.withTransfer transferLogic $ Endpoints.transferSmartTokensTx aid 30 (C.PaymentCredentialByKey opPkh) - >>= void . sendTx . signTxOperator (user Wallet.w2) + asWallet Wallet.w2 $ Env.withDirectoryFor scriptRoot $ Env.withTransfer transferLogic $ Endpoints.transferSmartTokensTx policy aid 30 (C.PaymentCredentialByKey opPkh) + >>= sendTx . signTxOperator (user Wallet.w2) seizeUserOutput :: (MonadUtxoQuery m, MonadFail m, MonadMockchain C.ConwayEra m) => DirectoryScriptRoot -> m () seizeUserOutput scriptRoot = failOnError $ Env.withEnv $ do @@ -244,7 +248,7 @@ seizeUserOutput scriptRoot = failOnError $ Env.withEnv $ do >>= void . sendTx . signTxOperator admin asAdmin @C.ConwayEra $ Env.withDirectoryFor scriptRoot $ Env.withTransferFromOperator $ do - Endpoints.transferSmartTokensTx aid 50 (C.PaymentCredentialByKey userPkh) + Endpoints.transferSmartTokensTx DontSubmitFailingTx aid 50 (C.PaymentCredentialByKey userPkh) >>= void . sendTx . signTxOperator admin Query.programmableLogicOutputs @C.ConwayEra >>= void . expectN 2 "programmable logic outputs" @@ -350,3 +354,13 @@ nodeParamsFor = \case mockchainSucceedsWithTarget :: ScriptTarget -> ReaderT ScriptTarget (MockchainT C.ConwayEra IO) a -> Assertion mockchainSucceedsWithTarget target = mockchainSucceedsWith (nodeParamsFor target) . flip runReaderT target + +{-| Assert that the transaction exists on the mockchain and that its script validity flag +is set to 'C.ScriptInvalid' +-} +assertFailingTx :: (MonadMockchain era m, C.IsAlonzoBasedEra era, MonadFail m, MonadIO m) => Either (ValidationError era) C.TxId -> m () +assertFailingTx = \case + Left err -> fail $ "Expected TxId, got: " <> show err + Right txId -> do + C.TxBody C.TxBodyContent{C.txScriptValidity} <- getTxById txId >>= maybe (fail $ "Tx not found: " <> show txId) (pure . C.getTxBody) + liftIO (assertEqual "Tx validity" (C.TxScriptValidity C.alonzoBasedEra C.ScriptInvalid) txScriptValidity) diff --git a/src/wst-poc.cabal b/src/wst-poc.cabal index 2a0c8a81..11659093 100644 --- a/src/wst-poc.cabal +++ b/src/wst-poc.cabal @@ -76,6 +76,7 @@ library Wst.Client Wst.JSON.Utils Wst.Offchain.BuildTx.DirectorySet + Wst.Offchain.BuildTx.Failing Wst.Offchain.BuildTx.LinkedList Wst.Offchain.BuildTx.ProgrammableLogic Wst.Offchain.BuildTx.ProtocolParams