diff --git a/.env.example b/.env.example index bec8e4db..b24f9fcf 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,9 @@ CONVERTIBLES_FACET= EQUITY_COMPENSATION_FACET= STOCK_PLAN_FACET= WARRANT_FACET= -STAKEHOLDER_NFT_FACET= \ No newline at end of file +STAKEHOLDER_NFT_FACET= + +# Canton config +TRANSFER_AGENT_CLIENT_SECRET= +FAIRMINT_PARTY_ID= +FAIRMINT_USER_ID= diff --git a/.gitmodules b/.gitmodules index 53046645..d5e25e1a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "chain/lib/diamond-3-hardhat"] path = chain/lib/diamond-3-hardhat url = https://github.com/mudgen/diamond-3-hardhat +[submodule "fairmint-canton"] + path = src/chain-operations/canton/lib/fairmint-canton + url = https://github.com/Fairmint/canton diff --git a/.prettierignore b/.prettierignore index d1086455..09a9e083 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ /node_modules /chain/out .history +src/chain-operations/canton/lib # Build output /dist @@ -14,4 +15,4 @@ .env* # Logs -*.log \ No newline at end of file +*.log diff --git a/eslint.config.js b/eslint.config.js index 901bbf4c..3ed31e68 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,7 @@ import importPlugin from "eslint-plugin-import"; export default [ { files: ["**/*.{js,ts,mjs,mts}"], - ignores: ["node_modules/**", "eslint.config.js"], + ignores: ["node_modules/**", "eslint.config.js", "src/chain-operations/canton/lib/**"], languageOptions: { ecmaVersion: 2022, sourceType: "module", @@ -39,7 +39,7 @@ export default [ }, { files: ["**/*.ts"], - ignores: ["node_modules/**"], + ignores: ["node_modules/**", "src/chain-operations/canton/lib/**"], languageOptions: { parser: tsparser, parserOptions: { diff --git a/src/chain-operations/canton/clientConfig.js b/src/chain-operations/canton/clientConfig.js new file mode 100644 index 00000000..62299357 --- /dev/null +++ b/src/chain-operations/canton/clientConfig.js @@ -0,0 +1,7 @@ +import { TransferAgentConfig } from "./lib/fairmint-canton/scripts/src/helpers/config"; +import { FairmintClient } from "./lib/fairmint-canton/scripts/src/helpers/fairmintClient"; + +const config = new TransferAgentConfig(true); +const client = new FairmintClient(config); + +export { config, client }; diff --git a/src/chain-operations/canton/constants.js b/src/chain-operations/canton/constants.js new file mode 100644 index 00000000..90a5789e --- /dev/null +++ b/src/chain-operations/canton/constants.js @@ -0,0 +1,9 @@ +// TODO https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-7.md +// Once changed from chainId: number to CAIP-7: string, use `canton:mainnet` and `canton:devnet` + +export const CANTON_MAINNET_CHAIN_ID = 6765788401; +export const CANTON_DEVNET_CHAIN_ID = 6765788402; + +export function isCantonChainId(chainId) { + return chainId == CANTON_MAINNET_CHAIN_ID || chainId == CANTON_DEVNET_CHAIN_ID; +} diff --git a/src/chain-operations/canton/deployCapTableCanton.js b/src/chain-operations/canton/deployCapTableCanton.js new file mode 100644 index 00000000..cc15f101 --- /dev/null +++ b/src/chain-operations/canton/deployCapTableCanton.js @@ -0,0 +1,29 @@ +import { client } from "./clientConfig"; + +// eslint-disable-next-line no-unused-vars +export async function deployCapTableCanton(issuerId, initial_shares_authorized, chainId, issuer) { + console.log("🗽 | Deploying cap table on Canton..."); + + // Create FairmintAdminService [One time] + const { contractId } = await client.createFairmintAdminService(); + + // Create new party for issuer [Once per issuer] + const { partyId: issuerPartyId } = await client.createParty(issuerId); + + // Authorize issuer [Once per issuer] + const authorizationContractId = await client.authorizeIssuer(contractId, issuerPartyId); + + // Issuer accepts authorization [Once per issuer] + const issuerContractId = await client.acceptIssuerAuthorization( + authorizationContractId, + issuer.legal_name, + initial_shares_authorized, + issuerPartyId + ); + + return { + partyId: issuerPartyId, + // deployId: updateId, // TODO + address: issuerContractId, + }; +} diff --git a/src/chain-operations/canton/lib/fairmint-canton b/src/chain-operations/canton/lib/fairmint-canton new file mode 160000 index 00000000..9be91106 --- /dev/null +++ b/src/chain-operations/canton/lib/fairmint-canton @@ -0,0 +1 @@ +Subproject commit 9be91106195c491b5bec8e1c331c37e70a18213a diff --git a/src/chain-operations/canton/stakeholderControllerCanton.js b/src/chain-operations/canton/stakeholderControllerCanton.js new file mode 100644 index 00000000..57c13893 --- /dev/null +++ b/src/chain-operations/canton/stakeholderControllerCanton.js @@ -0,0 +1,11 @@ +import { client } from "./clientConfig"; + +// eslint-disable-next-line no-unused-vars +export const convertAndReflectStakeholderOnchainCanton = async (stakeholderId) => { + console.log("🗽 | Converting and reflecting stakeholder onchain Canton..."); + + // Create new party for stakeholder [Once per stakeholder] + const { partyId } = await client.createParty(stakeholderId); + + return { partyId, updateId: null /* TODO */ }; +}; diff --git a/src/chain-operations/canton/stockClassControllerCanton.js b/src/chain-operations/canton/stockClassControllerCanton.js new file mode 100644 index 00000000..c0af06fc --- /dev/null +++ b/src/chain-operations/canton/stockClassControllerCanton.js @@ -0,0 +1,13 @@ +import { client } from "./clientConfig"; + +export const convertAndReflectStockClassOnchainCanton = async (stockClass, issuer) => { + const classType = stockClass.class_type === "COMMON" ? "Common" : "Unknown"; + const { stockClassContractId, updatedIssuerContractId } = await client.createStockClass( + issuer.deployed_to, + classType, + stockClass.initial_shares_authorized, + issuer.party_id + ); + + return { stockClassContractId, updatedIssuerContractId }; +}; diff --git a/src/chain-operations/canton/transferControllerCanton.js b/src/chain-operations/canton/transferControllerCanton.js new file mode 100644 index 00000000..3a9d73d4 --- /dev/null +++ b/src/chain-operations/canton/transferControllerCanton.js @@ -0,0 +1,21 @@ +import { client } from "./clientConfig"; + +export const convertAndCreateTransferStockOnchainCanton = async (contract, transfer) => { + const { transferorPartyId, transferorStockPositionContractId, transfereePartyId, quantity } = transfer; + + // Transferer proposes share transfer to transferee + const { transferProposalContractId, updatedStockPositionContractId } = await client.proposeTransfer( + transferorStockPositionContractId, + transfereePartyId, + quantity, + transferorPartyId + ); + + // Transferee accepts the transfer proposal and receives shares + const transfereeStockPositionContractId = await client.acceptTransfer(transferProposalContractId, transfereePartyId); + + return { + transferorUpdatedStockPositionContractId: updatedStockPositionContractId, + transfereeStockPositionContractId: transfereeStockPositionContractId, + }; +}; diff --git a/src/chain-operations/deployCapTable.js b/src/chain-operations/deployCapTable.js index 3ebdb5c3..7deb48a4 100644 --- a/src/chain-operations/deployCapTable.js +++ b/src/chain-operations/deployCapTable.js @@ -7,6 +7,8 @@ import getProvider from "./getProvider.js"; import Factory, { FACTORY_VERSION } from "../db/objects/Factory.js"; import assert from "node:assert"; import { decodeError } from "../utils/errorDecoder"; +import { isCantonChainId } from "./canton/constants.js"; +import { deployCapTableCanton } from "./canton/deployCapTableCanton.js"; setupEnv(); @@ -20,7 +22,11 @@ export const getWallet = async (chainId) => { return new ethers.Wallet(WALLET_PRIVATE_KEY, provider); }; -async function deployCapTable(issuerId, initial_shares_authorized, chainId) { +async function deployCapTable(issuerId, initial_shares_authorized, chainId, issuer) { + if (isCantonChainId(chainId)) { + return deployCapTableCanton(issuerId, initial_shares_authorized, chainId, issuer); + } + // Get provider for specified chain const wallet = await getWallet(chainId); console.log("🗽 | Wallet address: ", wallet.address); diff --git a/src/chain-operations/getContractInstances.js b/src/chain-operations/getContractInstances.js index a4afc68a..606b32c8 100644 --- a/src/chain-operations/getContractInstances.js +++ b/src/chain-operations/getContractInstances.js @@ -11,10 +11,16 @@ import WARRANT_FACET from "../../chain/out/WarrantFacet.sol/WarrantFacet.json"; import EQUITY_COMPENSATION_FACET from "../../chain/out/EquityCompensationFacet.sol/EquityCompensationFacet.json"; import STOCK_PLAN_FACET from "../../chain/out/StockPlanFacet.sol/StockPlanFacet.json"; import STAKEHOLDER_NFT_FACET from "../../chain/out/StakeholderNFTFacet.sol/StakeholderNFTFacet.json"; +import { isCantonChainId } from "./canton/constants.js"; setupEnv(); export const getContractInstance = (address, chainId) => { + if (isCantonChainId(chainId)) { + console.log(`Canton chain ${chainId} contract instance is not supported yet`); + return null; + } + const WALLET_PRIVATE_KEY = process.env.PRIVATE_KEY; // Create a combined ABI from all facets const combinedABI = [ diff --git a/src/chain-operations/getProvider.js b/src/chain-operations/getProvider.js index e361ca80..a1916042 100644 --- a/src/chain-operations/getProvider.js +++ b/src/chain-operations/getProvider.js @@ -1,7 +1,12 @@ import { ethers } from "ethers"; import { getChainConfig } from "../utils/chains.js"; +import { isCantonChainId } from "./canton/constants.js"; function getProvider(chainId) { + if (isCantonChainId(chainId)) { + throw new Error("Canton is not supported yet"); + } + const chainConfig = getChainConfig(chainId); if (!chainConfig) { throw new Error(`Unsupported chain ID: ${chainId}`); diff --git a/src/chain-operations/issuanceControllerCanton.js b/src/chain-operations/issuanceControllerCanton.js new file mode 100644 index 00000000..fbe5f7a8 --- /dev/null +++ b/src/chain-operations/issuanceControllerCanton.js @@ -0,0 +1,19 @@ +import { client } from "./canton/clientConfig"; + +export const convertAndCreateIssuanceStockOnchainCanton = async ({ stockClassContractId, stakeholderPartyId, quantity, issuerPartyId }) => { + // Issuer proposes quantity shares to stakeholder + const { proposalContractId, updatedStockClassContractId } = await client.proposeIssueStock( + stockClassContractId, + stakeholderPartyId, + quantity, + issuerPartyId + ); + + // Stakeholder accepts the proposal and receives shares + const stakeholderStockPositionContractId = await client.acceptIssueStockProposal(proposalContractId, stakeholderPartyId); + + return { + stakeholderStockPositionContractId, + updatedStockClassContractId, + }; +}; diff --git a/src/controllers/stockClassController.js b/src/controllers/stockClassController.js index 2d14c004..a30b52f5 100644 --- a/src/controllers/stockClassController.js +++ b/src/controllers/stockClassController.js @@ -1,9 +1,15 @@ import { toScaledBigNumber } from "../utils/convertToFixedPointDecimals.js"; import { convertUUIDToBytes16 } from "../utils/convertUUID.js"; import { decodeError } from "../utils/errorDecoder.js"; +import { isCantonChainId } from "../chain-operations/canton/constants.js"; +import { convertAndReflectStockClassOnchainCanton } from "../chain-operations/canton/stockClassControllerCanton.js"; /// @dev: controller handles conversion from OCF type to Onchain types and creates the stock class. -export const convertAndReflectStockClassOnchain = async (contract, stockClass) => { +export const convertAndReflectStockClassOnchain = async (contract, stockClass, issuer) => { + if (isCantonChainId(issuer.chain_id)) { + return convertAndReflectStockClassOnchainCanton(stockClass, issuer); + } + try { const stockClassIdBytes16 = convertUUIDToBytes16(stockClass.id); const scaledSharePrice = toScaledBigNumber(stockClass.price_per_share.amount); diff --git a/src/controllers/transactions/issuanceController.js b/src/controllers/transactions/issuanceController.js index ca1e5a41..f63fd198 100644 --- a/src/controllers/transactions/issuanceController.js +++ b/src/controllers/transactions/issuanceController.js @@ -1,12 +1,30 @@ import { convertUUIDToBytes16 } from "../../utils/convertUUID.js"; import { toScaledBigNumber } from "../../utils/convertToFixedPointDecimals.js"; import { decodeError } from "../../utils/errorDecoder.js"; +import { isCantonChainId } from "../../chain-operations/canton/constants.js"; +import { convertAndCreateIssuanceStockOnchainCanton } from "../../chain-operations/issuanceControllerCanton.js"; // Stock Issuance export const convertAndCreateIssuanceStockOnchain = async ( contract, - { id, security_id, stock_class_id, stakeholder_id, quantity, share_price, custom_id = "" } + { + id, + chain_id, + security_id, + stock_class_id, + stakeholder_id, + quantity, + share_price, + custom_id = "", + stockClassContractId, + stakeholderPartyId, + issuerPartyId, + } ) => { + if (isCantonChainId(chain_id)) { + return convertAndCreateIssuanceStockOnchainCanton({ stockClassContractId, stakeholderPartyId, quantity, issuerPartyId }); + } + try { const tx = await contract.issueStock({ id: convertUUIDToBytes16(id), diff --git a/src/controllers/transactions/transferController.js b/src/controllers/transactions/transferController.js index 99d56305..18c6a450 100644 --- a/src/controllers/transactions/transferController.js +++ b/src/controllers/transactions/transferController.js @@ -1,8 +1,14 @@ import { convertUUIDToBytes16 } from "../../utils/convertUUID.js"; import { toScaledBigNumber } from "../../utils/convertToFixedPointDecimals.js"; import { decodeError } from "../../utils/errorDecoder.js"; +import { isCantonChainId } from "../../chain-operations/canton/constants.js"; +import { convertAndCreateTransferStockOnchainCanton } from "../../chain-operations/canton/transferControllerCanton.js"; export const convertAndCreateTransferStockOnchain = async (contract, transfer) => { + if (isCantonChainId(transfer.chain_id)) { + return convertAndCreateTransferStockOnchainCanton(contract, transfer); + } + try { const { quantity, transferorId, transfereeId, stockClassId, sharePrice } = transfer; diff --git a/src/db/objects/Issuer.js b/src/db/objects/Issuer.js index 7a2f5f93..f1e259e2 100644 --- a/src/db/objects/Issuer.js +++ b/src/db/objects/Issuer.js @@ -23,6 +23,7 @@ const IssuerSchema = new mongoose.Schema( chain_id: { type: Number, required: true }, tx_hash: { type: String, default: null }, factory: { ref: "Factory", type: String }, + party_id: { type: String, default: null }, }, { timestamps: true } ); diff --git a/src/db/objects/Stakeholder.js b/src/db/objects/Stakeholder.js index 01cd9f52..ff4d845a 100644 --- a/src/db/objects/Stakeholder.js +++ b/src/db/objects/Stakeholder.js @@ -20,6 +20,8 @@ const StakeholderSchema = new mongoose.Schema( addresses: [{}], tax_ids: [{}], tx_hash: { type: String, default: null }, + party_id: { type: String, default: null }, + stock_position_contract_id: { type: String, default: null }, }, { timestamps: true } ); diff --git a/src/db/objects/StockClass.js b/src/db/objects/StockClass.js index 2ce98201..a4bafffe 100644 --- a/src/db/objects/StockClass.js +++ b/src/db/objects/StockClass.js @@ -25,6 +25,7 @@ const StockClassSchema = new mongoose.Schema( }, is_onchain_synced: { type: Boolean, default: false }, tx_hash: { type: String, default: null }, + contract_id: { type: String, default: null }, }, { timestamps: true } ); diff --git a/src/examples/testTransfer.mjs b/src/examples/testTransfer.mjs index d0c391aa..c38a727f 100644 --- a/src/examples/testTransfer.mjs +++ b/src/examples/testTransfer.mjs @@ -2,6 +2,7 @@ import { issuer, stakeholder1, stakeholder2, stockClass, stockIssuance, stockTra import axios from "axios"; import sleep from "../utils/sleep.js"; import { v4 as uuid } from "uuid"; +import { CANTON_DEVNET_CHAIN_ID } from "../chain-operations/canton/constants.js"; const main = async () => { try { @@ -14,7 +15,7 @@ const main = async () => { // 1. Create issuer console.log("⏳ Creating issuer..."); issuer.id = issuerId; - issuer.chain_id = 31337; + issuer.chain_id = CANTON_DEVNET_CHAIN_ID; const issuerResponse = await axios.post("http://localhost:8080/issuer/create", issuer); console.log("✅ Issuer created:", issuerResponse.data); diff --git a/src/routes/issuer.js b/src/routes/issuer.js index d798983f..1442c43d 100644 --- a/src/routes/issuer.js +++ b/src/routes/issuer.js @@ -64,13 +64,19 @@ issuer.post("/create", async (req, res) => { const issuerIdBytes16 = convertUUIDToBytes16(incomingIssuerToValidate.id); console.log("💾 | Issuer id in bytes16 ", issuerIdBytes16); - const { address, deployHash } = await deployCapTable(issuerIdBytes16, incomingIssuerToValidate.initial_shares_authorized, chain_id); + const { address, deployHash, partyId } = await deployCapTable( + issuerIdBytes16, + incomingIssuerToValidate.initial_shares_authorized, + chain_id, + incomingIssuerToValidate + ); const incomingIssuerForDB = { ...incomingIssuerToValidate, deployed_to: address, tx_hash: deployHash, chain_id, + party_id: partyId, }; const issuer = await createIssuer(incomingIssuerForDB); diff --git a/src/routes/manifest.js b/src/routes/manifest.js index c5c1a53a..79904c19 100644 --- a/src/routes/manifest.js +++ b/src/routes/manifest.js @@ -133,4 +133,39 @@ router.post("/verify", async (req, res) => { } }); +router.post("/cross-id-check", async (req, res) => { + try { + // Validate request body + if (!req.body || Object.keys(req.body).length === 0) { + return res.status(400).send({ + error: "Request body is empty or invalid JSON.", + valid: false, + }); + } + + // Get data from workspace JSON + const data = await getAllStateMachineObjectsFromWorkspaceJSON(req.body); + + // Validate cap table data + const validationErrors = await validateCapTableData(data); + if (validationErrors.length > 0) { + return res.status(400).send({ + errors: validationErrors, + valid: false, + }); + } + + return res.status(200).send({ + valid: true, + errors: [], + }); + } catch (error) { + console.error("Cap table verification error:", error); + return res.status(500).send({ + error: String(error), + valid: false, + }); + } +}); + export default router; diff --git a/src/routes/stakeholder/base.js b/src/routes/stakeholder/base.js index 6a643b2b..e4fff84c 100644 --- a/src/routes/stakeholder/base.js +++ b/src/routes/stakeholder/base.js @@ -12,6 +12,8 @@ import { createStakeholder } from "../../db/operations/create.js"; import { readIssuerById, readStakeholderById, getAllStakeholdersByIssuerId } from "../../db/operations/read.js"; import validateInputAgainstOCF from "../../utils/validateInputAgainstSchema.js"; import Stakeholder from "../../db/objects/Stakeholder.js"; +import { isCantonChainId } from "../../chain-operations/canton/constants.js"; +import { convertAndReflectStakeholderOnchainCanton } from "../../chain-operations/canton/stakeholderControllerCanton.js"; const router = Router(); @@ -109,15 +111,25 @@ router.post("/create", async (req, res) => { } // Save offchain - const stakeholder = await createStakeholder(incomingStakeholderForDB); + const stakeholder = await createStakeholder({ ...incomingStakeholderForDB }); + console.log("✅ | Stakeholder created offchain:", stakeholder); // Save onchain - const receipt = await convertAndReflectStakeholderOnchain(contract, incomingStakeholderForDB.id); - await Stakeholder.findByIdAndUpdate(stakeholder._id, { tx_hash: receipt.hash }); + let tx_hash; + let partyId = null; + if (!isCantonChainId(issuer.chain_id)) { + ({ hash: tx_hash } = await convertAndReflectStakeholderOnchain(contract, incomingStakeholderForDB.id)); + await Stakeholder.findByIdAndUpdate(stakeholder._id, { tx_hash }); + } else { + ({ updateId: tx_hash, partyId } = await convertAndReflectStakeholderOnchainCanton(incomingStakeholderForDB.id)); + } - console.log("✅ | Stakeholder created offchain:", stakeholder); + if (partyId) { + await Stakeholder.findByIdAndUpdate(stakeholder._id, { party_id: partyId }); + console.log("✅ | Stakeholder updated offchain with new partyId:", partyId); + } - res.status(200).send({ stakeholder: { ...stakeholder.toObject(), tx_hash: receipt.hash } }); + res.status(200).send({ stakeholder: { ...stakeholder.toObject(), tx_hash, partyId } }); } catch (error) { console.error(error); res.status(500).send(`${error}`); diff --git a/src/routes/stockClass.js b/src/routes/stockClass.js index aefc1824..45ce1d2a 100644 --- a/src/routes/stockClass.js +++ b/src/routes/stockClass.js @@ -6,6 +6,7 @@ import { createStockClass } from "../db/operations/create.js"; import { readIssuerById, readStockClassById } from "../db/operations/read.js"; import validateInputAgainstOCF from "../utils/validateInputAgainstSchema.js"; import Stockclass from "../db/objects/StockClass"; +import Issuer from "../db/objects/Issuer"; const stockClass = Router(); @@ -45,6 +46,9 @@ stockClass.post("/create", async (req, res) => { try { const issuer = await readIssuerById(issuerId); + if (!issuer) { + return res.status(404).send({ message: "Issuer not found" }); + } // OCF doesn't allow extra fields in their validation const incomingStockClassToValidate = { @@ -68,12 +72,20 @@ stockClass.post("/create", async (req, res) => { const stockClass = await createStockClass(incomingStockClassForDB); // Save Onchain - const receipt = await convertAndReflectStockClassOnchain(contract, incomingStockClassForDB); - await Stockclass.findByIdAndUpdate(stockClass._id, { tx_hash: receipt.hash }); - + const { + hash: tx_hash, + stockClassContractId, + updatedIssuerContractId, + } = await convertAndReflectStockClassOnchain(contract, incomingStockClassForDB, issuer); + await Stockclass.findByIdAndUpdate(stockClass._id, { tx_hash, contract_id: stockClassContractId ?? null }); console.log("✅ | Stock Class created offchain:", stockClass); - res.status(200).send({ stockClass: { ...stockClass.toObject(), tx_hash: receipt.hash } }); + if (updatedIssuerContractId) { + await Issuer.findByIdAndUpdate(issuerId, { deployed_to: updatedIssuerContractId }); + console.log("✅ | Issuer updated offchain:", issuer); + } + + res.status(200).send({ stockClass: { ...stockClass.toObject(), tx_hash: tx_hash ?? null } }); } catch (error) { console.error(error); res.status(500).send(`${error}`); diff --git a/src/routes/transactions/base.js b/src/routes/transactions/base.js index ec439ae0..99fcce65 100644 --- a/src/routes/transactions/base.js +++ b/src/routes/transactions/base.js @@ -44,6 +44,7 @@ import { readStockPlanById, readIssuerById, readStockClassById, + readStakeholderById, readConvertibleIssuanceBySecurityId, readStockIssuanceBySecurityId, readEquityCompensationIssuanceBySecurityId, @@ -56,6 +57,7 @@ import get from "lodash/get"; import { convertAndCreateEquityCompensationExerciseOnchain } from "../../controllers/transactions/exerciseController"; import { adjustStockPlanPoolOnchain } from "../../controllers/stockPlanController"; import StockIssuance from "../../db/objects/transactions/issuance/StockIssuance.js"; +import StockClass from "../../db/objects/StockClass.js"; import ConvertibleIssuance from "../../db/objects/transactions/issuance/ConvertibleIssuance.js"; import EquityCompensationIssuance from "../../db/objects/transactions/issuance/EquityCompensationIssuance.js"; import WarrantIssuance from "../../db/objects/transactions/issuance/WarrantIssuance.js"; @@ -63,6 +65,7 @@ import StockClassAuthorizedSharesAdjustment from "../../db/objects/transactions/ import StockPlanPoolAdjustment from "../../db/objects/transactions/adjustment/StockPlanPoolAdjustment.js"; import { EquityCompensationExercise } from "../../db/objects/transactions/exercise"; import { StockCancellation } from "../../db/objects/transactions/cancellation"; +import Stakeholder from "../../db/objects/Stakeholder"; const transactions = Router(); @@ -71,7 +74,7 @@ transactions.post("/issuance/stock", async (req, res) => { const { issuerId, data } = req.body; try { - await readIssuerById(issuerId); + const issuer = await readIssuerById(issuerId); const incomingStockIssuance = { id: uuid(), // for OCF Validation security_id: uuid(), // for OCF Validation @@ -90,8 +93,20 @@ transactions.post("/issuance/stock", async (req, res) => { } const stockIssuance = await createStockIssuance({ ...incomingStockIssuance, issuer: issuerId }); + const stockClass = await readStockClassById(incomingStockIssuance.stock_class_id); + if (!stockClass?._id) { + return res.status(404).send({ message: "Stock class not found" }); + } + const stakeholder = await readStakeholderById(incomingStockIssuance.stakeholder_id); + if (!stakeholder?._id) { + return res.status(404).send({ message: "Stakeholder not found" }); + } - const receipt = await convertAndCreateIssuanceStockOnchain(contract, { + const { + hash: tx_hash, + stakeholderStockPositionContractId, + updatedStockClassContractId, + } = await convertAndCreateIssuanceStockOnchain(contract, { security_id: incomingStockIssuance.security_id, stock_class_id: incomingStockIssuance.stock_class_id, stakeholder_id: incomingStockIssuance.stakeholder_id, @@ -100,12 +115,27 @@ transactions.post("/issuance/stock", async (req, res) => { stock_legend_ids_mapping: incomingStockIssuance.stock_legend_ids_mapping, custom_id: incomingStockIssuance.custom_id || "", id: incomingStockIssuance.id, + + chain_id: issuer.chain_id, + stockClassContractId: stockClass.contract_id, + issuerPartyId: issuer.party_id, + stakeholderPartyId: stakeholder.party_id, }); // Update the stock issuance with tx_hash - await StockIssuance.findByIdAndUpdate(stockIssuance._id, { tx_hash: receipt.hash }); + await StockIssuance.findByIdAndUpdate(stockIssuance._id, { tx_hash: tx_hash ?? null }); - res.status(200).send({ stockIssuance: { ...stockIssuance.toObject(), tx_hash: receipt.hash } }); + // Canton only updates: + if (stakeholderStockPositionContractId) { + await Stakeholder.findByIdAndUpdate(stakeholder._id, { stock_position_contract_id: stakeholderStockPositionContractId }); + console.log("✅ | Stakeholder updated offchain with new Stock Position Contract ID", stakeholderStockPositionContractId); + } + if (updatedStockClassContractId) { + await StockClass.findByIdAndUpdate(stockClass._id, { contract_id: updatedStockClassContractId }); + console.log("✅ | Stock Class updated offchain with new Contract ID", updatedStockClassContractId); + } + + res.status(200).send({ stockIssuance: { ...stockIssuance.toObject(), tx_hash: tx_hash ?? null } }); } catch (error) { console.error(error); res.status(500).send(`${error}`); @@ -117,10 +147,34 @@ transactions.post("/transfer/stock", async (req, res) => { const { issuerId, data } = req.body; try { - await readIssuerById(issuerId); + const issuer = await readIssuerById(issuerId); + const transferor = await readStakeholderById(data.transferorId); + if (!transferor) { + return res.status(404).send({ message: "Transferor not found" }); + } + const transferee = await readStakeholderById(data.transfereeId); + if (!transferee) { + return res.status(404).send({ message: "Transferee not found" }); + } // @dev: Transfer Validation is not possible through schema because it validates that the transfer has occurred,at this stage it has not yet. - await convertAndCreateTransferStockOnchain(contract, data); + const { transferorUpdatedStockPositionContractId, transfereeStockPositionContractId } = await convertAndCreateTransferStockOnchain(contract, { + ...data, + chain_id: issuer.chain_id, + transferorPartyId: transferor.party_id, + transferorStockPositionContractId: transferor.stock_position_contract_id, + transfereePartyId: transferee.party_id, + }); + + // Canton only updates: + if (transferorUpdatedStockPositionContractId) { + await Stakeholder.findByIdAndUpdate(transferor._id, { stock_position_contract_id: transferorUpdatedStockPositionContractId }); + console.log("✅ | Transferor updated offchain with new Stock Position Contract ID", transferorUpdatedStockPositionContractId); + } + if (transfereeStockPositionContractId) { + await Stakeholder.findByIdAndUpdate(transferee._id, { stock_position_contract_id: transfereeStockPositionContractId }); + console.log("✅ | Transferee updated offchain with new Stock Position Contract ID", transfereeStockPositionContractId); + } res.status(200).send("success"); } catch (error) { diff --git a/src/utils/chains.js b/src/utils/chains.js index abca1b0b..4f0df326 100644 --- a/src/utils/chains.js +++ b/src/utils/chains.js @@ -1,3 +1,5 @@ +import { CANTON_DEVNET_CHAIN_ID } from "../chain-operations/canton/constants.js"; + // Chain configuration for supported networks export const SUPPORTED_CHAINS = { 8453: { @@ -18,6 +20,10 @@ export const SUPPORTED_CHAINS = { rpcUrl: "http://localhost:8545", wsUrl: "ws://localhost:8545", }, + [CANTON_DEVNET_CHAIN_ID]: { + // Canton + name: "Canton", + }, }; // Get chain configuration diff --git a/src/utils/websocket.ts b/src/utils/websocket.ts index 9640b025..bcf47723 100644 --- a/src/utils/websocket.ts +++ b/src/utils/websocket.ts @@ -7,6 +7,7 @@ import { handleStockPlan, txMapper, txTypes } from "../chain-operations/transact import { handleStakeholder, handleStockClass } from "../chain-operations/transactionHandlers"; import Issuer from "../db/objects/Issuer"; import { TxCreated, StakeholderCreated, StockClassCreated, StockPlanCreated } from "../chain-operations/topics"; +import { isCantonChainId } from "../chain-operations/canton/constants"; const TOPICS = { TxCreated, StakeholderCreated, StockClassCreated, StockPlanCreated }; @@ -27,6 +28,11 @@ const getChainProvider = (chainId: string): ethers.Provider => { // Function to add new addresses to watch for a specific chain export const addAddressesToWatch = async (chainId: string, addresses: string | string[]) => { + if (isCantonChainId(chainId)) { + console.log(`Canton chain ${chainId} address watching is not supported yet`); + return; + } + const addressArray = Array.isArray(addresses) ? addresses : [addresses]; if (!watchedAddressesByChain.has(chainId)) { @@ -42,6 +48,10 @@ export const addAddressesToWatch = async (chainId: string, addresses: string | s // Function to setup a single chain listener const setupChainListener = async (chainId: string, addresses: string[]) => { + if (isCantonChainId(chainId)) { + console.log(`Canton chain ${chainId} chain listener is not supported yet`); + return; + } const provider = getChainProvider(chainId); if (addresses.length > 0) {