Skip to content

Commit d13efcd

Browse files
Merge pull request #9236 from BitGo/CHALO-941
feat: add canton end investor offer invite builder
2 parents 35edadb + 1cc6827 commit d13efcd

8 files changed

Lines changed: 336 additions & 1 deletion

File tree

modules/sdk-coin-canton/src/canton.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export class Canton extends BaseCoin {
133133
case TransactionType.CosignDelegationProposal:
134134
case TransactionType.AllocationAllocate:
135135
case TransactionType.AllocationRequest:
136+
case TransactionType.EndInvestorOnboardingOffer:
136137
// There is no recipient info to verify for these transaction types, so always return true.
137138
return true;
138139
case TransactionType.CantonCommand:
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { PublicKey, TransactionType } from '@bitgo/sdk-core';
2+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3+
import { CantonPrepareCommandResponse, EndInvestorOnboardingOfferData } from './iface';
4+
import { TransactionBuilder } from './transactionBuilder';
5+
import { Transaction } from './transaction/transaction';
6+
7+
/**
8+
* Builder for an EndInvestorOnboardingOffer txRequest — an internal, non-signable transaction
9+
* that records an on-chain EndInvestorOnboardingOffer contract on the end investor's wallet
10+
* so they can accept or reject. Built locally without an IMS call because the inviting
11+
* participant may be external (non-BitGo) and therefore not registered in BitGo's IMS.
12+
*
13+
* setTransaction and addSignature are intentionally not implemented because this
14+
* transaction type is never signed or broadcast directly.
15+
*/
16+
export class EndInvestorOnboardingOfferBuilder extends TransactionBuilder {
17+
private _contractId: string;
18+
private _endInvestorPartyId: string;
19+
private _participantPartyId: string;
20+
private _comment?: string;
21+
22+
constructor(_coinConfig: Readonly<CoinConfig>) {
23+
super(_coinConfig);
24+
}
25+
26+
initBuilder(tx: Transaction): void {
27+
super.initBuilder(tx);
28+
this.setTransactionType();
29+
}
30+
31+
get transactionType(): TransactionType {
32+
return TransactionType.EndInvestorOnboardingOffer;
33+
}
34+
35+
setTransactionType(): void {
36+
this.transaction.transactionType = TransactionType.EndInvestorOnboardingOffer;
37+
}
38+
39+
setTransaction(_transaction: CantonPrepareCommandResponse): void {
40+
throw new Error('Not implemented!');
41+
}
42+
43+
/** @inheritDoc */
44+
addSignature(_publicKey: PublicKey, _signature: Buffer): void {
45+
throw new Error('Not implemented!');
46+
}
47+
48+
/**
49+
* Sets the on-chain contractId of the EndInvestorOnboardingOffer.
50+
* Also sets the transaction id.
51+
* @param id - contract id (from pendingContractId)
52+
*/
53+
contractId(id: string): this {
54+
if (!id || !id.trim()) {
55+
throw new Error('contractId must be a non-empty string');
56+
}
57+
this._contractId = id.trim();
58+
this.transaction.id = id.trim();
59+
return this;
60+
}
61+
62+
/**
63+
* Sets the Canton party ID of the end investor being onboarded.
64+
* @param id - end investor party id
65+
*/
66+
endInvestorPartyId(id: string): this {
67+
if (!id || !id.trim()) {
68+
throw new Error('endInvestorPartyId must be a non-empty string');
69+
}
70+
this._endInvestorPartyId = id.trim();
71+
return this;
72+
}
73+
74+
/**
75+
* Sets the Canton party ID of the participant that created the offer.
76+
* @param id - participant party id (may be external to BitGo)
77+
*/
78+
participantPartyId(id: string): this {
79+
if (!id || !id.trim()) {
80+
throw new Error('participantPartyId must be a non-empty string');
81+
}
82+
this._participantPartyId = id.trim();
83+
return this;
84+
}
85+
86+
/**
87+
* Sets an optional free-form comment from the participant.
88+
* @param comment - comment string
89+
*/
90+
comment(comment: string): this {
91+
this._comment = comment;
92+
return this;
93+
}
94+
95+
/**
96+
* Builds and returns the EndInvestorOnboardingOfferData object from the builder's internal state.
97+
*
98+
* @returns {EndInvestorOnboardingOfferData} - A fully constructed and validated data object.
99+
* @throws {Error} If any required field is missing.
100+
*/
101+
toRequestObject(): EndInvestorOnboardingOfferData {
102+
this.validate();
103+
const result: EndInvestorOnboardingOfferData = {
104+
contractId: this._contractId,
105+
endInvestorPartyId: this._endInvestorPartyId,
106+
participantPartyId: this._participantPartyId,
107+
};
108+
if (this._comment !== undefined) {
109+
result.comment = this._comment;
110+
}
111+
return result;
112+
}
113+
114+
protected async buildImplementation(): Promise<Transaction> {
115+
this.validate();
116+
this.transaction.endInvestorOnboardingOfferData = this.toRequestObject();
117+
return this.transaction;
118+
}
119+
120+
private validate(): void {
121+
if (!this._contractId) throw new Error('contractId is missing');
122+
if (!this._endInvestorPartyId) throw new Error('endInvestorPartyId is missing');
123+
if (!this._participantPartyId) throw new Error('participantPartyId is missing');
124+
}
125+
}

modules/sdk-coin-canton/src/lib/iface.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface TxData {
3434
acknowledgeData?: TransferAcknowledge;
3535
cosignDelegationProposalData?: CosignDelegationProposal;
3636
allocationRequestData?: AllocationRequest;
37+
endInvestorOnboardingOfferData?: EndInvestorOnboardingOfferData;
3738
memoId?: string;
3839
token?: string;
3940
cantonCommand?: CantonCommandExplain;
@@ -128,6 +129,7 @@ export interface TransactionBroadcastData {
128129
acknowledgeData?: TransferAcknowledge;
129130
cosignDelegationProposalData?: CosignDelegationProposal;
130131
allocationRequestData?: AllocationRequest;
132+
endInvestorOnboardingOfferData?: EndInvestorOnboardingOfferData;
131133
prepareCommandResponse?: CantonPrepareCommandResponse;
132134
txType: string;
133135
preparedTransaction?: string;
@@ -226,6 +228,23 @@ export interface AllocationRequest {
226228
comment?: string;
227229
}
228230

231+
/**
232+
* Internal (non-signable) data for an EndInvestorOnboardingOffer txRequest.
233+
* Records an on-chain EndInvestorOnboardingOffer contract on the end investor's wallet
234+
* so they can accept or reject. Built locally without an IMS call — the participant
235+
* party may be external (non-BitGo) and therefore not registered in BitGo's IMS.
236+
*/
237+
export interface EndInvestorOnboardingOfferData {
238+
/** The on-chain contractId of the EndInvestorOnboardingOffer (from pendingContractId). */
239+
contractId: string;
240+
/** Canton party ID of the end investor being onboarded. */
241+
endInvestorPartyId: string;
242+
/** Canton party ID of the participant that created the offer (may be external to BitGo). */
243+
participantPartyId: string;
244+
/** Optional comment from the participant. */
245+
comment?: string;
246+
}
247+
229248
export const CANTON_COMMAND_KEYS = ['CreateCommand', 'ExerciseCommand'] as const;
230249
export type CantonCommandKind = (typeof CANTON_COMMAND_KEYS)[number];
231250

modules/sdk-coin-canton/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { AllocationAllocateBuilder } from './allocationAllocateBuilder';
66
export { AllocationAllocateWithdrawnBuilder } from './allocationAllocateWithdrawnBuilder';
77
export { AllocationRejectBuilder } from './allocationRejectBuilder';
88
export { AllocationRequestBuilder } from './allocationRequestBuilder';
9+
export { EndInvestorOnboardingOfferBuilder } from './endInvestorOnboardingOfferBuilder';
910
export { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder';
1011
export { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder';
1112
export { KeyPair } from './keyPair';

modules/sdk-coin-canton/src/lib/transaction/transaction.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
CantonCommandExplain,
1313
CantonPrepareCommandResponse,
1414
CosignDelegationProposal,
15+
EndInvestorOnboardingOfferData,
1516
MultiHashSignature,
1617
PartySignature,
1718
PreparedTxnParsedInfo,
@@ -29,6 +30,7 @@ export class Transaction extends BaseTransaction {
2930
private _acknowledgeData: TransferAcknowledge;
3031
private _cosignDelegationProposalData: CosignDelegationProposal;
3132
private _allocationRequestData: AllocationRequest;
33+
private _endInvestorOnboardingOfferData: EndInvestorOnboardingOfferData;
3234
private _cantonCommandActAs: string[];
3335

3436
constructor(coinConfig: Readonly<CoinConfig>) {
@@ -59,6 +61,10 @@ export class Transaction extends BaseTransaction {
5961
this._allocationRequestData = data;
6062
}
6163

64+
set endInvestorOnboardingOfferData(data: EndInvestorOnboardingOfferData) {
65+
this._endInvestorOnboardingOfferData = data;
66+
}
67+
6268
set cantonCommandActAs(parties: string[]) {
6369
this._cantonCommandActAs = parties;
6470
}
@@ -123,6 +129,17 @@ export class Transaction extends BaseTransaction {
123129
};
124130
return Buffer.from(JSON.stringify(minData)).toString('base64');
125131
}
132+
if (this._type === TransactionType.EndInvestorOnboardingOffer) {
133+
if (!this._endInvestorOnboardingOfferData) {
134+
throw new InvalidTransactionError('EndInvestorOnboardingOfferData is not set');
135+
}
136+
const minData: TransactionBroadcastData = {
137+
txType: TransactionType[this._type],
138+
submissionId: this.id,
139+
endInvestorOnboardingOfferData: this._endInvestorOnboardingOfferData,
140+
};
141+
return Buffer.from(JSON.stringify(minData)).toString('base64');
142+
}
126143
if (!this._prepareCommand) {
127144
throw new InvalidTransactionError('Empty transaction data');
128145
}
@@ -206,6 +223,13 @@ export class Transaction extends BaseTransaction {
206223
result.allocationRequestData = this._allocationRequestData;
207224
return result;
208225
}
226+
if (this._type === TransactionType.EndInvestorOnboardingOffer) {
227+
if (!this._endInvestorOnboardingOfferData) {
228+
throw new InvalidTransactionError('EndInvestorOnboardingOfferData is not set');
229+
}
230+
result.endInvestorOnboardingOfferData = this._endInvestorOnboardingOfferData;
231+
return result;
232+
}
209233
if (this._type === TransactionType.CantonCommand) {
210234
if (!this._prepareCommand?.preparedTransaction) {
211235
throw new InvalidTransactionError('Empty transaction data');
@@ -242,7 +266,8 @@ export class Transaction extends BaseTransaction {
242266
if (
243267
this._type === TransactionType.TransferAcknowledge ||
244268
this._type === TransactionType.CosignDelegationProposal ||
245-
this._type === TransactionType.AllocationRequest
269+
this._type === TransactionType.AllocationRequest ||
270+
this._type === TransactionType.EndInvestorOnboardingOffer
246271
) {
247272
return Buffer.from(DUMMY_HASH, 'base64');
248273
}
@@ -273,6 +298,10 @@ export class Transaction extends BaseTransaction {
273298
if (decoded.allocationRequestData) {
274299
this.allocationRequestData = decoded.allocationRequestData;
275300
}
301+
} else if (this.type === TransactionType.EndInvestorOnboardingOffer) {
302+
if (decoded.endInvestorOnboardingOfferData) {
303+
this.endInvestorOnboardingOfferData = decoded.endInvestorOnboardingOfferData;
304+
}
276305
} else {
277306
if (decoded.prepareCommandResponse) {
278307
this.prepareCommand = decoded.prepareCommandResponse;
@@ -398,6 +427,21 @@ export class Transaction extends BaseTransaction {
398427
}
399428
return explanation;
400429
}
430+
case TransactionType.EndInvestorOnboardingOffer: {
431+
// Non-signable notification record — no inputs, outputs, or amounts to explain.
432+
return {
433+
id: this.id,
434+
displayOrder,
435+
outputs: [],
436+
outputAmount: '0',
437+
inputs: [],
438+
inputAmount: '0',
439+
changeOutputs: [],
440+
changeAmount: '0',
441+
fee: { fee: '0' },
442+
type: this.type,
443+
};
444+
}
401445
}
402446
return {
403447
id: this.id,

modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AllocationAllocateWithdrawnBuilder } from './allocationAllocateWithdraw
1010
import { AllocationRejectBuilder } from './allocationRejectBuilder';
1111
import { AllocationRequestBuilder } from './allocationRequestBuilder';
1212
import { CantonCommandBuilder } from './cantonCommandBuilder';
13+
import { EndInvestorOnboardingOfferBuilder } from './endInvestorOnboardingOfferBuilder';
1314
import { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder';
1415
import { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder';
1516
import { OneStepPreApprovalBuilder } from './oneStepPreApprovalBuilder';
@@ -76,6 +77,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
7677
case TransactionType.CantonCommand: {
7778
return this.getCantonCommandBuilder(tx);
7879
}
80+
case TransactionType.EndInvestorOnboardingOffer: {
81+
return this.getEndInvestorOnboardingOfferBuilder(tx);
82+
}
7983
default: {
8084
throw new InvalidTransactionError('unsupported transaction');
8185
}
@@ -103,6 +107,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
103107
return TransactionBuilderFactory.initializeBuilder(tx, new CantonCommandBuilder(this._coinConfig));
104108
}
105109

110+
getEndInvestorOnboardingOfferBuilder(tx?: Transaction): EndInvestorOnboardingOfferBuilder {
111+
return TransactionBuilderFactory.initializeBuilder(tx, new EndInvestorOnboardingOfferBuilder(this._coinConfig));
112+
}
113+
106114
getOneStepPreapprovalBuilder(tx?: Transaction): OneStepPreApprovalBuilder {
107115
return TransactionBuilderFactory.initializeBuilder(tx, new OneStepPreApprovalBuilder(this._coinConfig));
108116
}

0 commit comments

Comments
 (0)