@dynamic-labs-wallet/browser 1.0.19 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.cjs +286 -235
- package/index.esm.js +287 -236
- package/package.json +3 -3
- package/src/client.d.ts +11 -5
- package/src/client.d.ts.map +1 -1
- package/src/httpStatus.d.ts +8 -0
- package/src/httpStatus.d.ts.map +1 -0
- package/src/services/signedSession.d.ts +51 -0
- package/src/services/signedSession.d.ts.map +1 -0
- package/src/utils.d.ts +2 -7
- package/src/utils.d.ts.map +1 -1
package/index.esm.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { BitcoinAddressType, MPC_RELAY_PROD_API_URL, getMPCChainConfig, BackupLocation, ENCRYPTED_SHARES_STORAGE_SUFFIX, Logger, WalletApiError, handleAxiosError, WalletOperation,
|
|
1
|
+
import { BitcoinAddressType, MPC_RELAY_PROD_API_URL, getMPCChainConfig, BackupLocation, ENCRYPTED_SHARES_STORAGE_SUFFIX, Logger, WalletApiError, handleAxiosError, WalletOperation, parseNamespacedVersion, WalletReadyState, FEATURE_FLAGS, ThresholdSignatureScheme, getClientThreshold, MPC_CONFIG, classifyForwardMpcError, getTSSConfig, serializeMessageForForwardMPC, isEvmCompatibleChain, getReshareConfig, getRequiredExternalKeyShareId, verifiedCredentialNameToChainEnum, AuthMode, NoopLogger, DynamicApiClient, getEnvironmentFromUrl, IFRAME_DOMAIN_MAP } from '@dynamic-labs-wallet/core';
|
|
2
2
|
export * from '@dynamic-labs-wallet/core';
|
|
3
3
|
export { Logger } from '@dynamic-labs-wallet/core';
|
|
4
4
|
import { EdBls12377, BIP340, ExportableEd25519, Ecdsa, MessageHash, EcdsaSignature, EcdsaKeygenResult, ExportableEd25519KeygenResult, BIP340KeygenResult } from '#internal/web';
|
|
5
5
|
export { BIP340, BIP340InitKeygenResult, BIP340KeygenResult, Ecdsa, EcdsaInitKeygenResult, EcdsaKeygenResult, EcdsaPublicKey, EcdsaSignature, Ed25519, Ed25519InitKeygenResult, Ed25519KeygenResult, EdBls12377, EdBls12377KeygenResult, MessageHash } from '#internal/web';
|
|
6
|
-
import { gte } from 'semver';
|
|
7
6
|
import { v4, validate } from 'uuid';
|
|
8
7
|
import { SigningAlgorithm } from '@dynamic-labs-wallet/primitives';
|
|
9
8
|
import { ProviderEnum, RoomTypeEnum } from '@dynamic-labs/sdk-api-core';
|
|
10
9
|
import loadArgon2idWasm from 'argon2id';
|
|
11
10
|
import { AxiosError } from 'axios';
|
|
12
11
|
import PQueue from 'p-queue';
|
|
12
|
+
import { gte } from 'semver';
|
|
13
13
|
|
|
14
14
|
function _extends() {
|
|
15
15
|
_extends = Object.assign || function assign(target) {
|
|
@@ -998,6 +998,16 @@ const downloadFileFromGoogleDrive = async ({ accessToken, fileName })=>{
|
|
|
998
998
|
}
|
|
999
999
|
};
|
|
1000
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Extracts the HTTP status from an `AxiosError`, or `undefined` for non-HTTP
|
|
1003
|
+
* errors. Lives in its own leaf module (no `#internal/web` / WASM imports) so
|
|
1004
|
+
* lightweight consumers like `services/signedSession.ts` can use it without
|
|
1005
|
+
* pulling the MPC WASM bundle that `utils.ts` loads.
|
|
1006
|
+
*/ const getHttpStatus = (error)=>{
|
|
1007
|
+
var _error_response;
|
|
1008
|
+
return error instanceof AxiosError ? (_error_response = error.response) == null ? void 0 : _error_response.status : undefined;
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1001
1011
|
const STORAGE_KEY = 'dynamic-waas-wallet-client';
|
|
1002
1012
|
const BACKUP_FILENAME = 'dynamicWalletKeyShareBackup.json';
|
|
1003
1013
|
const CLIENT_KEYSHARE_EXPORT_FILENAME_PREFIX = 'dynamicWalletKeyShareBackup';
|
|
@@ -1077,14 +1087,6 @@ const getClientKeyShareBackupInfo = (params)=>{
|
|
|
1077
1087
|
const timeoutPromise = ({ timeInMs, activity = 'Ceremony' })=>{
|
|
1078
1088
|
return new Promise((_, reject)=>setTimeout(()=>reject(new Error(`${activity} did not complete in ${timeInMs}ms`)), timeInMs));
|
|
1079
1089
|
};
|
|
1080
|
-
/**
|
|
1081
|
-
* Extracts the HTTP status code from an error, returning undefined for non-HTTP
|
|
1082
|
-
* failures (network errors, DNS, aborts) which retry logic typically treats
|
|
1083
|
-
* the same as 5xx.
|
|
1084
|
-
*/ const getHttpStatus = (error)=>{
|
|
1085
|
-
var _error_response;
|
|
1086
|
-
return error instanceof AxiosError ? (_error_response = error.response) == null ? void 0 : _error_response.status : undefined;
|
|
1087
|
-
};
|
|
1088
1090
|
const buildErrorContext = (error)=>{
|
|
1089
1091
|
var _error_response, _error_response1;
|
|
1090
1092
|
return {
|
|
@@ -2464,6 +2466,84 @@ const readEnvironmentSettings = ()=>{
|
|
|
2464
2466
|
}
|
|
2465
2467
|
});
|
|
2466
2468
|
|
|
2469
|
+
/**
|
|
2470
|
+
* Owns everything about signed sessions and their single-use replay nonces:
|
|
2471
|
+
* resolving a session from an explicit value or the host reverse channel,
|
|
2472
|
+
* deciding whether the SDK version requires one, and building the
|
|
2473
|
+
* refresh-on-replay-400 retry predicate shared by the backup and recovery
|
|
2474
|
+
* flows.
|
|
2475
|
+
*
|
|
2476
|
+
* Extracted from `DynamicWalletClient` so this logic can be exercised in
|
|
2477
|
+
* isolation. The host callback is read lazily (`getCallback`) so it stays in
|
|
2478
|
+
* sync with the owning client even if reassigned after construction.
|
|
2479
|
+
*/ class SignedSessionManager {
|
|
2480
|
+
/** True when a reverse channel is configured to mint fresh signed sessions. */ get canRefresh() {
|
|
2481
|
+
return this.getCallback() !== undefined;
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* Resolves the signed session ID from an explicit value or falls back to the
|
|
2485
|
+
* host reverse channel. Throws if neither source provides a value.
|
|
2486
|
+
*/ async resolve(signedSessionId) {
|
|
2487
|
+
const callback = this.getCallback();
|
|
2488
|
+
const resolved = signedSessionId != null ? signedSessionId : await (callback == null ? void 0 : callback());
|
|
2489
|
+
if (!resolved) {
|
|
2490
|
+
throw new Error(signedSessionId === undefined && callback ? 'signedSessionId callback returned an invalid value' : 'signedSessionId is required for backup but was not provided and no callback is configured');
|
|
2491
|
+
}
|
|
2492
|
+
return resolved;
|
|
2493
|
+
}
|
|
2494
|
+
/** Whether the configured SDK version requires a signed session ID. */ isRequired() {
|
|
2495
|
+
return this.required;
|
|
2496
|
+
}
|
|
2497
|
+
computeIsRequired(sdkVersion) {
|
|
2498
|
+
if (!sdkVersion) {
|
|
2499
|
+
return false;
|
|
2500
|
+
}
|
|
2501
|
+
try {
|
|
2502
|
+
const parsedVersion = parseNamespacedVersion(sdkVersion);
|
|
2503
|
+
if (!parsedVersion) {
|
|
2504
|
+
return false;
|
|
2505
|
+
}
|
|
2506
|
+
const { namespace, version } = parsedVersion;
|
|
2507
|
+
const namespaceMinVersion = SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE[namespace];
|
|
2508
|
+
if (namespaceMinVersion) {
|
|
2509
|
+
return gte(version, namespaceMinVersion);
|
|
2510
|
+
}
|
|
2511
|
+
return gte(version, SIGNED_SESSION_ID_MIN_VERSION);
|
|
2512
|
+
} catch (error) {
|
|
2513
|
+
this.logger.warn(`[DynamicWaasWalletClient] Error checking if requiresSignedSessionId should be set to true for version ${sdkVersion}`, {
|
|
2514
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2515
|
+
});
|
|
2516
|
+
return false;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2520
|
+
* Builds a `retryPromise` `shouldRetry` predicate that refreshes a consumed
|
|
2521
|
+
* single-use nonce via the reverse channel on a replay 400 and retries once.
|
|
2522
|
+
* The signed session carries a single-use replay nonce: it is valid on its
|
|
2523
|
+
* first use, but once consumed (e.g. by a preceding operation) the server
|
|
2524
|
+
* rejects with a 400, at which point a fresh session is fetched and the call
|
|
2525
|
+
* retried. `alsoRetry` lets callers opt into additional retryable statuses
|
|
2526
|
+
* (e.g. 429 / 5xx) on top of the nonce refresh.
|
|
2527
|
+
*/ refreshShouldRetry({ onRefreshed, operationName, logContext, alsoRetry }) {
|
|
2528
|
+
return async (error, attempt)=>{
|
|
2529
|
+
const status = getHttpStatus(error);
|
|
2530
|
+
if (status === 400 && attempt === 1 && this.canRefresh) {
|
|
2531
|
+
onRefreshed(await this.resolve());
|
|
2532
|
+
this.logger.info(`[${operationName}] refreshed signed session, retrying`, _extends({
|
|
2533
|
+
status
|
|
2534
|
+
}, logContext));
|
|
2535
|
+
return true;
|
|
2536
|
+
}
|
|
2537
|
+
return alsoRetry ? alsoRetry(status) : false;
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
constructor({ logger, sdkVersion, getCallback }){
|
|
2541
|
+
this.logger = logger;
|
|
2542
|
+
this.getCallback = getCallback;
|
|
2543
|
+
this.required = this.computeIsRequired(sdkVersion);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2467
2547
|
/**
|
|
2468
2548
|
* Determines the recovery state of a wallet based on backup info and local shares.
|
|
2469
2549
|
*
|
|
@@ -2513,17 +2593,6 @@ const KNOWN_SHARE_SET_TYPES = new Set([
|
|
|
2513
2593
|
]);
|
|
2514
2594
|
class DynamicWalletClient {
|
|
2515
2595
|
/**
|
|
2516
|
-
* Resolves the signed session ID from an explicit value or falls back to the callback.
|
|
2517
|
-
* Throws if neither source provides a value.
|
|
2518
|
-
*/ async resolveSignedSessionId(signedSessionId) {
|
|
2519
|
-
const resolved = signedSessionId != null ? signedSessionId : await (this.getSignedSessionIdCallback == null ? void 0 : this.getSignedSessionIdCallback.call(this));
|
|
2520
|
-
if (!resolved) {
|
|
2521
|
-
const errorMsg = signedSessionId === undefined && this.getSignedSessionIdCallback ? 'signedSessionId callback returned an invalid value' : 'signedSessionId is required for backup but was not provided and no callback is configured';
|
|
2522
|
-
throw new Error(errorMsg);
|
|
2523
|
-
}
|
|
2524
|
-
return resolved;
|
|
2525
|
-
}
|
|
2526
|
-
/**
|
|
2527
2596
|
* Check if wallet has heavy operations in progress
|
|
2528
2597
|
*/ static isHeavyOpInProgress(accountAddress) {
|
|
2529
2598
|
return WalletQueueManager.isHeavyOpInProgress(accountAddress);
|
|
@@ -2609,29 +2678,8 @@ class DynamicWalletClient {
|
|
|
2609
2678
|
* Check if the SDK version meets the requirement for signed session ID
|
|
2610
2679
|
* Uses namespace-specific version requirements when available
|
|
2611
2680
|
* @returns boolean indicating if requireSignedSessionId should be set to true
|
|
2612
|
-
*/ requiresSignedSessionId() {
|
|
2613
|
-
|
|
2614
|
-
return false;
|
|
2615
|
-
}
|
|
2616
|
-
try {
|
|
2617
|
-
const parsedVersion = parseNamespacedVersion(this.sdkVersion);
|
|
2618
|
-
if (!parsedVersion) {
|
|
2619
|
-
return false;
|
|
2620
|
-
}
|
|
2621
|
-
const { namespace, version } = parsedVersion;
|
|
2622
|
-
// Check if we have a namespace-specific version requirement
|
|
2623
|
-
const namespaceMinVersion = SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE[namespace];
|
|
2624
|
-
if (namespaceMinVersion) {
|
|
2625
|
-
return gte(version, namespaceMinVersion);
|
|
2626
|
-
}
|
|
2627
|
-
// Fall back to default version requirement
|
|
2628
|
-
return gte(version, SIGNED_SESSION_ID_MIN_VERSION);
|
|
2629
|
-
} catch (error) {
|
|
2630
|
-
this.logger.warn(`[DynamicWaasWalletClient] Error checking if requiresSignedSessionId should be set to true for version ${this.sdkVersion}`, {
|
|
2631
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2632
|
-
});
|
|
2633
|
-
return false;
|
|
2634
|
-
}
|
|
2681
|
+
*/ /** @see SignedSessionManager.isRequired */ requiresSignedSessionId() {
|
|
2682
|
+
return this.signedSession.isRequired();
|
|
2635
2683
|
}
|
|
2636
2684
|
async initLoggerContext(authToken) {
|
|
2637
2685
|
// only decode jwt token in header auth mode
|
|
@@ -3666,24 +3714,35 @@ class DynamicWalletClient {
|
|
|
3666
3714
|
});
|
|
3667
3715
|
// Ensure client key shares exist before hitting the API
|
|
3668
3716
|
const clientKeyShares = await this.ensureClientShare(accountAddress);
|
|
3669
|
-
//
|
|
3670
|
-
//
|
|
3671
|
-
//
|
|
3672
|
-
|
|
3673
|
-
//
|
|
3674
|
-
//
|
|
3675
|
-
//
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3717
|
+
// The MPC ceremony needs client + server in the room concurrently, so we
|
|
3718
|
+
// run mpcSigner.refresh BEFORE awaiting the gate. The gate's purpose is to
|
|
3719
|
+
// ensure the walletMap.shareSetId rotation (done in onCeremonyComplete)
|
|
3720
|
+
// lands before storeEncryptedBackupByWallet reads it; without it, backup
|
|
3721
|
+
// would race ceremony_complete and the atomic swap could target the stale
|
|
3722
|
+
// row. refresh's API call returns on room_created (not the local MPC
|
|
3723
|
+
// result), so the gate await sits between the local MPC and the backup.
|
|
3724
|
+
const ceremonyGate = this.createCeremonyTerminalGate((_accountAddress, _walletId, shareSetId, shareSetType)=>{
|
|
3725
|
+
this.logger.info('[WaasRefresh] ceremony_complete received', {
|
|
3726
|
+
context: {
|
|
3727
|
+
walletId: wallet.walletId,
|
|
3728
|
+
newShareSetId: shareSetId,
|
|
3729
|
+
shareSetType,
|
|
3730
|
+
dynamicRequestId
|
|
3731
|
+
}
|
|
3732
|
+
});
|
|
3733
|
+
this.logOnMalformedCeremonyPayload({
|
|
3734
|
+
walletId: wallet.walletId,
|
|
3735
|
+
ceremony: 'refresh',
|
|
3736
|
+
shareSetId,
|
|
3737
|
+
shareSetType
|
|
3738
|
+
});
|
|
3739
|
+
this.rotateRootUserShareSetIdIfChanged({
|
|
3740
|
+
accountAddress,
|
|
3741
|
+
wallet,
|
|
3742
|
+
ceremony: 'refresh',
|
|
3743
|
+
shareSetId,
|
|
3744
|
+
shareSetType
|
|
3745
|
+
});
|
|
3687
3746
|
});
|
|
3688
3747
|
this.logger.info('[WaasRefresh] calling refreshWalletAccountShares', {
|
|
3689
3748
|
context: {
|
|
@@ -3698,31 +3757,8 @@ class DynamicWalletClient {
|
|
|
3698
3757
|
shareSetId: wallet.shareSetId,
|
|
3699
3758
|
mfaToken,
|
|
3700
3759
|
elevatedAccessToken,
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
this.logger.info('[WaasRefresh] ceremony_complete received', {
|
|
3704
|
-
context: {
|
|
3705
|
-
walletId: wallet.walletId,
|
|
3706
|
-
newShareSetId: shareSetId,
|
|
3707
|
-
shareSetType,
|
|
3708
|
-
dynamicRequestId
|
|
3709
|
-
}
|
|
3710
|
-
});
|
|
3711
|
-
this.logOnMalformedCeremonyPayload({
|
|
3712
|
-
walletId: wallet.walletId,
|
|
3713
|
-
ceremony: 'refresh',
|
|
3714
|
-
shareSetId,
|
|
3715
|
-
shareSetType
|
|
3716
|
-
});
|
|
3717
|
-
this.rotateRootUserShareSetIdIfChanged({
|
|
3718
|
-
accountAddress,
|
|
3719
|
-
wallet,
|
|
3720
|
-
ceremony: 'refresh',
|
|
3721
|
-
shareSetId,
|
|
3722
|
-
shareSetType
|
|
3723
|
-
});
|
|
3724
|
-
ceremonyCompleteResolver(undefined);
|
|
3725
|
-
}
|
|
3760
|
+
onError: ceremonyGate.onError,
|
|
3761
|
+
onCeremonyComplete: ceremonyGate.onCeremonyComplete
|
|
3726
3762
|
});
|
|
3727
3763
|
this.logger.info('[WaasRefresh] room_created received, starting local MPC', {
|
|
3728
3764
|
context: {
|
|
@@ -3752,40 +3788,11 @@ class DynamicWalletClient {
|
|
|
3752
3788
|
this.logger.info('[WaasRefresh] local MPC done, awaiting ceremony_complete before backup', {
|
|
3753
3789
|
context: {
|
|
3754
3790
|
walletId: wallet.walletId,
|
|
3755
|
-
ceremonyCallbackFired,
|
|
3791
|
+
ceremonyCallbackFired: ceremonyGate.fired,
|
|
3756
3792
|
dynamicRequestId
|
|
3757
3793
|
}
|
|
3758
3794
|
});
|
|
3759
|
-
|
|
3760
|
-
// onCeremonyComplete lands in walletMap before the backup call reads it.
|
|
3761
|
-
// Bounded by a 5s timeout so a missing/late event never hangs the SDK —
|
|
3762
|
-
// if it fires we proceed with the rotated id; if it doesn't we fall back
|
|
3763
|
-
// to the (stale) walletMap.shareSetId, matching pre-fix behavior.
|
|
3764
|
-
const REFRESH_CEREMONY_COMPLETE_TIMEOUT_MS = 5000;
|
|
3765
|
-
let ceremonyCompleteTimedOut = false;
|
|
3766
|
-
let refreshTimeoutId;
|
|
3767
|
-
await Promise.race([
|
|
3768
|
-
ceremonyCompletePromise,
|
|
3769
|
-
new Promise((resolve)=>{
|
|
3770
|
-
refreshTimeoutId = setTimeout(()=>{
|
|
3771
|
-
ceremonyCompleteTimedOut = true;
|
|
3772
|
-
resolve(undefined);
|
|
3773
|
-
}, REFRESH_CEREMONY_COMPLETE_TIMEOUT_MS);
|
|
3774
|
-
})
|
|
3775
|
-
]);
|
|
3776
|
-
// Cancel the pending timer when ceremony_complete wins the race so it
|
|
3777
|
-
// doesn't fire later and flip ceremonyCompleteTimedOut after the fact.
|
|
3778
|
-
// Safe no-op when the timer has already fired.
|
|
3779
|
-
clearTimeout(refreshTimeoutId);
|
|
3780
|
-
if (ceremonyCompleteTimedOut) {
|
|
3781
|
-
this.logger.warn(`[WaasRefresh] ceremony_complete didn’t arrive within ${REFRESH_CEREMONY_COMPLETE_TIMEOUT_MS}ms — proceeding with stale walletMap`, {
|
|
3782
|
-
context: {
|
|
3783
|
-
walletId: wallet.walletId,
|
|
3784
|
-
ceremonyCallbackFired,
|
|
3785
|
-
dynamicRequestId
|
|
3786
|
-
}
|
|
3787
|
-
});
|
|
3788
|
-
}
|
|
3795
|
+
await ceremonyGate.awaitTerminal();
|
|
3789
3796
|
// Backup & activate server shares BEFORE saving to local storage.
|
|
3790
3797
|
// This prevents a mismatch where new client shares are stored locally
|
|
3791
3798
|
// but old server shares remain active if backup/activation fails.
|
|
@@ -3808,15 +3815,13 @@ class DynamicWalletClient {
|
|
|
3808
3815
|
clientKeyShares: refreshResults
|
|
3809
3816
|
});
|
|
3810
3817
|
// Operation summary — correlates against downstream sign failures /
|
|
3811
|
-
// pending-rotation reports.
|
|
3812
|
-
//
|
|
3813
|
-
// SSE event was lost mid-flight.
|
|
3818
|
+
// pending-rotation reports. The gate always fired here (we throw before
|
|
3819
|
+
// backup otherwise); kept for log continuity.
|
|
3814
3820
|
this.logger.info('[WaasRefresh] completed', {
|
|
3815
3821
|
context: {
|
|
3816
3822
|
walletId: wallet.walletId,
|
|
3817
3823
|
oldShareSetId: wallet.shareSetId,
|
|
3818
|
-
ceremonyCallbackFired,
|
|
3819
|
-
ceremonyCompleteTimedOut,
|
|
3824
|
+
ceremonyCallbackFired: ceremonyGate.fired,
|
|
3820
3825
|
dynamicRequestId
|
|
3821
3826
|
}
|
|
3822
3827
|
});
|
|
@@ -3972,6 +3977,50 @@ class DynamicWalletClient {
|
|
|
3972
3977
|
});
|
|
3973
3978
|
}
|
|
3974
3979
|
}
|
|
3980
|
+
// Terminal gate shared by refresh and reshare. Both APIs resolve early on
|
|
3981
|
+
// room_created, so a failed ceremony surfaces *after* the API call returns —
|
|
3982
|
+
// as an SSE error event, a stream close without ceremony_complete, or a
|
|
3983
|
+
// transport drop (all delivered via onError). awaitTerminal() races the two
|
|
3984
|
+
// outcomes with no timeout:
|
|
3985
|
+
// - ceremony_complete → resolves → caller backs up against the rotated id.
|
|
3986
|
+
// - any onError → rejects → caller throws, never backs up (the wedge fix:
|
|
3987
|
+
// a failed ceremony must not promote a client share whose server
|
|
3988
|
+
// counterpart was never persisted).
|
|
3989
|
+
// redcoast always emits one or the other and the event-stream consumer turns
|
|
3990
|
+
// "closed without ceremony_complete" into an onError, so the race always
|
|
3991
|
+
// settles. Aborting fails closed — worst case is a clean retry.
|
|
3992
|
+
//
|
|
3993
|
+
// The reject handle is wired synchronously (Promise executors run inline)
|
|
3994
|
+
// before the API call starts, so an immediate onError can never be dropped.
|
|
3995
|
+
// The lost branch's rejection is swallowed so it never surfaces as unhandled.
|
|
3996
|
+
createCeremonyTerminalGate(onCeremonyComplete) {
|
|
3997
|
+
let resolveComplete;
|
|
3998
|
+
const completePromise = new Promise((resolve)=>{
|
|
3999
|
+
resolveComplete = resolve;
|
|
4000
|
+
});
|
|
4001
|
+
let rejectOnError;
|
|
4002
|
+
const errorPromise = new Promise((_, reject)=>{
|
|
4003
|
+
rejectOnError = reject;
|
|
4004
|
+
});
|
|
4005
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
4006
|
+
errorPromise.catch(()=>{});
|
|
4007
|
+
let fired = false;
|
|
4008
|
+
return {
|
|
4009
|
+
onError: rejectOnError,
|
|
4010
|
+
onCeremonyComplete: (accountAddress, walletId, shareSetId, shareSetType)=>{
|
|
4011
|
+
fired = true;
|
|
4012
|
+
onCeremonyComplete == null ? void 0 : onCeremonyComplete(accountAddress, walletId, shareSetId, shareSetType);
|
|
4013
|
+
resolveComplete();
|
|
4014
|
+
},
|
|
4015
|
+
awaitTerminal: ()=>Promise.race([
|
|
4016
|
+
completePromise,
|
|
4017
|
+
errorPromise
|
|
4018
|
+
]),
|
|
4019
|
+
get fired () {
|
|
4020
|
+
return fired;
|
|
4021
|
+
}
|
|
4022
|
+
};
|
|
4023
|
+
}
|
|
3975
4024
|
// Runs the post-ceremony backup-activation step (storeEncryptedBackupByWallet
|
|
3976
4025
|
// for refresh, backupSharesWithDistribution for reshare). On failure, rolls
|
|
3977
4026
|
// walletMap.shareSetId back to the source row.
|
|
@@ -4203,19 +4252,39 @@ class DynamicWalletClient {
|
|
|
4203
4252
|
...existingClientKeygenIds
|
|
4204
4253
|
];
|
|
4205
4254
|
const bitcoinConfig = this.getBitcoinConfigForChain(chainName, accountAddress);
|
|
4206
|
-
//
|
|
4207
|
-
//
|
|
4208
|
-
// fire" with downstream sign failures or pending-rotation reports.
|
|
4209
|
-
let ceremonyCallbackFired = false;
|
|
4210
|
-
// Deferred resolved by onCeremonyComplete. The MPC reshare requires
|
|
4211
|
-
// client + server in the room concurrently, so we run mpcSigner.reshare*
|
|
4212
|
-
// BEFORE awaiting this — the await's purpose is to ensure the
|
|
4255
|
+
// The MPC reshare needs client + server in the room concurrently, so we
|
|
4256
|
+
// run mpcSigner.reshare* BEFORE awaiting the gate. The gate ensures the
|
|
4213
4257
|
// walletMap.shareSetId rotation lands before storeEncryptedBackupByWallet
|
|
4214
|
-
// reads it
|
|
4215
|
-
// swap
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4258
|
+
// reads it; without it, backup would race ceremony_complete and the atomic
|
|
4259
|
+
// swap could target the stale row.
|
|
4260
|
+
const ceremonyGate = this.createCeremonyTerminalGate((_accountAddress, _walletId, shareSetId, shareSetType)=>{
|
|
4261
|
+
this.logger.info('[WaasReshare] ceremony_complete received', {
|
|
4262
|
+
context: {
|
|
4263
|
+
walletId: wallet.walletId,
|
|
4264
|
+
newShareSetId: shareSetId,
|
|
4265
|
+
shareSetType,
|
|
4266
|
+
dynamicRequestId
|
|
4267
|
+
}
|
|
4268
|
+
});
|
|
4269
|
+
this.logOnMalformedCeremonyPayload({
|
|
4270
|
+
walletId: wallet.walletId,
|
|
4271
|
+
ceremony: 'reshare',
|
|
4272
|
+
shareSetId,
|
|
4273
|
+
shareSetType
|
|
4274
|
+
});
|
|
4275
|
+
this.rotateRootUserShareSetIdIfChanged({
|
|
4276
|
+
accountAddress,
|
|
4277
|
+
wallet,
|
|
4278
|
+
ceremony: 'reshare',
|
|
4279
|
+
shareSetId,
|
|
4280
|
+
shareSetType,
|
|
4281
|
+
newThresholdSignatureScheme,
|
|
4282
|
+
extraContext: {
|
|
4283
|
+
oldThresholdSignatureScheme,
|
|
4284
|
+
newThresholdSignatureScheme,
|
|
4285
|
+
resolvedDelegation
|
|
4286
|
+
}
|
|
4287
|
+
});
|
|
4219
4288
|
});
|
|
4220
4289
|
this.logger.info('[WaasReshare] calling reshare API', {
|
|
4221
4290
|
context: {
|
|
@@ -4238,37 +4307,8 @@ class DynamicWalletClient {
|
|
|
4238
4307
|
mfaToken,
|
|
4239
4308
|
elevatedAccessToken,
|
|
4240
4309
|
revokeDelegation,
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
this.logger.info('[WaasReshare] ceremony_complete received', {
|
|
4244
|
-
context: {
|
|
4245
|
-
walletId: wallet.walletId,
|
|
4246
|
-
newShareSetId: shareSetId,
|
|
4247
|
-
shareSetType,
|
|
4248
|
-
dynamicRequestId
|
|
4249
|
-
}
|
|
4250
|
-
});
|
|
4251
|
-
this.logOnMalformedCeremonyPayload({
|
|
4252
|
-
walletId: wallet.walletId,
|
|
4253
|
-
ceremony: 'reshare',
|
|
4254
|
-
shareSetId,
|
|
4255
|
-
shareSetType
|
|
4256
|
-
});
|
|
4257
|
-
this.rotateRootUserShareSetIdIfChanged({
|
|
4258
|
-
accountAddress,
|
|
4259
|
-
wallet,
|
|
4260
|
-
ceremony: 'reshare',
|
|
4261
|
-
shareSetId,
|
|
4262
|
-
shareSetType,
|
|
4263
|
-
newThresholdSignatureScheme,
|
|
4264
|
-
extraContext: {
|
|
4265
|
-
oldThresholdSignatureScheme,
|
|
4266
|
-
newThresholdSignatureScheme,
|
|
4267
|
-
resolvedDelegation
|
|
4268
|
-
}
|
|
4269
|
-
});
|
|
4270
|
-
ceremonyCompleteResolver(undefined);
|
|
4271
|
-
}
|
|
4310
|
+
onError: ceremonyGate.onError,
|
|
4311
|
+
onCeremonyComplete: ceremonyGate.onCeremonyComplete
|
|
4272
4312
|
});
|
|
4273
4313
|
this.logger.info('[WaasReshare] room_created received, starting local MPC', {
|
|
4274
4314
|
context: {
|
|
@@ -4377,38 +4417,11 @@ class DynamicWalletClient {
|
|
|
4377
4417
|
this.logger.info('[WaasReshare] local MPC done, awaiting ceremony_complete before backup', {
|
|
4378
4418
|
context: {
|
|
4379
4419
|
walletId: wallet.walletId,
|
|
4380
|
-
ceremonyCallbackFired,
|
|
4420
|
+
ceremonyCallbackFired: ceremonyGate.fired,
|
|
4381
4421
|
dynamicRequestId
|
|
4382
4422
|
}
|
|
4383
4423
|
});
|
|
4384
|
-
|
|
4385
|
-
// onCeremonyComplete lands in walletMap before the backup call reads it.
|
|
4386
|
-
// Bounded by a 5s timeout so a missing/late event never hangs the SDK.
|
|
4387
|
-
const RESHARE_CEREMONY_COMPLETE_TIMEOUT_MS = 5000;
|
|
4388
|
-
let ceremonyCompleteTimedOut = false;
|
|
4389
|
-
let reshareTimeoutId;
|
|
4390
|
-
await Promise.race([
|
|
4391
|
-
ceremonyCompletePromise,
|
|
4392
|
-
new Promise((resolve)=>{
|
|
4393
|
-
reshareTimeoutId = setTimeout(()=>{
|
|
4394
|
-
ceremonyCompleteTimedOut = true;
|
|
4395
|
-
resolve(undefined);
|
|
4396
|
-
}, RESHARE_CEREMONY_COMPLETE_TIMEOUT_MS);
|
|
4397
|
-
})
|
|
4398
|
-
]);
|
|
4399
|
-
// Cancel the pending timer when ceremony_complete wins the race so it
|
|
4400
|
-
// doesn't fire later and flip ceremonyCompleteTimedOut after the fact.
|
|
4401
|
-
// Safe no-op when the timer has already fired.
|
|
4402
|
-
clearTimeout(reshareTimeoutId);
|
|
4403
|
-
if (ceremonyCompleteTimedOut) {
|
|
4404
|
-
this.logger.warn(`[WaasReshare] ceremony_complete didn’t arrive within ${RESHARE_CEREMONY_COMPLETE_TIMEOUT_MS}ms — proceeding with stale walletMap`, {
|
|
4405
|
-
context: {
|
|
4406
|
-
walletId: wallet.walletId,
|
|
4407
|
-
ceremonyCallbackFired,
|
|
4408
|
-
dynamicRequestId
|
|
4409
|
-
}
|
|
4410
|
-
});
|
|
4411
|
-
}
|
|
4424
|
+
await ceremonyGate.awaitTerminal();
|
|
4412
4425
|
// Backup & activate server shares BEFORE saving to local storage.
|
|
4413
4426
|
// This prevents a mismatch where new client shares are stored locally
|
|
4414
4427
|
// but old server shares remain active if backup/activation fails.
|
|
@@ -4446,18 +4459,16 @@ class DynamicWalletClient {
|
|
|
4446
4459
|
});
|
|
4447
4460
|
}
|
|
4448
4461
|
// Operation summary — correlates against downstream sign failures /
|
|
4449
|
-
// pending-rotation reports.
|
|
4450
|
-
//
|
|
4451
|
-
// SSE event was lost mid-flight.
|
|
4462
|
+
// pending-rotation reports. The gate always fired here (we throw before
|
|
4463
|
+
// backup otherwise); kept for log continuity.
|
|
4452
4464
|
this.logger.info('[WaasReshare] completed', {
|
|
4453
4465
|
context: {
|
|
4454
|
-
ceremonyCompleteTimedOut,
|
|
4455
4466
|
walletId: wallet.walletId,
|
|
4456
4467
|
oldShareSetId: wallet.shareSetId,
|
|
4457
4468
|
oldThresholdSignatureScheme,
|
|
4458
4469
|
newThresholdSignatureScheme,
|
|
4459
4470
|
resolvedDelegation,
|
|
4460
|
-
ceremonyCallbackFired
|
|
4471
|
+
ceremonyCallbackFired: ceremonyGate.fired
|
|
4461
4472
|
}
|
|
4462
4473
|
});
|
|
4463
4474
|
} catch (error) {
|
|
@@ -5297,8 +5308,7 @@ class DynamicWalletClient {
|
|
|
5297
5308
|
try {
|
|
5298
5309
|
var _preEncryptedCloudShares_, _this_getWalletFromMap, _backupData_locationsWithKeyShares, _backupData_locationsWithKeyShares1;
|
|
5299
5310
|
// `let` so the retry path can swap in a freshly-signed session via the reverse channel on 400.
|
|
5300
|
-
let resolvedSignedSessionId = await this.
|
|
5301
|
-
const canRefreshSignedSessionId = this.getSignedSessionIdCallback !== undefined;
|
|
5311
|
+
let resolvedSignedSessionId = await this.signedSession.resolve(signedSessionId);
|
|
5302
5312
|
if (!(walletData == null ? void 0 : walletData.walletId)) {
|
|
5303
5313
|
const error = new Error(`WalletId not found for accountAddress ${accountAddress}`);
|
|
5304
5314
|
logError({
|
|
@@ -5391,19 +5401,18 @@ class DynamicWalletClient {
|
|
|
5391
5401
|
passwordUpdateBatchId
|
|
5392
5402
|
}), {
|
|
5393
5403
|
operationName,
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
}
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
}
|
|
5404
|
+
// Refresh a consumed nonce on a 400; otherwise back off and retry
|
|
5405
|
+
// transient statuses (undefined / 429 / 5xx).
|
|
5406
|
+
shouldRetry: this.signedSession.refreshShouldRetry({
|
|
5407
|
+
onRefreshed: (id)=>{
|
|
5408
|
+
resolvedSignedSessionId = id;
|
|
5409
|
+
},
|
|
5410
|
+
operationName,
|
|
5411
|
+
logContext: {
|
|
5412
|
+
dynamicRequestId
|
|
5413
|
+
},
|
|
5414
|
+
alsoRetry: (status)=>status === undefined || status === 429 || status !== undefined && status >= 500
|
|
5415
|
+
})
|
|
5407
5416
|
}));
|
|
5408
5417
|
}
|
|
5409
5418
|
// Cloud providers already have internal retries (Google Drive and iCloud),
|
|
@@ -5593,7 +5602,7 @@ class DynamicWalletClient {
|
|
|
5593
5602
|
// still throw — the host can re-auth from the error.
|
|
5594
5603
|
let resolvedSessionId;
|
|
5595
5604
|
try {
|
|
5596
|
-
resolvedSessionId = await this.
|
|
5605
|
+
resolvedSessionId = await this.signedSession.resolve(signedSessionId);
|
|
5597
5606
|
} catch (e) {
|
|
5598
5607
|
resolvedSessionId = undefined;
|
|
5599
5608
|
}
|
|
@@ -5951,13 +5960,14 @@ class DynamicWalletClient {
|
|
|
5951
5960
|
externalKeyShareIds: shares[BackupLocation.DYNAMIC] || []
|
|
5952
5961
|
}
|
|
5953
5962
|
});
|
|
5954
|
-
const result = await this.
|
|
5963
|
+
const result = await this.recoverEncryptedSharesWithNonceRetry({
|
|
5955
5964
|
walletId: wallet.walletId,
|
|
5956
5965
|
shareSetId: wallet.shareSetId,
|
|
5957
5966
|
externalKeyShareIds: shares[BackupLocation.DYNAMIC] || [],
|
|
5958
|
-
signedSessionId
|
|
5959
|
-
|
|
5960
|
-
|
|
5967
|
+
signedSessionId
|
|
5968
|
+
}, {
|
|
5969
|
+
walletId: wallet.walletId,
|
|
5970
|
+
context: 'validatePasswordAgainstEncryptedShares'
|
|
5961
5971
|
});
|
|
5962
5972
|
if (!result) {
|
|
5963
5973
|
throw new Error('Failed to retrieve encrypted backup from API for password validation');
|
|
@@ -6148,6 +6158,32 @@ class DynamicWalletClient {
|
|
|
6148
6158
|
mfaToken
|
|
6149
6159
|
}));
|
|
6150
6160
|
}
|
|
6161
|
+
/**
|
|
6162
|
+
* Fetches encrypted backup shares from the server, transparently refreshing
|
|
6163
|
+
* the signed session on a single-use-nonce rejection. Recovery is otherwise
|
|
6164
|
+
* non-retrying, so other errors surface immediately. When no reverse channel
|
|
6165
|
+
* is configured the provided session is used unchanged.
|
|
6166
|
+
*/ async recoverEncryptedSharesWithNonceRetry(params, logContext) {
|
|
6167
|
+
let resolvedSignedSessionId = params.signedSessionId;
|
|
6168
|
+
return retryPromise(()=>this.apiClient.recoverEncryptedBackupByWallet(_extends({}, params, {
|
|
6169
|
+
signedSessionId: resolvedSignedSessionId,
|
|
6170
|
+
requiresSignedSessionId: this.requiresSignedSessionId(),
|
|
6171
|
+
userId: this.userId
|
|
6172
|
+
})), {
|
|
6173
|
+
operationName: 'recoverEncryptedBackupByWallet',
|
|
6174
|
+
// At most one retry — the nonce refresh — and no back-off before it,
|
|
6175
|
+
// since a consumed-nonce 400 is deterministic, not a transient error.
|
|
6176
|
+
maxAttempts: 2,
|
|
6177
|
+
retryInterval: 0,
|
|
6178
|
+
shouldRetry: this.signedSession.refreshShouldRetry({
|
|
6179
|
+
onRefreshed: (id)=>{
|
|
6180
|
+
resolvedSignedSessionId = id;
|
|
6181
|
+
},
|
|
6182
|
+
operationName: 'recoverEncryptedBackupByWallet',
|
|
6183
|
+
logContext
|
|
6184
|
+
})
|
|
6185
|
+
});
|
|
6186
|
+
}
|
|
6151
6187
|
async internalRecoverEncryptedBackupByWallet({ accountAddress, password, walletOperation, signedSessionId, shareCount = undefined, storeRecoveredShares = true, mfaToken }) {
|
|
6152
6188
|
try {
|
|
6153
6189
|
const wallet = this.getWalletFromMap(accountAddress);
|
|
@@ -6168,14 +6204,15 @@ class DynamicWalletClient {
|
|
|
6168
6204
|
thresholdSignatureScheme: wallet.thresholdSignatureScheme
|
|
6169
6205
|
}
|
|
6170
6206
|
});
|
|
6171
|
-
const data = await this.
|
|
6207
|
+
const data = await this.recoverEncryptedSharesWithNonceRetry({
|
|
6172
6208
|
walletId: wallet.walletId,
|
|
6173
6209
|
shareSetId: wallet.shareSetId,
|
|
6174
6210
|
externalKeyShareIds: dynamicKeyShareIds,
|
|
6175
6211
|
signedSessionId,
|
|
6176
|
-
mfaToken
|
|
6177
|
-
|
|
6178
|
-
|
|
6212
|
+
mfaToken
|
|
6213
|
+
}, {
|
|
6214
|
+
walletId: wallet.walletId,
|
|
6215
|
+
walletOperation
|
|
6179
6216
|
});
|
|
6180
6217
|
const dynamicKeyShares = data.keyShares.filter((keyShare)=>keyShare.encryptedAccountCredential !== null && keyShare.backupLocation === BackupLocation.DYNAMIC);
|
|
6181
6218
|
const decryptedKeyShares = await Promise.all(dynamicKeyShares.map((keyShare)=>// `decryptKeyShare` owns the `environmentId` fallback; passing
|
|
@@ -6843,8 +6880,10 @@ class DynamicWalletClient {
|
|
|
6843
6880
|
})) {
|
|
6844
6881
|
const walletData = this.getWalletFromMap(accountAddress);
|
|
6845
6882
|
const isPasswordEncrypted = isWalletPasswordEncrypted(walletData);
|
|
6846
|
-
//
|
|
6847
|
-
|
|
6883
|
+
// Locked wallet (password-encrypted, no password supplied): its shares need the
|
|
6884
|
+
// user's password, not the environmentId. Return it ENCRYPTED for any operation
|
|
6885
|
+
// rather than fall through to an envId-decrypt that throws KeyShareDecryptionError.
|
|
6886
|
+
if (isPasswordEncrypted && !password) {
|
|
6848
6887
|
// Skip fetch if encrypted shares are already cached in storage
|
|
6849
6888
|
const alreadyCached = await hasEncryptedSharesCached({
|
|
6850
6889
|
accountAddress,
|
|
@@ -6865,28 +6904,32 @@ class DynamicWalletClient {
|
|
|
6865
6904
|
}
|
|
6866
6905
|
return cachedWallet;
|
|
6867
6906
|
}
|
|
6907
|
+
// RECOVER on purpose: cache the full set a later unlockWallet needs, not the triggering op's subset.
|
|
6868
6908
|
const { shares } = this.recoverStrategy({
|
|
6869
6909
|
clientKeyShareBackupInfo: walletData.clientKeySharesBackupInfo,
|
|
6870
6910
|
thresholdSignatureScheme: walletData.thresholdSignatureScheme,
|
|
6871
6911
|
walletOperation: WalletOperation.RECOVER
|
|
6872
6912
|
});
|
|
6873
6913
|
const { dynamic: dynamicKeyShareIds } = shares;
|
|
6874
|
-
this.logger.info('[walletMap] getWallet:
|
|
6914
|
+
this.logger.info('[walletMap] getWallet: caching encrypted shares for locked wallet', {
|
|
6875
6915
|
context: {
|
|
6876
6916
|
accountAddress,
|
|
6877
6917
|
walletId: walletData.walletId,
|
|
6878
6918
|
externalKeyShareIds: dynamicKeyShareIds,
|
|
6879
6919
|
dynamicRequestId,
|
|
6880
|
-
isPasswordEncrypted
|
|
6920
|
+
isPasswordEncrypted,
|
|
6921
|
+
walletOperation
|
|
6881
6922
|
}
|
|
6882
6923
|
});
|
|
6883
|
-
const data = await this.
|
|
6924
|
+
const data = await this.recoverEncryptedSharesWithNonceRetry({
|
|
6884
6925
|
walletId: walletData.walletId,
|
|
6885
6926
|
shareSetId: walletData.shareSetId,
|
|
6886
6927
|
externalKeyShareIds: dynamicKeyShareIds,
|
|
6887
|
-
signedSessionId
|
|
6888
|
-
|
|
6889
|
-
|
|
6928
|
+
signedSessionId
|
|
6929
|
+
}, {
|
|
6930
|
+
walletId: walletData.walletId,
|
|
6931
|
+
context: 'getWallet:eagerLoad',
|
|
6932
|
+
dynamicRequestId
|
|
6890
6933
|
});
|
|
6891
6934
|
// Cache encrypted shares for later decryption by unlockWallet
|
|
6892
6935
|
const encryptedStorageKey = `${normalizeAddress(accountAddress)}${ENCRYPTED_SHARES_STORAGE_SUFFIX}`;
|
|
@@ -7023,13 +7066,14 @@ class DynamicWalletClient {
|
|
|
7023
7066
|
externalKeyShareIds
|
|
7024
7067
|
}
|
|
7025
7068
|
});
|
|
7026
|
-
const data = await this.
|
|
7069
|
+
const data = await this.recoverEncryptedSharesWithNonceRetry({
|
|
7027
7070
|
walletId: walletData.walletId,
|
|
7028
7071
|
externalKeyShareIds,
|
|
7029
7072
|
signedSessionId,
|
|
7030
|
-
mfaToken
|
|
7031
|
-
|
|
7032
|
-
|
|
7073
|
+
mfaToken
|
|
7074
|
+
}, {
|
|
7075
|
+
walletId: walletData.walletId,
|
|
7076
|
+
context: 'fetchAndCacheEncryptedShares'
|
|
7033
7077
|
});
|
|
7034
7078
|
if (!data) {
|
|
7035
7079
|
return null;
|
|
@@ -7477,6 +7521,13 @@ class DynamicWalletClient {
|
|
|
7477
7521
|
if (internalOptions == null ? void 0 : internalOptions.getSignedSessionId) {
|
|
7478
7522
|
this.getSignedSessionIdCallback = internalOptions.getSignedSessionId;
|
|
7479
7523
|
}
|
|
7524
|
+
// Read the callback lazily so it tracks `getSignedSessionIdCallback` even
|
|
7525
|
+
// when tests reassign it after construction.
|
|
7526
|
+
this.signedSession = new SignedSessionManager({
|
|
7527
|
+
logger: this.logger,
|
|
7528
|
+
sdkVersion: this.sdkVersion,
|
|
7529
|
+
getCallback: ()=>this.getSignedSessionIdCallback
|
|
7530
|
+
});
|
|
7480
7531
|
this.apiClient = new DynamicApiClient({
|
|
7481
7532
|
environmentId,
|
|
7482
7533
|
authToken,
|