diff --git a/modules/sdk-coin-canton/src/canton.ts b/modules/sdk-coin-canton/src/canton.ts index e5ca997604..f414dd42b5 100644 --- a/modules/sdk-coin-canton/src/canton.ts +++ b/modules/sdk-coin-canton/src/canton.ts @@ -133,6 +133,7 @@ export class Canton extends BaseCoin { case TransactionType.CosignDelegationProposal: case TransactionType.AllocationAllocate: case TransactionType.AllocationRequest: + case TransactionType.EndInvestorOnboardingOffer: // There is no recipient info to verify for these transaction types, so always return true. return true; case TransactionType.CantonCommand: diff --git a/modules/sdk-coin-canton/src/lib/endInvestorOnboardingOfferBuilder.ts b/modules/sdk-coin-canton/src/lib/endInvestorOnboardingOfferBuilder.ts new file mode 100644 index 0000000000..3b067c22d0 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/endInvestorOnboardingOfferBuilder.ts @@ -0,0 +1,125 @@ +import { PublicKey, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { CantonPrepareCommandResponse, EndInvestorOnboardingOfferData } from './iface'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction/transaction'; + +/** + * Builder for an EndInvestorOnboardingOffer txRequest — an internal, non-signable transaction + * that records an on-chain EndInvestorOnboardingOffer contract on the end investor's wallet + * so they can accept or reject. Built locally without an IMS call because the inviting + * participant may be external (non-BitGo) and therefore not registered in BitGo's IMS. + * + * setTransaction and addSignature are intentionally not implemented because this + * transaction type is never signed or broadcast directly. + */ +export class EndInvestorOnboardingOfferBuilder extends TransactionBuilder { + private _contractId: string; + private _endInvestorPartyId: string; + private _participantPartyId: string; + private _comment?: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + this.setTransactionType(); + } + + get transactionType(): TransactionType { + return TransactionType.EndInvestorOnboardingOffer; + } + + setTransactionType(): void { + this.transaction.transactionType = TransactionType.EndInvestorOnboardingOffer; + } + + setTransaction(_transaction: CantonPrepareCommandResponse): void { + throw new Error('Not implemented!'); + } + + /** @inheritDoc */ + addSignature(_publicKey: PublicKey, _signature: Buffer): void { + throw new Error('Not implemented!'); + } + + /** + * Sets the on-chain contractId of the EndInvestorOnboardingOffer. + * Also sets the transaction id. + * @param id - contract id (from pendingContractId) + */ + contractId(id: string): this { + if (!id || !id.trim()) { + throw new Error('contractId must be a non-empty string'); + } + this._contractId = id.trim(); + this.transaction.id = id.trim(); + return this; + } + + /** + * Sets the Canton party ID of the end investor being onboarded. + * @param id - end investor party id + */ + endInvestorPartyId(id: string): this { + if (!id || !id.trim()) { + throw new Error('endInvestorPartyId must be a non-empty string'); + } + this._endInvestorPartyId = id.trim(); + return this; + } + + /** + * Sets the Canton party ID of the participant that created the offer. + * @param id - participant party id (may be external to BitGo) + */ + participantPartyId(id: string): this { + if (!id || !id.trim()) { + throw new Error('participantPartyId must be a non-empty string'); + } + this._participantPartyId = id.trim(); + return this; + } + + /** + * Sets an optional free-form comment from the participant. + * @param comment - comment string + */ + comment(comment: string): this { + this._comment = comment; + return this; + } + + /** + * Builds and returns the EndInvestorOnboardingOfferData object from the builder's internal state. + * + * @returns {EndInvestorOnboardingOfferData} - A fully constructed and validated data object. + * @throws {Error} If any required field is missing. + */ + toRequestObject(): EndInvestorOnboardingOfferData { + this.validate(); + const result: EndInvestorOnboardingOfferData = { + contractId: this._contractId, + endInvestorPartyId: this._endInvestorPartyId, + participantPartyId: this._participantPartyId, + }; + if (this._comment !== undefined) { + result.comment = this._comment; + } + return result; + } + + protected async buildImplementation(): Promise { + this.validate(); + this.transaction.endInvestorOnboardingOfferData = this.toRequestObject(); + return this.transaction; + } + + private validate(): void { + if (!this._contractId) throw new Error('contractId is missing'); + if (!this._endInvestorPartyId) throw new Error('endInvestorPartyId is missing'); + if (!this._participantPartyId) throw new Error('participantPartyId is missing'); + } +} diff --git a/modules/sdk-coin-canton/src/lib/iface.ts b/modules/sdk-coin-canton/src/lib/iface.ts index ee1871bfbb..6df9b6b393 100644 --- a/modules/sdk-coin-canton/src/lib/iface.ts +++ b/modules/sdk-coin-canton/src/lib/iface.ts @@ -34,6 +34,7 @@ export interface TxData { acknowledgeData?: TransferAcknowledge; cosignDelegationProposalData?: CosignDelegationProposal; allocationRequestData?: AllocationRequest; + endInvestorOnboardingOfferData?: EndInvestorOnboardingOfferData; memoId?: string; token?: string; cantonCommand?: CantonCommandExplain; @@ -128,6 +129,7 @@ export interface TransactionBroadcastData { acknowledgeData?: TransferAcknowledge; cosignDelegationProposalData?: CosignDelegationProposal; allocationRequestData?: AllocationRequest; + endInvestorOnboardingOfferData?: EndInvestorOnboardingOfferData; prepareCommandResponse?: CantonPrepareCommandResponse; txType: string; preparedTransaction?: string; @@ -226,6 +228,23 @@ export interface AllocationRequest { comment?: string; } +/** + * Internal (non-signable) data for an EndInvestorOnboardingOffer txRequest. + * Records an on-chain EndInvestorOnboardingOffer contract on the end investor's wallet + * so they can accept or reject. Built locally without an IMS call — the participant + * party may be external (non-BitGo) and therefore not registered in BitGo's IMS. + */ +export interface EndInvestorOnboardingOfferData { + /** The on-chain contractId of the EndInvestorOnboardingOffer (from pendingContractId). */ + contractId: string; + /** Canton party ID of the end investor being onboarded. */ + endInvestorPartyId: string; + /** Canton party ID of the participant that created the offer (may be external to BitGo). */ + participantPartyId: string; + /** Optional comment from the participant. */ + comment?: string; +} + export const CANTON_COMMAND_KEYS = ['CreateCommand', 'ExerciseCommand'] as const; export type CantonCommandKind = (typeof CANTON_COMMAND_KEYS)[number]; diff --git a/modules/sdk-coin-canton/src/lib/index.ts b/modules/sdk-coin-canton/src/lib/index.ts index bc5994865d..b824b600e4 100644 --- a/modules/sdk-coin-canton/src/lib/index.ts +++ b/modules/sdk-coin-canton/src/lib/index.ts @@ -6,6 +6,7 @@ export { AllocationAllocateBuilder } from './allocationAllocateBuilder'; export { AllocationAllocateWithdrawnBuilder } from './allocationAllocateWithdrawnBuilder'; export { AllocationRejectBuilder } from './allocationRejectBuilder'; export { AllocationRequestBuilder } from './allocationRequestBuilder'; +export { EndInvestorOnboardingOfferBuilder } from './endInvestorOnboardingOfferBuilder'; export { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder'; export { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder'; export { KeyPair } from './keyPair'; diff --git a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts index 0a188deb16..cbf9cf2955 100644 --- a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts @@ -12,6 +12,7 @@ import { CantonCommandExplain, CantonPrepareCommandResponse, CosignDelegationProposal, + EndInvestorOnboardingOfferData, MultiHashSignature, PartySignature, PreparedTxnParsedInfo, @@ -29,6 +30,7 @@ export class Transaction extends BaseTransaction { private _acknowledgeData: TransferAcknowledge; private _cosignDelegationProposalData: CosignDelegationProposal; private _allocationRequestData: AllocationRequest; + private _endInvestorOnboardingOfferData: EndInvestorOnboardingOfferData; private _cantonCommandActAs: string[]; constructor(coinConfig: Readonly) { @@ -59,6 +61,10 @@ export class Transaction extends BaseTransaction { this._allocationRequestData = data; } + set endInvestorOnboardingOfferData(data: EndInvestorOnboardingOfferData) { + this._endInvestorOnboardingOfferData = data; + } + set cantonCommandActAs(parties: string[]) { this._cantonCommandActAs = parties; } @@ -123,6 +129,17 @@ export class Transaction extends BaseTransaction { }; return Buffer.from(JSON.stringify(minData)).toString('base64'); } + if (this._type === TransactionType.EndInvestorOnboardingOffer) { + if (!this._endInvestorOnboardingOfferData) { + throw new InvalidTransactionError('EndInvestorOnboardingOfferData is not set'); + } + const minData: TransactionBroadcastData = { + txType: TransactionType[this._type], + submissionId: this.id, + endInvestorOnboardingOfferData: this._endInvestorOnboardingOfferData, + }; + return Buffer.from(JSON.stringify(minData)).toString('base64'); + } if (!this._prepareCommand) { throw new InvalidTransactionError('Empty transaction data'); } @@ -206,6 +223,13 @@ export class Transaction extends BaseTransaction { result.allocationRequestData = this._allocationRequestData; return result; } + if (this._type === TransactionType.EndInvestorOnboardingOffer) { + if (!this._endInvestorOnboardingOfferData) { + throw new InvalidTransactionError('EndInvestorOnboardingOfferData is not set'); + } + result.endInvestorOnboardingOfferData = this._endInvestorOnboardingOfferData; + return result; + } if (this._type === TransactionType.CantonCommand) { if (!this._prepareCommand?.preparedTransaction) { throw new InvalidTransactionError('Empty transaction data'); @@ -242,7 +266,8 @@ export class Transaction extends BaseTransaction { if ( this._type === TransactionType.TransferAcknowledge || this._type === TransactionType.CosignDelegationProposal || - this._type === TransactionType.AllocationRequest + this._type === TransactionType.AllocationRequest || + this._type === TransactionType.EndInvestorOnboardingOffer ) { return Buffer.from(DUMMY_HASH, 'base64'); } @@ -273,6 +298,10 @@ export class Transaction extends BaseTransaction { if (decoded.allocationRequestData) { this.allocationRequestData = decoded.allocationRequestData; } + } else if (this.type === TransactionType.EndInvestorOnboardingOffer) { + if (decoded.endInvestorOnboardingOfferData) { + this.endInvestorOnboardingOfferData = decoded.endInvestorOnboardingOfferData; + } } else { if (decoded.prepareCommandResponse) { this.prepareCommand = decoded.prepareCommandResponse; @@ -398,6 +427,21 @@ export class Transaction extends BaseTransaction { } return explanation; } + case TransactionType.EndInvestorOnboardingOffer: { + // Non-signable notification record — no inputs, outputs, or amounts to explain. + return { + id: this.id, + displayOrder, + outputs: [], + outputAmount: '0', + inputs: [], + inputAmount: '0', + changeOutputs: [], + changeAmount: '0', + fee: { fee: '0' }, + type: this.type, + }; + } } return { id: this.id, diff --git a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts index 6dc2eb4b8d..5568d04611 100644 --- a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts @@ -10,6 +10,7 @@ import { AllocationAllocateWithdrawnBuilder } from './allocationAllocateWithdraw import { AllocationRejectBuilder } from './allocationRejectBuilder'; import { AllocationRequestBuilder } from './allocationRequestBuilder'; import { CantonCommandBuilder } from './cantonCommandBuilder'; +import { EndInvestorOnboardingOfferBuilder } from './endInvestorOnboardingOfferBuilder'; import { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder'; import { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder'; import { OneStepPreApprovalBuilder } from './oneStepPreApprovalBuilder'; @@ -76,6 +77,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { case TransactionType.CantonCommand: { return this.getCantonCommandBuilder(tx); } + case TransactionType.EndInvestorOnboardingOffer: { + return this.getEndInvestorOnboardingOfferBuilder(tx); + } default: { throw new InvalidTransactionError('unsupported transaction'); } @@ -103,6 +107,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(tx, new CantonCommandBuilder(this._coinConfig)); } + getEndInvestorOnboardingOfferBuilder(tx?: Transaction): EndInvestorOnboardingOfferBuilder { + return TransactionBuilderFactory.initializeBuilder(tx, new EndInvestorOnboardingOfferBuilder(this._coinConfig)); + } + getOneStepPreapprovalBuilder(tx?: Transaction): OneStepPreApprovalBuilder { return TransactionBuilderFactory.initializeBuilder(tx, new OneStepPreApprovalBuilder(this._coinConfig)); } diff --git a/modules/sdk-coin-canton/test/unit/builder/endInvestorOnboardingOffer/endInvestorOnboardingOfferBuilder.ts b/modules/sdk-coin-canton/test/unit/builder/endInvestorOnboardingOffer/endInvestorOnboardingOfferBuilder.ts new file mode 100644 index 0000000000..cfd95d85db --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/builder/endInvestorOnboardingOffer/endInvestorOnboardingOfferBuilder.ts @@ -0,0 +1,133 @@ +import assert from 'assert'; +import should from 'should'; + +import { coins } from '@bitgo/statics'; + +import { EndInvestorOnboardingOfferBuilder, Transaction } from '../../../../src'; +import { EndInvestorOnboardingOfferData } from '../../../../src/lib/iface'; + +const contractId = + '0020108b7aee0e0215538668a7ebb00e7e811135ff2ebf23137760056e077b9347ca121220dfa25cd905caba01345b515d1002af94b0ba2a9c2e9a6751c07ed28795454a9e'; +const endInvestorPartyId = 'end-investor-1::1220460fe6779731682d05fb151822f20f4a756f1d6fe84f38b8d91172947adbae24'; +const participantPartyId = 'bd::1220460fe6779731682d05fb151822f20f4a756f1d6fe84f38b8d91172947adbae24'; +const comment = 'welcome to DTCC onboarding'; + +// Helper to set all required fields on a builder +function buildWithAllRequired(txBuilder: EndInvestorOnboardingOfferBuilder): EndInvestorOnboardingOfferBuilder { + return txBuilder.contractId(contractId).endInvestorPartyId(endInvestorPartyId).participantPartyId(participantPartyId); +} + +describe('EndInvestorOnboardingOffer Builder', () => { + it('should build the request object with all required fields', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + buildWithAllRequired(txBuilder).comment(comment); + const requestObj: EndInvestorOnboardingOfferData = txBuilder.toRequestObject(); + should.exist(requestObj); + assert.equal(requestObj.contractId, contractId); + assert.equal(requestObj.endInvestorPartyId, endInvestorPartyId); + assert.equal(requestObj.participantPartyId, participantPartyId); + assert.equal(requestObj.comment, comment); + }); + + it('should build the request object without optional comment', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + buildWithAllRequired(txBuilder); + const requestObj: EndInvestorOnboardingOfferData = txBuilder.toRequestObject(); + should.exist(requestObj); + assert.equal(requestObj.comment, undefined); + }); + + it('should set the transaction id to the contractId', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.contractId(contractId); + assert.equal(tx.id, contractId); + }); + + it('should produce a round-trippable serialized transaction', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + buildWithAllRequired(txBuilder).comment(comment); + const requestObj = txBuilder.toRequestObject(); + tx.endInvestorOnboardingOfferData = requestObj; + + const serialized = tx.toBroadcastFormat(); + const tx2 = new Transaction(coins.get('tcanton')); + tx2.fromRawTransaction(serialized); + const tx2Json = tx2.toJson(); + should.exist(tx2Json.endInvestorOnboardingOfferData); + assert.equal(tx2Json.endInvestorOnboardingOfferData!.contractId, contractId); + assert.equal(tx2Json.endInvestorOnboardingOfferData!.endInvestorPartyId, endInvestorPartyId); + assert.equal(tx2Json.endInvestorOnboardingOfferData!.participantPartyId, participantPartyId); + assert.equal(tx2Json.endInvestorOnboardingOfferData!.comment, comment); + }); + + it('should return DUMMY_HASH as the signable payload', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + buildWithAllRequired(txBuilder); + const requestObj = txBuilder.toRequestObject(); + tx.endInvestorOnboardingOfferData = requestObj; + // signablePayload must be non-empty (it returns the DUMMY_HASH buffer) + assert.ok(tx.signablePayload.length > 0); + }); + + // --- missing required field tests --- + + it('should throw if contractId is missing', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + txBuilder.initBuilder(new Transaction(coins.get('tcanton'))); + txBuilder.endInvestorPartyId(endInvestorPartyId).participantPartyId(participantPartyId); + assert.throws(() => txBuilder.toRequestObject(), /contractId is missing/); + }); + + it('should throw if endInvestorPartyId is missing', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + txBuilder.initBuilder(new Transaction(coins.get('tcanton'))); + txBuilder.contractId(contractId).participantPartyId(participantPartyId); + assert.throws(() => txBuilder.toRequestObject(), /endInvestorPartyId is missing/); + }); + + it('should throw if participantPartyId is missing', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + txBuilder.initBuilder(new Transaction(coins.get('tcanton'))); + txBuilder.contractId(contractId).endInvestorPartyId(endInvestorPartyId); + assert.throws(() => txBuilder.toRequestObject(), /participantPartyId is missing/); + }); + + // --- invalid setter argument tests --- + + it('should throw if contractId is an empty string', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.contractId(''), /contractId must be a non-empty string/); + }); + + it('should throw if endInvestorPartyId is an empty string', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.endInvestorPartyId(''), /endInvestorPartyId must be a non-empty string/); + }); + + it('should throw if participantPartyId is an empty string', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.participantPartyId(''), /participantPartyId must be a non-empty string/); + }); + + // --- not-implemented methods --- + + it('should throw on setTransaction', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.setTransaction({} as any), /Not implemented/); + }); + + it('should throw on addSignature', function () { + const txBuilder = new EndInvestorOnboardingOfferBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.addSignature({} as any, Buffer.from('')), /Not implemented/); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 811945cae3..02777ac187 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -111,6 +111,10 @@ export enum TransactionType { AllocationReject, // canton generic DAML command CantonCommand, + // canton end investor onboarding offer (internal/dummy txRequest recording an on-chain + // EndInvestorOnboardingOffer contract on the end investor's wallet so they can accept or reject. + // Never signed or submitted — built locally without an IMS call.) + EndInvestorOnboardingOffer, // trx FREEZE,