diff --git a/.github/workflows/deploy.dev.yaml b/.github/workflows/deploy.dev.yaml index a65b8b58..05f44d69 100644 --- a/.github/workflows/deploy.dev.yaml +++ b/.github/workflows/deploy.dev.yaml @@ -56,6 +56,12 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} DATABASE_URL: ${{ secrets.DATABASE_URL }} RPC_URL: ${{ secrets.RPC_URL }} + FAIRMINT_MAINNET_USER_ID: ${{ secrets.FAIRMINT_MAINNET_USER_ID }} + FAIRMINT_MAINNET_PARTY_ID: ${{ secrets.FAIRMINT_MAINNET_PARTY_ID }} + TRANSFER_AGENT_MAINNET_CLIENT_SECRET: ${{ secrets.TRANSFER_AGENT_MAINNET_CLIENT_SECRET }} + FAIRMINT_DEVNET_USER_ID: ${{ secrets.FAIRMINT_DEVNET_USER_ID }} + FAIRMINT_DEVNET_PARTY_ID: ${{ secrets.FAIRMINT_DEVNET_PARTY_ID }} + TRANSFER_AGENT_DEVNET_CLIENT_SECRET: ${{ secrets.TRANSFER_AGENT_DEVNET_CLIENT_SECRET }} run: | # Generate timestamp for deployment @@ -121,14 +127,25 @@ jobs: -e PORT=8080 \ -e PRIVATE_KEY='${PRIVATE_KEY}' \ -e ETHERSCAN_L2_API_KEY='${ETHERSCAN_L2_API_KEY}' \ + -e FAIRMINT_MAINNET_USER_ID='${FAIRMINT_MAINNET_USER_ID}' \ + -e FAIRMINT_MAINNET_PARTY_ID='${FAIRMINT_MAINNET_PARTY_ID}' \ + -e TRANSFER_AGENT_MAINNET_CLIENT_SECRET='${TRANSFER_AGENT_MAINNET_CLIENT_SECRET}' \ + -e FAIRMINT_DEVNET_USER_ID='${FAIRMINT_DEVNET_USER_ID}' \ + -e FAIRMINT_DEVNET_PARTY_ID='${FAIRMINT_DEVNET_PARTY_ID}' \ + -e TRANSFER_AGENT_DEVNET_CLIENT_SECRET='${TRANSFER_AGENT_DEVNET_CLIENT_SECRET}' \ -v '/home/ubuntu/global-bundle.pem:/global-bundle.pem' \ ocp-dev:${DEPLOY_TIME} && \ # Wait for container to be healthy - wait_for_health "\$CONTAINER_NAME" && \ + echo "Printing logs before health check:" + docker logs \$CONTAINER_NAME || true + + wait_for_health "\$CONTAINER_NAME" if [ \$? -eq 0 ]; then handle_container_switch "\$CONTAINER_NAME" "${DEPLOY_TIME}" "dev" else + echo "Container failed health check. Printing logs:" + docker logs \$CONTAINER_NAME || true handle_failed_deployment "\$CONTAINER_NAME" "${DEPLOY_TIME}" "dev" fi " diff --git a/.github/workflows/deploy.prod.yaml b/.github/workflows/deploy.prod.yaml index 44378da3..ff2590a7 100644 --- a/.github/workflows/deploy.prod.yaml +++ b/.github/workflows/deploy.prod.yaml @@ -58,6 +58,9 @@ jobs: FAIRMINT_MAINNET_USER_ID: ${{ secrets.FAIRMINT_MAINNET_USER_ID }} FAIRMINT_MAINNET_PARTY_ID: ${{ secrets.FAIRMINT_MAINNET_PARTY_ID }} TRANSFER_AGENT_MAINNET_CLIENT_SECRET: ${{ secrets.TRANSFER_AGENT_MAINNET_CLIENT_SECRET }} + FAIRMINT_DEVNET_USER_ID: ${{ secrets.FAIRMINT_DEVNET_USER_ID }} + FAIRMINT_DEVNET_PARTY_ID: ${{ secrets.FAIRMINT_DEVNET_PARTY_ID }} + TRANSFER_AGENT_DEVNET_CLIENT_SECRET: ${{ secrets.TRANSFER_AGENT_DEVNET_CLIENT_SECRET }} run: | # Generate timestamp for deployment @@ -123,9 +126,12 @@ jobs: -e PORT=8080 \ -e PRIVATE_KEY='${PRIVATE_KEY}' \ -e ETHERSCAN_L2_API_KEY='${ETHERSCAN_L2_API_KEY}' \ - -e FAIRMINT_MAINNET_USER_ID: '${FAIRMINT_MAINNET_USER_ID}' \ - -e FAIRMINT_MAINNET_PARTY_ID: '${FAIRMINT_MAINNET_PARTY_ID}' \ - -e TRANSFER_AGENT_MAINNET_CLIENT_SECRET: '${TRANSFER_AGENT_MAINNET_CLIENT_SECRET}' \ + -e FAIRMINT_MAINNET_USER_ID='${FAIRMINT_MAINNET_USER_ID}' \ + -e FAIRMINT_MAINNET_PARTY_ID='${FAIRMINT_MAINNET_PARTY_ID}' \ + -e TRANSFER_AGENT_MAINNET_CLIENT_SECRET='${TRANSFER_AGENT_MAINNET_CLIENT_SECRET}' \ + -e FAIRMINT_DEVNET_USER_ID='${FAIRMINT_DEVNET_USER_ID}' \ + -e FAIRMINT_DEVNET_PARTY_ID='${FAIRMINT_DEVNET_PARTY_ID}' \ + -e TRANSFER_AGENT_DEVNET_CLIENT_SECRET='${TRANSFER_AGENT_DEVNET_CLIENT_SECRET}' \ -v '/home/ubuntu/global-bundle.pem:/global-bundle.pem' \ ocp-prod:${DEPLOY_TIME} && \ diff --git a/.gitmodules b/.gitmodules index d5e25e1a..53046645 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,6 +13,3 @@ [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/src/chain-operations/canton/clientConfig.js b/src/chain-operations/canton/clientConfig.js index 62299357..a5eab7fd 100644 --- a/src/chain-operations/canton/clientConfig.js +++ b/src/chain-operations/canton/clientConfig.js @@ -1,5 +1,5 @@ -import { TransferAgentConfig } from "./lib/fairmint-canton/scripts/src/helpers/config"; -import { FairmintClient } from "./lib/fairmint-canton/scripts/src/helpers/fairmintClient"; +import { TransferAgentConfig } from "./lib/config"; +import { FairmintClient } from "./lib/fairmintClient"; const config = new TransferAgentConfig(true); const client = new FairmintClient(config); diff --git a/src/chain-operations/canton/lib/client.js b/src/chain-operations/canton/lib/client.js new file mode 100644 index 00000000..9ad663d1 --- /dev/null +++ b/src/chain-operations/canton/lib/client.js @@ -0,0 +1,270 @@ +import axios from 'axios'; +import { TransferAgentConfig } from './config.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +export class TransferAgentClient { + constructor(config) { + this.config = config; + this.bearerToken = null; + this.sequenceNumber = 1; + this.axiosInstance = axios.create(); + this.logDir = path.join(process.cwd(), 'logs'); + if (!fs.existsSync(this.logDir)) { + fs.mkdirSync(this.logDir, { recursive: true }); + } + } + + getFairmintPartyId() { + return this.config.fairmintPartyId; + } + + async logRequestResponse(url, request, response) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logFile = path.join(this.logDir, `request-${timestamp}.json`); + + const logData = { + timestamp, + url, + request, + response + }; + + fs.writeFileSync(logFile, JSON.stringify(logData, null, 2)); + } + + async makePostRequest(url, data, headers) { + try { + const response = await this.axiosInstance.post(url, data, { headers }); + await this.logRequestResponse(url, data, response.data); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorData = error.response?.data; + + // Check for security-sensitive error + if (errorData?.cause === "A security-sensitive error has been received") { + // Clear the bearer token to force re-authentication + this.bearerToken = null; + + // Get new headers with fresh authentication + const newHeaders = await this.getHeaders(); + + // Retry the request once with new authentication + try { + const retryResponse = await this.axiosInstance.post(url, data, { headers: newHeaders }); + await this.logRequestResponse(url, data, retryResponse.data); + return retryResponse.data; + } catch (retryError) { + // If retry fails, log and throw the original error + await this.logRequestResponse(url, data, { + error: axios.isAxiosError(retryError) ? retryError.response?.data || retryError.message : retryError + }); + throw error; + } + } + + await this.logRequestResponse(url, data, { + error: errorData || error.message + }); + throw error; + } + throw error; + } + } + + async authenticate() { + const formData = new URLSearchParams(); + formData.append('grant_type', 'client_credentials'); + formData.append('client_id', this.config.clientId); + formData.append('client_secret', this.config.clientSecret); + formData.append('audience', this.config.audience); + formData.append('scope', this.config.scope); + + try { + const response = await this.makePostRequest( + this.config.authUrl, + formData.toString(), + { + 'Content-Type': 'application/x-www-form-urlencoded', + } + ); + + this.bearerToken = response.access_token; + return this.bearerToken; + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(`Authentication failed: ${error.response?.data || error.message}`); + } + throw error; + } + } + + async getHeaders() { + if (!this.bearerToken) { + await this.authenticate(); + } + + return { + 'Authorization': `Bearer ${this.bearerToken}`, + 'Content-Type': 'application/json', + }; + } + + async createCommand(params) { + const command = { + commands: [{ + CreateCommand: { + templateId: params.templateId, + createArguments: params.createArguments, + }, + }], + commandId: this.sequenceNumber.toString(), + actAs: params.actAs, + }; + + this.sequenceNumber++; + + try { + const headers = await this.getHeaders(); + const response = await this.makePostRequest( + `${this.config.ledgerUrl}/commands/submit-and-wait-for-transaction-tree`, + command, + headers + ); + + return { + contractId: response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':0'].CreatedTreeEvent.value.contractId, + updateId: response.transactionTree.updateId + }; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorData = error.response?.data ? JSON.stringify(error.response.data, null, 2) : error.message; + throw new Error(`Failed to create command: ${errorData}`); + } + throw error; + } + } + + async exerciseCommand(params) { + const command = { + commands: [{ + ExerciseCommand: { + templateId: params.templateId, + contractId: params.contractId, + choice: params.choice, + choiceArgument: params.choiceArgument + } + }], + commandId: this.sequenceNumber.toString(), + actAs: params.actAs + }; + + this.sequenceNumber++; + + try { + const headers = await this.getHeaders(); + const response = await this.makePostRequest( + `${this.config.ledgerUrl}/commands/submit-and-wait-for-transaction-tree`, + command, + headers + ); + return response; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorData = error.response?.data ? JSON.stringify(error.response.data, null, 2) : error.message; + throw new Error(`Failed to exercise command: ${errorData}`); + } + throw error; + } + } + + async createParty(partyIdHint) { + try { + const headers = await this.getHeaders(); + const response = await this.makePostRequest( + `${this.config.ledgerUrl}/parties`, + { + partyIdHint: `FM:${partyIdHint}`, + displayName: partyIdHint, + identityProviderId: "" + }, + headers + ); + + const partyId = response.partyDetails.party; + + // Set user rights for the newly created party + await this.setUserRights(partyId); + + return {partyId, isNewParty: true}; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorData = error.response?.data; + // Check if this is a "party already exists" error + if (errorData?.cause?.includes('Party already exists')) { + // Look up the party ID from the ledger + const parties = await this.getParties(); + const existingParty = parties.partyDetails.find(p => p.party.startsWith(`FM:${partyIdHint}`)); + if (existingParty) { + // Set user rights for the newly created party + await this.setUserRights(existingParty.party); + + return { partyId: existingParty.party, isNewParty: false }; + } + } + const errorMessage = errorData ? JSON.stringify(errorData, null, 2) : error.message; + throw new Error(`Failed to create party: ${errorMessage}`); + } + throw error; + } + } + + async setUserRights(partyId) { + const headers = await this.getHeaders(); + await this.makePostRequest( + `${this.config.ledgerUrl}/users/${this.config.fairmintUserId}/rights`, + { + userId: this.config.fairmintUserId, + rights: [ + { + type: "CanActAs", + party: partyId + }, + { + type: "CanReadAs", + party: partyId + } + ] + }, + headers + ); + } + + async getParties() { + const headers = await this.getHeaders(); + return await this.makePostRequest( + `${this.config.ledgerUrl}/parties`, + {}, + headers + ); + } + + async getEventsByContractId(contractId) { + const headers = await this.getHeaders(); + return await this.makePostRequest( + `${this.config.ledgerUrl}/events/contract/${contractId}`, + {}, + headers + ); + } + + async getTransactionTreeByOffset(offset) { + const headers = await this.getHeaders(); + return await this.makePostRequest( + `${this.config.ledgerUrl}/transactions/tree/${offset}`, + {}, + headers + ); + } +} \ No newline at end of file diff --git a/src/chain-operations/canton/lib/config.js b/src/chain-operations/canton/lib/config.js new file mode 100644 index 00000000..5b7bdabb --- /dev/null +++ b/src/chain-operations/canton/lib/config.js @@ -0,0 +1,35 @@ +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +export class TransferAgentConfig { + constructor(isMainnet = false) { + this.authUrl = 'https://auth.transfer-agent.xyz/application/o/token/'; + this.scope = 'daml_ledger_apia'; + + if (isMainnet) { + this.ledgerUrl = 'https://ledger-api.validator.transfer-agent.xyz/v2'; + this.clientId = this.audience = 'validator-mainnet-m2m'; + this.clientSecret = process.env.TRANSFER_AGENT_MAINNET_CLIENT_SECRET || ''; + this.fairmintPartyId = process.env.FAIRMINT_MAINNET_PARTY_ID || ''; + this.fairmintUserId = process.env.FAIRMINT_MAINNET_USER_ID || ''; + } else { + this.ledgerUrl = 'https://ledger-api.validator.devnet.transfer-agent.xyz/v2'; + this.clientId = this.audience = 'validator-devnet-m2m'; + this.clientSecret = process.env.TRANSFER_AGENT_DEVNET_CLIENT_SECRET || ''; + this.fairmintPartyId = process.env.FAIRMINT_DEVNET_PARTY_ID || ''; + this.fairmintUserId = process.env.FAIRMINT_DEVNET_USER_ID || ''; + } + + if (!this.clientSecret) { + throw new Error(`${isMainnet ? 'TRANSFER_AGENT_MAINNET_CLIENT_SECRET' : 'TRANSFER_AGENT_DEVNET_CLIENT_SECRET'} environment variable is not set`); + } + if (!this.fairmintPartyId) { + throw new Error(`${isMainnet ? 'FAIRMINT_MAINNET_PARTY_ID' : 'FAIRMINT_DEVNET_PARTY_ID'} environment variable is not set`); + } + if (!this.fairmintUserId) { + throw new Error(`${isMainnet ? 'FAIRMINT_MAINNET_USER_ID' : 'FAIRMINT_DEVNET_USER_ID'} environment variable is not set`); + } + } +} \ No newline at end of file diff --git a/src/chain-operations/canton/lib/fairmint-canton b/src/chain-operations/canton/lib/fairmint-canton deleted file mode 160000 index 9be91106..00000000 --- a/src/chain-operations/canton/lib/fairmint-canton +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9be91106195c491b5bec8e1c331c37e70a18213a diff --git a/src/chain-operations/canton/lib/fairmintClient.js b/src/chain-operations/canton/lib/fairmintClient.js new file mode 100644 index 00000000..66e03fa2 --- /dev/null +++ b/src/chain-operations/canton/lib/fairmintClient.js @@ -0,0 +1,153 @@ +import { TransferAgentClient } from './client.js'; +import { TransferAgentConfig } from './config.js'; + +// Application specific constants +const TEMPLATES = { + FAIRMINT_ADMIN_SERVICE: '#OpenCapTable-v00:FairmintAdminService:FairmintAdminService' +}; + +export class FairmintClient { + constructor(config) { + this.client = new TransferAgentClient(config); + } + + async createFairmintAdminService() { + const response = await this.client.createCommand({ + templateId: TEMPLATES.FAIRMINT_ADMIN_SERVICE, + createArguments: { + fairmint: this.client.getFairmintPartyId(), + }, + actAs: [this.client.getFairmintPartyId()], + }); + console.debug(`Created FairmintAdminService with contract ID: ${response.contractId}`); + return response; + } + + async authorizeIssuer(contractId, issuerPartyId) { + const response = await this.client.exerciseCommand({ + templateId: TEMPLATES.FAIRMINT_ADMIN_SERVICE, + contractId, + choice: 'AuthorizeIssuer', + choiceArgument: { + issuer: issuerPartyId + }, + actAs: [this.client.getFairmintPartyId()] + }); + + // Extract the IssuerAuthorization contract ID from the response + const authorizationContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':0'].ExercisedTreeEvent.exerciseResult; + console.debug(`Successfully authorized issuer with contract ID: ${authorizationContractId}`); + return authorizationContractId; + } + + async createParty(partyIdHint) { + const response = await this.client.createParty(partyIdHint); + console.debug(`${response.isNewParty ? 'Created' : 'Reused'} party for ${partyIdHint} with ID: ${response.partyId}`); + return response; + } + + async acceptIssuerAuthorization(authorizationContractId, name, authorizedShares, issuerPartyId) { + const response = await this.client.exerciseCommand({ + templateId: '#OpenCapTable-v00:IssuerAuthorization:IssuerAuthorization', + contractId: authorizationContractId, + choice: 'CreateIssuer', + choiceArgument: { + name, + authorizedShares: authorizedShares.toString() + }, + actAs: [issuerPartyId] + }); + + // Extract the Issuer contract ID from the response + const issuerContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':1'].CreatedTreeEvent.value.contractId; + console.debug(`Successfully created issuer with contract ID: ${issuerContractId}`); + return issuerContractId; + } + + async createStockClass(issuerContractId, stockClassType, shares, issuerPartyId) { + const response = await this.client.exerciseCommand({ + templateId: '#OpenCapTable-v00:Issuer:Issuer', + contractId: issuerContractId, + choice: 'CreateStockClass', + choiceArgument: { + stockClassType, + shares: shares.toString() + }, + actAs: [issuerPartyId] + }); + + // Extract both the StockClass and updated Issuer contract IDs from the response + const stockClassContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':2'].CreatedTreeEvent.value.contractId; + const updatedIssuerContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':1'].CreatedTreeEvent.value.contractId; + console.debug(`Created stock class with contract ID: ${stockClassContractId}`); + return { stockClassContractId, updatedIssuerContractId }; + } + + async proposeIssueStock(stockClassContractId, recipientPartyId, quantity, issuerPartyId) { + const response = await this.client.exerciseCommand({ + templateId: '#OpenCapTable-v00:StockClass:StockClass', + contractId: stockClassContractId, + choice: 'ProposeIssueStock', + choiceArgument: { + recipient: recipientPartyId, + quantity: quantity.toString() + }, + actAs: [issuerPartyId] + }); + + // Extract both the IssueStockClassProposal and updated StockClass contract IDs from the response + const proposalContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':1'].CreatedTreeEvent.value.contractId; + const updatedStockClassContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':2'].CreatedTreeEvent.value.contractId; + console.debug(`Proposed stock issuance to ${recipientPartyId} with proposal ID: ${proposalContractId}`); + return { proposalContractId, updatedStockClassContractId }; + } + + async acceptIssueStockProposal(proposalContractId, recipientPartyId) { + const response = await this.client.exerciseCommand({ + templateId: '#OpenCapTable-v00:StockClass:IssueStockClassProposal', + contractId: proposalContractId, + choice: 'AcceptIssueStockProposal', + choiceArgument: {}, + actAs: [recipientPartyId] + }); + + // Extract the StockPosition contract ID from the response + const stockPositionContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':1'].CreatedTreeEvent.value.contractId; + console.debug(`${recipientPartyId} accepted stock issuance and received position with ID: ${stockPositionContractId}`); + return stockPositionContractId; + } + + async proposeTransfer(stockPositionContractId, recipientPartyId, quantity, ownerPartyId) { + const response = await this.client.exerciseCommand({ + templateId: '#OpenCapTable-v00:StockPosition:StockPosition', + contractId: stockPositionContractId, + choice: 'ProposeTransfer', + choiceArgument: { + recipient: recipientPartyId, + quantityToTransfer: quantity.toString() + }, + actAs: [ownerPartyId] + }); + + // Extract both the TransferProposal and updated StockPosition contract IDs from the response + const transferProposalContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':1'].CreatedTreeEvent.value.contractId; + const updatedStockPositionContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':2'].CreatedTreeEvent.value.contractId; + console.debug(`${ownerPartyId} proposed transfer to ${recipientPartyId} with proposal ID: ${transferProposalContractId}`); + return { transferProposalContractId, updatedStockPositionContractId }; + } + + async acceptTransfer(transferProposalContractId, recipientPartyId) { + const response = await this.client.exerciseCommand({ + templateId: '#OpenCapTable-v00:StockPosition:StockTransferProposal', + contractId: transferProposalContractId, + choice: 'AcceptTransfer', + choiceArgument: {}, + actAs: [recipientPartyId] + }); + + // Extract the new StockPosition contract ID from the response + const stockPositionContractId = response.transactionTree.eventsById['#' + response.transactionTree.updateId + ':1'].CreatedTreeEvent.value.contractId; + console.debug(`${recipientPartyId} accepted transfer and received position with ID: ${stockPositionContractId}`); + return stockPositionContractId; + } +} \ No newline at end of file diff --git a/src/chain-operations/canton/lib/types.js b/src/chain-operations/canton/lib/types.js new file mode 100644 index 00000000..418508ff --- /dev/null +++ b/src/chain-operations/canton/lib/types.js @@ -0,0 +1,58 @@ +// Note: This file is kept for documentation purposes only +// The types are not used in JavaScript but are documented here for reference + +/** + * @typedef {Object} AuthResponse + * @property {string} access_token + */ + +/** + * @typedef {Object} CreateCommand + * @property {Object} CreateCommand + * @property {string} CreateCommand.templateId + * @property {Object} CreateCommand.createArguments + */ + +/** + * @typedef {Object} ExerciseCommand + * @property {Object} ExerciseCommand + * @property {string} ExerciseCommand.templateId + * @property {string} ExerciseCommand.contractId + * @property {string} ExerciseCommand.choice + * @property {Object} ExerciseCommand.choiceArgument + */ + +/** + * @typedef {CreateCommand|ExerciseCommand} Command + */ + +/** + * @typedef {Object} CommandRequest + * @property {Command[]} commands + * @property {string} commandId + * @property {string[]} actAs + */ + +/** + * @typedef {Object} CreatedTreeEvent + * @property {Object} CreatedTreeEvent + * @property {Object} CreatedTreeEvent.value + * @property {string} CreatedTreeEvent.value.contractId + */ + +/** + * @typedef {Object} TransactionTree + * @property {string} updateId + * @property {Object.} eventsById + */ + +/** + * @typedef {Object} CommandResponse + * @property {TransactionTree} transactionTree + */ + +/** + * @typedef {Object} CreateContractResponse + * @property {string} contractId + * @property {string} updateId + */ \ No newline at end of file