-
Notifications
You must be signed in to change notification settings - Fork 305
feat: add sdk-core vault module interfaces and scaffolding #9198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,178 @@ | ||||||
| /** | ||||||
| * @prettier | ||||||
| * | ||||||
| * io-ts codecs for the vault REST surface. These are the single source of truth for the | ||||||
| * vault data shapes: the TypeScript interfaces in `iVault.ts` are derived from them via | ||||||
| * `t.TypeOf`, request bodies are encoded with `postWithCodec`, and responses are decoded | ||||||
| * (and validated) with `decodeWithCodec` — so there are no `as VaultData` casts. | ||||||
| * | ||||||
| * Timestamps use `DateFromISOString`: the wire representation stays an ISO-8601 string while | ||||||
| * the decoded object exposes a real `Date`. | ||||||
| */ | ||||||
| import * as t from 'io-ts'; | ||||||
| import { DateFromISOString } from 'io-ts-types'; | ||||||
|
|
||||||
| /** | ||||||
| * The four static root-key slots of a vault, keyed by (curve, scheme): | ||||||
| * - secp256k1Multisig ① — ex. UTXO/XRP/XTZ/TRX/EOS | ||||||
| * - ecdsaMpc ② — ex. EVM/Cosmos (DKLS) | ||||||
| * - eddsaMpc ③ — ex. SOL/SUI/NEAR/TON/APT/DOT | ||||||
| * - ed25519Multisig ④ — ex. ALGO/XLM/HBAR | ||||||
| */ | ||||||
| export const RootKeyType = t.keyof( | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Once we finalize, we should consider moving these codecs to public-types. Having it in the sdk for now is fine though. |
||||||
| { | ||||||
| secp256k1Multisig: null, | ||||||
| ecdsaMpc: null, | ||||||
| eddsaMpc: null, | ||||||
| ed25519Multisig: null, | ||||||
| }, | ||||||
| 'RootKeyType' | ||||||
| ); | ||||||
|
|
||||||
| export const VaultPermission = t.keyof( | ||||||
| { | ||||||
| view: null, | ||||||
| spend: null, | ||||||
| admin: null, | ||||||
| dapp: null, | ||||||
| }, | ||||||
| 'VaultPermission' | ||||||
| ); | ||||||
|
|
||||||
| /** An ordered [userKeyId, backupKeyId, bitgoKeyId] triplet — same shape/order as wallet.keys[]. */ | ||||||
| export const RootKeyTriplet = t.tuple([t.string, t.string, t.string], 'RootKeyTriplet'); | ||||||
|
|
||||||
| /** The 12 static root key ids, by (curve, scheme). */ | ||||||
| export const VaultRootKeys = t.type( | ||||||
| { | ||||||
| secp256k1Multisig: RootKeyTriplet, | ||||||
| ecdsaMpc: RootKeyTriplet, | ||||||
| eddsaMpc: RootKeyTriplet, | ||||||
| ed25519Multisig: RootKeyTriplet, | ||||||
| }, | ||||||
| 'VaultRootKeys' | ||||||
| ); | ||||||
|
|
||||||
| export const VaultMembershipData = t.intersection( | ||||||
| [ | ||||||
| t.type({ | ||||||
| userId: t.string, | ||||||
| permissions: t.array(VaultPermission), | ||||||
| }), | ||||||
| t.partial({ | ||||||
| needsRecovery: t.boolean, | ||||||
| }), | ||||||
| ], | ||||||
| 'VaultMembershipData' | ||||||
| ); | ||||||
|
|
||||||
| /** A pending UMS spend grant awaiting a key share — mirror of the wallet's walletShareRequests[]. */ | ||||||
| export const VaultShareRequest = t.type( | ||||||
| { | ||||||
| userId: t.string, | ||||||
| permissions: t.array(VaultPermission), | ||||||
| createdAt: DateFromISOString, | ||||||
| }, | ||||||
| 'VaultShareRequest' | ||||||
| ); | ||||||
|
|
||||||
| export const VaultFreeze = t.partial( | ||||||
| { | ||||||
| time: DateFromISOString, | ||||||
| expires: DateFromISOString, | ||||||
| reason: t.string, | ||||||
| }, | ||||||
| 'VaultFreeze' | ||||||
| ); | ||||||
|
|
||||||
| export const VaultStatus = t.keyof( | ||||||
| { | ||||||
| initializing: null, | ||||||
| active: null, | ||||||
| archived: null, | ||||||
| }, | ||||||
| 'VaultStatus' | ||||||
| ); | ||||||
|
|
||||||
| export const VaultData = t.intersection( | ||||||
| [ | ||||||
| t.type({ | ||||||
| id: t.string, | ||||||
| enterpriseId: t.string, | ||||||
| label: t.string, | ||||||
| // freeze is NOT a status — a frozen vault stays 'active' with the freeze field set (wallet precedent) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| status: VaultStatus, | ||||||
| creator: t.string, | ||||||
| users: t.array(VaultMembershipData), | ||||||
| createdAt: DateFromISOString, | ||||||
| }), | ||||||
| t.partial({ | ||||||
| vaultShareRequests: t.array(VaultShareRequest), | ||||||
| freeze: VaultFreeze, | ||||||
| rootKeys: VaultRootKeys, | ||||||
| archivedAt: DateFromISOString, | ||||||
| }), | ||||||
| ], | ||||||
| 'VaultData' | ||||||
| ); | ||||||
|
|
||||||
| /** Vault key-share states — identical to WalletShare states, no new states. */ | ||||||
| export const VaultShareState = t.keyof( | ||||||
| { | ||||||
| pendingapproval: null, | ||||||
| active: null, | ||||||
| accepted: null, | ||||||
| canceled: null, | ||||||
| rejected: null, | ||||||
| }, | ||||||
| 'VaultShareState' | ||||||
| ); | ||||||
|
|
||||||
| /** One of the 4 root USER keyshares carried on a VaultShare, ECDH-re-encrypted to the recipient. */ | ||||||
| export const VaultShareKeychain = t.type( | ||||||
| { | ||||||
| rootKeyType: RootKeyType, | ||||||
| rootKeyId: t.string, | ||||||
| encryptedPrv: t.string, | ||||||
| publicIdentifier: t.string, | ||||||
| fromPubKey: t.string, | ||||||
| toPubKey: t.string, | ||||||
| path: t.string, | ||||||
| }, | ||||||
| 'VaultShareKeychain' | ||||||
| ); | ||||||
|
|
||||||
| export const VaultShareData = t.intersection( | ||||||
| [ | ||||||
| t.type({ | ||||||
| id: t.string, | ||||||
| enterpriseId: t.string, | ||||||
| vaultId: t.string, | ||||||
| fromUser: t.string, | ||||||
| toUser: t.string, | ||||||
| permissions: t.array(VaultPermission), | ||||||
| state: VaultShareState, | ||||||
| createdAt: DateFromISOString, | ||||||
| }), | ||||||
| t.partial({ | ||||||
| vaultLabel: t.string, | ||||||
| message: t.string, | ||||||
| pendingApprovalId: t.string, | ||||||
| isUMSInitiated: t.boolean, | ||||||
| keychains: t.array(VaultShareKeychain), | ||||||
| updatedAt: DateFromISOString, | ||||||
| }), | ||||||
| ], | ||||||
| 'VaultShareData' | ||||||
| ); | ||||||
|
|
||||||
| // ---- request bodies ---- | ||||||
|
|
||||||
| /** POST /enterprise/:eId/vaults — Phase 1 carries no key material. */ | ||||||
| export const InitializeVaultBody = t.type({ label: t.string }, 'InitializeVaultBody'); | ||||||
|
|
||||||
| /** POST /enterprise/:eId/vaults/:vId/finalize — the 12 key ids as 4 ordered triplets. */ | ||||||
| export const FinalizeVaultBody = t.type({ rootKeys: VaultRootKeys }, 'FinalizeVaultBody'); | ||||||
|
|
||||||
| /** POST/DELETE /enterprise/:eId/vaults/:vId/freeze */ | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| export const FreezeVaultBody = t.partial({ duration: t.number }, 'FreezeVaultBody'); | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,100 @@ | ||||||
| /** | ||||||
| * @prettier | ||||||
| * | ||||||
| * @experimental The vault client surface is experimental and may change (including breaking | ||||||
| * changes) before the public release. | ||||||
| */ | ||||||
| import * as t from 'io-ts'; | ||||||
| import type { FreezeOptions, Wallet, WalletShare } from '../wallet'; | ||||||
| import * as VaultCodecs from './codecs'; | ||||||
|
|
||||||
| // ---- data shapes (derived from the io-ts codecs in ./codecs — single source of truth) ---- | ||||||
|
|
||||||
| export type RootKeyType = t.TypeOf<typeof VaultCodecs.RootKeyType>; | ||||||
| export type VaultPermission = t.TypeOf<typeof VaultCodecs.VaultPermission>; | ||||||
| export type VaultRootKeys = t.TypeOf<typeof VaultCodecs.VaultRootKeys>; | ||||||
| export type VaultMembershipData = t.TypeOf<typeof VaultCodecs.VaultMembershipData>; | ||||||
| export type VaultShareRequest = t.TypeOf<typeof VaultCodecs.VaultShareRequest>; | ||||||
| export type VaultData = t.TypeOf<typeof VaultCodecs.VaultData>; | ||||||
| export type VaultShareState = t.TypeOf<typeof VaultCodecs.VaultShareState>; | ||||||
| export type VaultShareKeychain = t.TypeOf<typeof VaultCodecs.VaultShareKeychain>; | ||||||
| export type VaultShareData = t.TypeOf<typeof VaultCodecs.VaultShareData>; | ||||||
|
|
||||||
| export interface InitializeVaultOptions { | ||||||
| label: string; | ||||||
| } | ||||||
|
davidkaplanbitgo marked this conversation as resolved.
|
||||||
|
|
||||||
| // Phase 3 — the client hands back the 12 key ids it created in Phase 2: | ||||||
| export interface FinalizeVaultOptions { | ||||||
| rootKeys: VaultRootKeys; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Sharing ONE vault wallet with a non-member rides the existing wallet-share handshake (FR-13), | ||||||
| * so the result is the existing WalletShare shape. | ||||||
| */ | ||||||
| export type WalletShareData = WalletShare; | ||||||
|
|
||||||
| // ---- per-vault operation options (bodies land in WCN-1203 / WCN-1204) ---- | ||||||
|
|
||||||
| export interface CreateVaultWalletOptions { | ||||||
| coin: string; | ||||||
| label: string; | ||||||
| type?: string; | ||||||
| multisigTypeVersion?: string; | ||||||
| } | ||||||
|
|
||||||
| interface AddVaultMemberBase { | ||||||
| permissions: VaultPermission[]; | ||||||
| /** required when 'spend' is included — the 4 root user keys ECDH-re-encrypted to the invitee */ | ||||||
| keychains?: VaultShareKeychain[]; | ||||||
| message?: string; | ||||||
| /** when true, suppress the invitation email that would otherwise be sent to `email` */ | ||||||
| disableEmail?: boolean; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's add a jsdoc on what this parameter does. |
||||||
| } | ||||||
|
|
||||||
| /** Add a member by either `userId` or `email` — exactly one is required. */ | ||||||
| export type AddVaultMemberOptions = | ||||||
| | (AddVaultMemberBase & { userId: string; email?: never }) | ||||||
| | (AddVaultMemberBase & { email: string; userId?: never }); | ||||||
|
|
||||||
| export interface AddVaultWalletMemberOptions { | ||||||
| walletId: string; | ||||||
| /** required — sharing re-encrypts the user key, which needs hardened derivation from the passphrase */ | ||||||
| walletPassphrase: string; | ||||||
| email?: string; | ||||||
| permissions?: string[]; | ||||||
| message?: string; | ||||||
| } | ||||||
|
davidkaplanbitgo marked this conversation as resolved.
|
||||||
|
|
||||||
| export type AcceptVaultShareAsSpenderOptions = { | ||||||
| vaultShareId: string; | ||||||
| userPassword: string; | ||||||
| newWalletPassphrase?: string; | ||||||
| }; | ||||||
| export type AcceptVaultShareAsNonSpenderOptions = { | ||||||
| vaultShareId: string; | ||||||
| }; | ||||||
| export type AcceptVaultShareOptions = AcceptVaultShareAsSpenderOptions | AcceptVaultShareAsNonSpenderOptions; | ||||||
|
|
||||||
| /** | ||||||
| * @experimental | ||||||
| */ | ||||||
| export interface IVault { | ||||||
|
davidkaplanbitgo marked this conversation as resolved.
|
||||||
| id(): string; | ||||||
| enterpriseId(): string; | ||||||
| label(): string; | ||||||
| status(): VaultData['status']; | ||||||
| url(extra?: string): string; | ||||||
| createWallet(params: CreateVaultWalletOptions): Promise<Wallet>; | ||||||
| // whole-vault: view/admin/spend; spend opens a key share (also how a spender services a | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| // vaultShareRequests entry in UMS orgs) | ||||||
| addMember(params: AddVaultMemberOptions): Promise<VaultData>; | ||||||
| // share ONE vault wallet, not the whole vault | ||||||
| addMemberToWallet(params: AddVaultWalletMemberOptions): Promise<WalletShareData>; | ||||||
| listShares(params?: { state?: VaultShareState }): Promise<VaultShareData[]>; | ||||||
| acceptShare(params: AcceptVaultShareOptions): Promise<VaultShareData>; | ||||||
| freeze(params?: FreezeOptions): Promise<VaultData>; | ||||||
| archive(): Promise<VaultData>; | ||||||
| toJSON(): VaultData; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** | ||
| * @prettier | ||
| * | ||
| * @experimental The vault client surface is experimental and may change (including breaking | ||
| * changes) before the public release. | ||
| */ | ||
| import { FinalizeVaultOptions, InitializeVaultOptions } from './iVault'; | ||
| import { Vault } from './vault'; | ||
|
|
||
| /** | ||
| * Options for vault creation. | ||
| * | ||
| * v1 targets the HOT custody model only: the multisig root user/backup keys are generated locally | ||
| * by the SDK, encrypted with `passphrase`, and registered on BitGo; the MPC roots run the standard | ||
| * hot ceremonies with the same passphrase. Self-managed cold keys and custodial vaults are out of | ||
| * scope for v1. | ||
| */ | ||
| export interface CreateVaultOptions { | ||
| label: string; | ||
| passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies | ||
| } | ||
|
|
||
| /** Handle returned by `initializeVault`, threaded into the key ceremonies and finalize. */ | ||
| export interface VaultCreationHandle { | ||
| vaultId: string; | ||
| } | ||
|
|
||
| /** | ||
| * The 12 minted root key ids produced by `createVaultKeys`, as 4 ordered [user, backup, bitgo] | ||
| * triplets — exactly the payload `finalizeVault` consumes. | ||
| */ | ||
| export type VaultKeys = FinalizeVaultOptions; | ||
|
|
||
| export interface ListVaultsOptions { | ||
| cursor?: string; // opaque cursor from a previous response's nextCursor | ||
| limit?: number; | ||
| } | ||
|
|
||
| export interface GetVaultOptions { | ||
| id: string; | ||
| } | ||
|
|
||
| /** | ||
| * @experimental | ||
| */ | ||
| export interface IVaults { | ||
| /** | ||
| * One-call convenience wrapper: initialize → createVaultKeys (4 vaultId-tagged ceremonies) → | ||
| * finalize → keycard. HOT custody only in v1. | ||
| */ | ||
| generateVault(params: CreateVaultOptions): Promise<Vault>; | ||
| /** Phase 1 — initialize a vault (metadata only, no key material). */ | ||
| initializeVault(params: InitializeVaultOptions): Promise<Vault>; | ||
| /** Phase 2 — run the 4 root key ceremonies tagged with `vaultId`; returns the 12 minted key ids. */ | ||
| createVaultKeys(params: CreateVaultOptions & VaultCreationHandle): Promise<VaultKeys>; | ||
| /** Phase 3 — finalize a vault with the 12 root key ids. Idempotent. */ | ||
| finalizeVault(vaultId: string, params: FinalizeVaultOptions): Promise<Vault>; | ||
| list(params?: ListVaultsOptions): Promise<{ vaults: Vault[]; nextCursor?: string }>; | ||
| get(params: GetVaultOptions): Promise<Vault>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export * from './iVault'; | ||
| export * from './iVaults'; | ||
| export * from './vault'; | ||
| export * from './vaults'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lets add an
@experimentaljsdoc, in case we need to make breaking changes before the public release.