import EmbeddedComponent from './components/EmbeddedComponent'; import type { PersonV2 } from './ivms/v2Types'; import RootNotabene, { type NotabeneConfig as RootConfig } from './notabene'; import { type CAIP10, type CallbackOptions, type ConnectionOptions, type ConnectionRequest, COUNTERPARTY_ASSIST_MESSAGES, type Deposit, type DID, type HostMessage, type RefreshSource, type RequestCounterpartyAssistToken, type TransactionOptions, type UUID, type Withdrawal, } from './types'; import { type ConnectionMetadata as RootConnectionMetadata, getRefreshResult as rootGetRefreshResult, } from './utils/connections'; import { isDelegatedToken } from './utils/delegatedToken'; export type { NaturalPersonNameIDV2, NaturalPersonNameV2, NaturalPersonV2, PersonV2, } from './ivms/v2Types'; export { convertNaturalPersonNameToV2 } from './responseTransformer/utils'; export { COUNTERPARTY_ASSIST_MESSAGES, type CounterpartyAssistToken, type RequestCounterpartyAssistToken, } from './types'; export type { CAIP10, CallbackOptions, ConnectionOptions, DID, RefreshSource, TransactionOptions, ValidationError, } from './types'; export type { EmbeddedComponent }; /** * Wallet ownership proof in the v2.1 node's shape * * @remarks * The same body is accepted inline on a transfer, where the address and owner come from the * transfer leg, and by `POST /customers/{customer}/wallets/{address}/proofs`. * - Signature proofs: `signature`, and the signed message as `attestation` * - Screenshot: `attestation` is `{ url }` * - Self-declaration: `attestation` is the declaration text * @public */ export interface WalletProof { type: string; signature?: string; attestation?: string | { url: string }; wallet_provider?: string; xpub?: string; chain_data?: Record; } /** * A party's travel rule data on a v2.1 transfer, in the IVMS-101 2023 shape the node validates * * @remarks * Mirrors the node's `PartyProfile` schema. The node also accepts `ivms_encrypted` and * `ivms_encrypted_fields` here, which components don't send. * @public */ export interface PartyProfile { ivms: PersonV2; } /** * The fields of the v2.1 node's `POST /api/v1/transfers` body that a component fills in * * @remarks * The host posts it unchanged with its own token, adding `ref` (a UUID) and, for a deposit, * `settlement_id`. Amounts are in whole units of the asset. * @public */ export interface CreateTransferFields { asset: string; amount: string; fiat_amount_usd?: string; originator_id?: string; originator?: PartyProfile; originator_entity_did?: DID; originator_proof_of_ownership?: WalletProof; beneficiary_id?: string; beneficiary?: PartyProfile; beneficiary_entity_did?: DID; beneficiary_proof_of_ownership?: WalletProof; source_address?: string; settlement_address?: string; } /** * A wallet the customer connected, returned by Connect on v2.1 * @public */ export interface WalletRecord { address: CAIP10; proof?: WalletProof; } /** * What the host passes to a v2.1 withdrawal component. The token identifies the * customer, so there is no `customer`; every field is optional and the component * asks the user for what is missing. * @public */ export type WithdrawalInput = Partial< Pick< Withdrawal, | 'asset' | 'amountDecimal' | 'destination' | 'assetPrice' | 'agent' | 'counterparty' > >; /** * What the host passes to a v2.1 deposit component * @public */ export type DepositInput = Partial< Pick< Deposit, | 'asset' | 'amountDecimal' | 'source' | 'assetPrice' | 'agent' | 'counterparty' > >; /** * What the host passes to a v2.1 connect component * @public */ export type ConnectionInput = Pick; /** * What a withdrawal or deposit component completes with on v2.1 * @public */ export interface TransferResponse { transfer: CreateTransferFields; } /** * Metadata of a Counterparty Assist connection on v2.1 * @public */ export type ConnectionMetadata = RootConnectionMetadata & { /** * Who acts on the counterparty's result, for a host reading a webhook. `host`: * the result is the `{ transfer }` to submit. `component`: ignore it, the * component that shared the link completes with it. Unset means `component`. * @alpha */ readonly audience?: 'host' | 'component'; /** * The jti of the counterparty-assist token sealed into this link (v2.1). Revoke * it with `POST /api/v1/entities/{entityDID}/delegate-token/{jti}/revoke` once * the connection is completed or closed. * @alpha */ readonly tokenId?: string; }; /** * What a withdrawal or deposit component completes with when the user shares the * transaction with a third-party Counterparty Assist instead of finishing it * * @remarks * The counterparty finishes later. Read their result with {@link getRefreshResult} * on `refreshSource`, or from the webhook. Narrow with `'refreshSource' in result`. * @public */ export type CounterpartyAssistShared = { refreshSource: RefreshSource }; /** * The state of a shared Counterparty Assist link on v2.1, from {@link getRefreshResult} * or the webhook * * @remarks * A completed link shared with `audience: 'host'` carries the `{ transfer }` to submit. * `metadata.tokenId` is the jti of the link's counterparty-assist token. * @public */ export type CounterpartyAssistResult = | { readonly id: UUID; readonly metadata: ConnectionMetadata; readonly status: 'active' | 'closed'; } | { readonly id: UUID; readonly metadata: ConnectionMetadata; readonly status: 'completed'; readonly result: TransferResponse; }; /** * Reads the result of a shared Counterparty Assist link on v2.1 * * @param refreshSource - The `refreshSource` from {@link CounterpartyAssistShared} * @returns The connection result; a completed `host` result carries the `{ transfer }` to submit * @public */ export async function getRefreshResult( refreshSource: RefreshSource, ): Promise { const result = await rootGetRefreshResult(refreshSource); // An active link's `tx` is the component's v1-shaped transaction, not something to act on. if (result.status === 'active') { return { id: result.id, metadata: result.metadata, status: 'active' }; } // On v2.1 the sealed result is the counterparty's `{ transfer }`. return result as unknown as CounterpartyAssistResult; } /** * What the connect component completes with on v2.1 * @public */ export interface WalletResponse { wallet: WalletRecord; } /** * Configuration for the v2.1 Notabene SDK * @public */ export type NotabeneConfig = Omit & { /** * The URL of the Notabene API node */ nodeUrl: string; /** * Called by the SDK to get a token, and again shortly before that token * expires. Must return a delegated token minted by your backend, never M2M * credentials; any other token is rejected. */ fetchToken: () => Promise; /** * Called once per Counterparty Assist link the user shares. Your backend mints a * counterparty-assist token with * `POST /api/v1/entities/{entityDID}/counterparty-assist-token` for the same * `customer_ref` as `fetchToken` (the component rejects another customer's token), * and this returns `{ token: access_token, jti }`. The token must be a delegated * token and differ from the `fetchToken` token, or the share fails. * * @remarks * The component waits up to 30 seconds. The `jti` is exposed as * `ConnectionMetadata.tokenId`; revoke the token with it once the connection is * completed or closed. Without this callback, components don't offer Counterparty * Assist on a v2.1 node. */ fetchCounterpartyAssistToken?: () => Promise<{ token: string; jti: string }>; }; /** * Notabene UX elements for v2.1 hosts * * Authenticates with `fetchToken` instead of a static token, and completes with * the v2.1 node's transfer or wallet body. * * @public */ export class Notabene { private root: RootNotabene; private fetchToken: () => Promise; private fetchCounterpartyAssistToken?: () => Promise<{ token: string; jti: string; }>; constructor({ fetchToken, fetchCounterpartyAssistToken, ...config }: NotabeneConfig) { this.root = new RootNotabene(config); let latestToken: string | undefined; this.fetchToken = async () => { const token = await fetchToken(); if (!isDelegatedToken(token)) { throw new Error( 'fetchToken must return a delegated token (EdDSA, did: kid, delegate: true)', ); } latestToken = token; return token; }; if (fetchCounterpartyAssistToken) { this.fetchCounterpartyAssistToken = async () => { const { token, jti } = await fetchCounterpartyAssistToken(); if (!isDelegatedToken(token)) { throw new Error( 'fetchCounterpartyAssistToken must return a delegated token (EdDSA, did: kid, delegate: true)', ); } if (typeof jti !== 'string' || jti === '') { throw new Error( 'fetchCounterpartyAssistToken must return the jti of the minted token', ); } if (token === latestToken) { throw new Error( 'fetchCounterpartyAssistToken must return a different token than fetchToken: mint it with POST /api/v1/entities/{entityDID}/counterparty-assist-token', ); } return { token, jti }; }; } } private warnIfCounterpartyAssistUnavailable(options?: TransactionOptions) { if (options?.counterpartyAssist && !this.fetchCounterpartyAssistToken) { console.warn( '[notabene] Counterparty Assist is configured but no fetchCounterpartyAssistToken is set, so it will not be offered', ); } } private createComponent( path: string, value: Partial, options?: O, callbacks?: CallbackOptions, ) { let url = this.root.componentUrl(path, value, options, callbacks); if (this.fetchCounterpartyAssistToken) { const flagged = new URL(url); flagged.hash = `${flagged.hash.slice(1)}&canMintCounterpartyAssistToken=true`; url = flagged.toString(); } const component = new EmbeddedComponent( url, value, options, this.fetchToken, ); component.on( COUNTERPARTY_ASSIST_MESSAGES.REQUEST_TOKEN, async (message) => { const { reqid } = message as unknown as RequestCounterpartyAssistToken; let reply: object; try { if (!this.fetchCounterpartyAssistToken) { throw new Error('fetchCounterpartyAssistToken is not configured'); } const { token, jti } = await this.fetchCounterpartyAssistToken(); reply = { type: COUNTERPARTY_ASSIST_MESSAGES.TOKEN, reqid, token, jti, }; } catch (error) { console.error( '[notabene] fetchCounterpartyAssistToken failed', error, ); reply = { type: COUNTERPARTY_ASSIST_MESSAGES.TOKEN, reqid, error: error instanceof Error ? error.message : String(error), }; } // Kept out of HostMessage so hosts' exhaustive switches over it still compile. component.send(reply as unknown as HostMessage); }, ); return component; } /** * Creates a withdrawal assist component */ public createWithdrawalAssist( value: WithdrawalInput, options?: TransactionOptions, callbacks?: CallbackOptions, ) { this.warnIfCounterpartyAssistUnavailable(options); return this.createComponent< WithdrawalInput, TransactionOptions, TransferResponse | CounterpartyAssistShared >('withdrawal-assist', value, options, callbacks); } /** * Creates a deposit assist component */ public createDepositAssist( value: DepositInput, options?: TransactionOptions, callbacks?: CallbackOptions, ) { this.warnIfCounterpartyAssistUnavailable(options); return this.createComponent< DepositInput, TransactionOptions, TransferResponse | CounterpartyAssistShared >('deposit-assist', value, options, callbacks); } /** * Creates a connect component */ public createConnectWallet( value: ConnectionInput, options?: ConnectionOptions, callbacks?: CallbackOptions, ) { return this.createComponent< ConnectionInput, ConnectionOptions, WalletResponse >('connect', value, options, callbacks); } }