@dynamic-labs-wallet/browser 1.0.81 → 1.0.83
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 +303 -243
- package/index.esm.js +304 -244
- package/package.json +3 -3
- package/src/backup/password/errors.d.ts +5 -1
- package/src/backup/password/errors.d.ts.map +1 -1
- package/src/client.d.ts.map +1 -1
- package/src/collectDynamicKeyShareIds.d.ts +3 -0
- package/src/collectDynamicKeyShareIds.d.ts.map +1 -0
- package/src/utils.d.ts.map +1 -1
package/index.cjs
CHANGED
|
@@ -7,8 +7,8 @@ var primitives = require('@dynamic-labs-wallet/primitives');
|
|
|
7
7
|
var sdkApiCore = require('@dynamic-labs/sdk-api-core');
|
|
8
8
|
var loadArgon2idWasm = require('argon2id');
|
|
9
9
|
var axios = require('axios');
|
|
10
|
-
var PQueue = require('p-queue');
|
|
11
10
|
var gte = require('semver/functions/gte');
|
|
11
|
+
var PQueue = require('p-queue');
|
|
12
12
|
|
|
13
13
|
function _extends() {
|
|
14
14
|
_extends = Object.assign || function assign(target) {
|
|
@@ -463,6 +463,237 @@ class EncryptionSelfTestFailedError extends Error {
|
|
|
463
463
|
}
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Extracts the HTTP status from an `AxiosError`, or `undefined` for non-HTTP
|
|
468
|
+
* errors. Lives in its own leaf module (no `#internal/web` / WASM imports) so
|
|
469
|
+
* lightweight consumers like `services/signedSession.ts` can use it without
|
|
470
|
+
* pulling the MPC WASM bundle that `utils.ts` loads.
|
|
471
|
+
*/ const getHttpStatus = (error)=>{
|
|
472
|
+
var _error_response;
|
|
473
|
+
return error instanceof axios.AxiosError ? (_error_response = error.response) == null ? void 0 : _error_response.status : undefined;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const STORAGE_KEY = 'dynamic-waas-wallet-client';
|
|
477
|
+
const BACKUP_FILENAME = 'dynamicWalletKeyShareBackup.json';
|
|
478
|
+
const CLIENT_KEYSHARE_EXPORT_FILENAME_PREFIX = 'dynamicWalletKeyShareBackup';
|
|
479
|
+
const SIGNED_SESSION_ID_MIN_VERSION = '4.25.4';
|
|
480
|
+
// Namespace-specific version requirements
|
|
481
|
+
const SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE = {
|
|
482
|
+
WalletKit: '4.25.4',
|
|
483
|
+
ClientSDK: '0.1.0-alpha.0'
|
|
484
|
+
};
|
|
485
|
+
// Minimum SDK version that forwards `getSignedSessionId` to the iframe's postMessage
|
|
486
|
+
// reverse channel, distinct from SIGNED_SESSION_ID_MIN_VERSION above (which only
|
|
487
|
+
// gates whether a signed session value is sent at all).
|
|
488
|
+
// WalletKit: dynamic-auth#10577 (v4.74.0). ClientSDK: dynamic-js-sdk#1937 (v1.12.1).
|
|
489
|
+
const SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION = '4.74.0';
|
|
490
|
+
const SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION_BY_NAMESPACE = {
|
|
491
|
+
WalletKit: SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION,
|
|
492
|
+
ClientSDK: '1.12.1'
|
|
493
|
+
};
|
|
494
|
+
const ROOM_EXPIRATION_TIME = 1000 * 60 * 10; // 10 minutes
|
|
495
|
+
const ROOM_CACHE_COUNT = 5;
|
|
496
|
+
const ENVIRONMENT_SETTINGS_STORAGE_KEY = 'dynamic_environment_settings';
|
|
497
|
+
|
|
498
|
+
function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion, label }, logger) {
|
|
499
|
+
if (!sdkVersion) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const parsedVersion = core.parseNamespacedVersion(sdkVersion);
|
|
504
|
+
if (!parsedVersion) {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
const { namespace, version } = parsedVersion;
|
|
508
|
+
var _minVersionByNamespace_namespace;
|
|
509
|
+
return gte(version, (_minVersionByNamespace_namespace = minVersionByNamespace[namespace]) != null ? _minVersionByNamespace_namespace : fallbackMinVersion);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
logger.warn(`[DynamicWaasWalletClient] Error checking ${label} for version ${sdkVersion}`, {
|
|
512
|
+
error: error instanceof Error ? error.message : String(error)
|
|
513
|
+
});
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** Whether the given SDK version requires a signed session ID to be sent at all. */ function isRequiresSignedSessionId(sdkVersion, logger) {
|
|
518
|
+
return meetsMinVersion(sdkVersion, {
|
|
519
|
+
minVersionByNamespace: SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE,
|
|
520
|
+
fallbackMinVersion: SIGNED_SESSION_ID_MIN_VERSION,
|
|
521
|
+
label: 'requiresSignedSessionId'
|
|
522
|
+
}, logger);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Whether the SDK version is known to forward `getSignedSessionId` to the iframe's
|
|
526
|
+
* postMessage reverse channel — stricter than {@link isRequiresSignedSessionId},
|
|
527
|
+
* which only checks whether a session value is sent at all.
|
|
528
|
+
*/ function isReverseChannelSupported(sdkVersion, logger) {
|
|
529
|
+
return meetsMinVersion(sdkVersion, {
|
|
530
|
+
minVersionByNamespace: SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION_BY_NAMESPACE,
|
|
531
|
+
fallbackMinVersion: SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION,
|
|
532
|
+
label: 'supportsReverseChannel'
|
|
533
|
+
}, logger);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Datadog event name emitted on every signed-session nonce rejection (a 400 or
|
|
538
|
+
* 401 from the backup/recovery endpoints). Log-only: lets us split the failure
|
|
539
|
+
* bucket by `reason` and measure the true per-wallet unrecoverable rate
|
|
540
|
+
* without changing any retry behavior. See DYNT-1599.
|
|
541
|
+
*/ const NONCE_FAILURE_EVENT = 'waas.signed_session.nonce_failure';
|
|
542
|
+
/**
|
|
543
|
+
* Reasons that represent a genuine session-key signature mismatch (the 401 the
|
|
544
|
+
* server returns per DYNT-1282). Used to gate 401 instrumentation so callers
|
|
545
|
+
* that reuse 401 for unrelated rejections don't pollute the failure metric.
|
|
546
|
+
*/ const SESSION_SIGNATURE_FAILURE_REASONS = new Set([
|
|
547
|
+
'invalid_nonce_signature',
|
|
548
|
+
'invalid_session_signature'
|
|
549
|
+
]);
|
|
550
|
+
/** Best-effort extraction of the server error `code`/`message` from a 4xx body. */ const readErrorBody = (error)=>{
|
|
551
|
+
var _error_response;
|
|
552
|
+
if (!(error instanceof axios.AxiosError)) {
|
|
553
|
+
return {};
|
|
554
|
+
}
|
|
555
|
+
const data = (_error_response = error.response) == null ? void 0 : _error_response.data;
|
|
556
|
+
if (typeof data === 'string') {
|
|
557
|
+
return {
|
|
558
|
+
message: data
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
if (!data || typeof data !== 'object') {
|
|
562
|
+
return {};
|
|
563
|
+
}
|
|
564
|
+
const record = data;
|
|
565
|
+
// Prefer a string `code`, else fall back to `error_code` (the field the Key
|
|
566
|
+
// Share Service emits, see KSS #271) or a bare `error`.
|
|
567
|
+
const code = [
|
|
568
|
+
record['code'],
|
|
569
|
+
record['error_code'],
|
|
570
|
+
record['error']
|
|
571
|
+
].find((value)=>typeof value === 'string');
|
|
572
|
+
const message = typeof record['message'] === 'string' ? record['message'] : undefined;
|
|
573
|
+
return {
|
|
574
|
+
code,
|
|
575
|
+
message
|
|
576
|
+
};
|
|
577
|
+
};
|
|
578
|
+
/** Maps the server error `code`/`message` onto a {@link NonceFailureReason}. */ const classifyNonceFailure = ({ code, message })=>{
|
|
579
|
+
const haystack = `${code != null ? code : ''} ${message != null ? message : ''}`.toLowerCase();
|
|
580
|
+
if (haystack.includes('already_used') || haystack.includes('already used') || haystack.includes('consumption failed')) {
|
|
581
|
+
return 'nonce_already_used';
|
|
582
|
+
}
|
|
583
|
+
if (haystack.includes('invalid_nonce_signature') || haystack.includes('invalid nonce signature')) {
|
|
584
|
+
return 'invalid_nonce_signature';
|
|
585
|
+
}
|
|
586
|
+
if (haystack.includes('invalid_session_signature') || haystack.includes('invalid session public key') || haystack.includes('invalid session signature')) {
|
|
587
|
+
return 'invalid_session_signature';
|
|
588
|
+
}
|
|
589
|
+
return 'other';
|
|
590
|
+
};
|
|
591
|
+
/**
|
|
592
|
+
* Owns everything about signed sessions and their single-use replay nonces:
|
|
593
|
+
* resolving a session from an explicit value or the host reverse channel,
|
|
594
|
+
* deciding whether the SDK version requires one, and building the
|
|
595
|
+
* refresh-on-replay-400 retry predicate shared by the backup and recovery
|
|
596
|
+
* flows.
|
|
597
|
+
*
|
|
598
|
+
* Extracted from `DynamicWalletClient` so this logic can be exercised in
|
|
599
|
+
* isolation. The host callback is read lazily (`getCallback`) so it stays in
|
|
600
|
+
* sync with the owning client even if reassigned after construction.
|
|
601
|
+
*/ class SignedSessionManager {
|
|
602
|
+
/** True when a reverse channel is configured to mint fresh signed sessions. */ get canRefresh() {
|
|
603
|
+
return this.getCallback() !== undefined;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Resolves the signed session ID from an explicit value or falls back to the
|
|
607
|
+
* host reverse channel. Throws if neither source provides a value.
|
|
608
|
+
*/ async resolve(signedSessionId) {
|
|
609
|
+
const callback = this.getCallback();
|
|
610
|
+
const resolved = signedSessionId != null ? signedSessionId : await (callback == null ? void 0 : callback());
|
|
611
|
+
if (!resolved) {
|
|
612
|
+
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');
|
|
613
|
+
}
|
|
614
|
+
return resolved;
|
|
615
|
+
}
|
|
616
|
+
/** Whether the configured SDK version requires a signed session ID. */ isRequired() {
|
|
617
|
+
return this.required;
|
|
618
|
+
}
|
|
619
|
+
/** Whether the host SDK version supports the getSignedSessionId reverse channel (see version-check.ts). */ supportsReverseChannel() {
|
|
620
|
+
return this.reverseChannelSupported;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Builds a `retryPromise` `shouldRetry` predicate that refreshes a consumed
|
|
624
|
+
* single-use nonce via the reverse channel on a replay 400 and retries once.
|
|
625
|
+
* The signed session carries a single-use replay nonce: it is valid on its
|
|
626
|
+
* first use, but once consumed (e.g. by a preceding operation) the server
|
|
627
|
+
* rejects with a 400, at which point a fresh session is fetched and the call
|
|
628
|
+
* retried. `alsoRetry` lets callers opt into additional retryable statuses
|
|
629
|
+
* (e.g. 429 / 5xx) on top of the nonce refresh.
|
|
630
|
+
*/ refreshShouldRetry({ onRefreshed, operationName, logContext, alsoRetry }) {
|
|
631
|
+
return async (error, attempt)=>{
|
|
632
|
+
const status = getHttpStatus(error);
|
|
633
|
+
// A 400 is a refreshable replay nonce; a 401 is a session-key mismatch the
|
|
634
|
+
// server returns to break the refresh loop (DYNT-1282) — logged only when the
|
|
635
|
+
// body classifies as a genuine session-signature failure so unrelated 401s
|
|
636
|
+
// (e.g. an exhausted add-signer grant) don't pollute the DYNT-1599 metric.
|
|
637
|
+
// Only the 400 can actually refresh.
|
|
638
|
+
const isSessionSignatureFailure = status === 401 && SESSION_SIGNATURE_FAILURE_REASONS.has(classifyNonceFailure(readErrorBody(error)));
|
|
639
|
+
const shouldInstrument = status === 400 || isSessionSignatureFailure;
|
|
640
|
+
if (shouldInstrument) {
|
|
641
|
+
const willRefresh = status === 400 && attempt === 1 && this.canRefresh && this.supportsReverseChannel();
|
|
642
|
+
this.instrumentNonceFailure({
|
|
643
|
+
error,
|
|
644
|
+
attempt,
|
|
645
|
+
status,
|
|
646
|
+
operationName,
|
|
647
|
+
willRefresh,
|
|
648
|
+
logContext
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if (status === 400 && attempt === 1 && this.canRefresh) {
|
|
652
|
+
// canRefresh only proves a local callback exists, not that the host answers it.
|
|
653
|
+
if (!this.supportsReverseChannel()) {
|
|
654
|
+
this.logger.warn(`[${operationName}] host SDK version predates the signed-session reverse channel, declining nonce refresh`, _extends({
|
|
655
|
+
status
|
|
656
|
+
}, logContext));
|
|
657
|
+
return alsoRetry ? alsoRetry(status) : false;
|
|
658
|
+
}
|
|
659
|
+
onRefreshed(await this.resolve());
|
|
660
|
+
this.logger.info(`[${operationName}] refreshed signed session, retrying`, _extends({
|
|
661
|
+
status
|
|
662
|
+
}, logContext));
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
return alsoRetry ? alsoRetry(status) : false;
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Emits a single Datadog log per signed-session nonce rejection so the
|
|
670
|
+
* failure bucket can be split by `reason` and correlated per wallet/request.
|
|
671
|
+
* Purely observational — it never alters the retry decision.
|
|
672
|
+
*/ instrumentNonceFailure({ error, attempt, status, operationName, willRefresh, logContext }) {
|
|
673
|
+
const body = readErrorBody(error);
|
|
674
|
+
this.logger.info(NONCE_FAILURE_EVENT, _extends({}, logContext, {
|
|
675
|
+
operationName,
|
|
676
|
+
attempt,
|
|
677
|
+
status,
|
|
678
|
+
reason: classifyNonceFailure(body),
|
|
679
|
+
errorCode: body.code,
|
|
680
|
+
errorMessage: body.message,
|
|
681
|
+
willRefresh,
|
|
682
|
+
canRefresh: this.canRefresh,
|
|
683
|
+
reverseChannelSupported: this.reverseChannelSupported,
|
|
684
|
+
required: this.required,
|
|
685
|
+
sdkVersion: this.sdkVersion
|
|
686
|
+
}));
|
|
687
|
+
}
|
|
688
|
+
constructor({ logger, sdkVersion, getCallback }){
|
|
689
|
+
this.logger = logger;
|
|
690
|
+
this.getCallback = getCallback;
|
|
691
|
+
this.sdkVersion = sdkVersion;
|
|
692
|
+
this.required = isRequiresSignedSessionId(sdkVersion, this.logger);
|
|
693
|
+
this.reverseChannelSupported = isReverseChannelSupported(sdkVersion, this.logger);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
466
697
|
const ERROR_NO_KEY_SHARES_BACKED_UP = 'No key shares were backed up to Dynamic backend';
|
|
467
698
|
const ERROR_KEYGEN_FAILED = '[DynamicWaasWalletClient]: Error with keygen';
|
|
468
699
|
const ERROR_CREATE_WALLET_ACCOUNT = '[DynamicWaasWalletClient]: Error creating wallet account';
|
|
@@ -555,6 +786,10 @@ const ERROR_WALLETS_ALREADY_ENCRYPTED = '[DynamicWaasWalletClient]: Cannot set p
|
|
|
555
786
|
*/ const USER_ACTIONABLE_PASSWORD_BACKUP_ERROR_REASONS = {
|
|
556
787
|
wrong_password: true,
|
|
557
788
|
stale_local_shares: false,
|
|
789
|
+
nonce_already_used: false,
|
|
790
|
+
invalid_nonce_signature: false,
|
|
791
|
+
invalid_session_signature: false,
|
|
792
|
+
signed_session: false,
|
|
558
793
|
encryption_failure: false,
|
|
559
794
|
decryption_failure: false,
|
|
560
795
|
upload_failure: false,
|
|
@@ -577,6 +812,30 @@ const FETCH_NETWORK_ERROR_REGEX = /failed to fetch|networkerror|load failed|netw
|
|
|
577
812
|
const ENCRYPTION_FAILURE_MESSAGE_PREFIX = 'Error encrypting data:';
|
|
578
813
|
const DECRYPTION_FAILURE_MESSAGE_PREFIX = 'Decryption failed:';
|
|
579
814
|
const isStaleSharesError = (error)=>error instanceof StaleLocalSharesError || error instanceof Error && error.name === 'StaleLocalSharesError';
|
|
815
|
+
// The backup/recovery endpoints reject the chaining signature with a 400
|
|
816
|
+
// (stale signed session / replay nonce) or 401 (session-key mismatch,
|
|
817
|
+
// DYNT-1282); the preserved backend code splits those into their precise
|
|
818
|
+
// cause so the failure is actionable instead of a generic `unknown`.
|
|
819
|
+
const SIGNED_SESSION_FAILURE_STATUSES = new Set([
|
|
820
|
+
400,
|
|
821
|
+
401
|
|
822
|
+
]);
|
|
823
|
+
const NONCE_REASON_TO_BACKUP_REASON = {
|
|
824
|
+
nonce_already_used: 'nonce_already_used',
|
|
825
|
+
invalid_nonce_signature: 'invalid_nonce_signature',
|
|
826
|
+
invalid_session_signature: 'invalid_session_signature',
|
|
827
|
+
other: 'signed_session'
|
|
828
|
+
};
|
|
829
|
+
const classifyWalletApiError = (error)=>{
|
|
830
|
+
if (!SIGNED_SESSION_FAILURE_STATUSES.has(error.status)) {
|
|
831
|
+
return 'upload_failure';
|
|
832
|
+
}
|
|
833
|
+
const nonceReason = classifyNonceFailure({
|
|
834
|
+
code: error.code,
|
|
835
|
+
message: error.message
|
|
836
|
+
});
|
|
837
|
+
return NONCE_REASON_TO_BACKUP_REASON[nonceReason];
|
|
838
|
+
};
|
|
580
839
|
const isNetworkLikeError = (error)=>{
|
|
581
840
|
if (typeof error !== 'object' || error === null) {
|
|
582
841
|
return false;
|
|
@@ -628,6 +887,12 @@ const computeReason = (error)=>{
|
|
|
628
887
|
if (isStaleSharesError(error)) {
|
|
629
888
|
return 'stale_local_shares';
|
|
630
889
|
}
|
|
890
|
+
// WalletApiError is a wrapped HTTP failure carrying the resolved status; its
|
|
891
|
+
// message is a generic status string ('Invalid request', etc.) so classify
|
|
892
|
+
// by status ahead of the message-based checks below.
|
|
893
|
+
if (error instanceof core.WalletApiError) {
|
|
894
|
+
return classifyWalletApiError(error);
|
|
895
|
+
}
|
|
631
896
|
if (error instanceof Error) {
|
|
632
897
|
if (error.message.includes(ERROR_PASSWORD_MISMATCH) || error.message.includes(ERROR_INCORRECT_PASSWORD)) {
|
|
633
898
|
return 'wrong_password';
|
|
@@ -995,38 +1260,6 @@ const downloadFileFromGoogleDrive = async ({ accessToken, fileName })=>{
|
|
|
995
1260
|
}
|
|
996
1261
|
};
|
|
997
1262
|
|
|
998
|
-
/**
|
|
999
|
-
* Extracts the HTTP status from an `AxiosError`, or `undefined` for non-HTTP
|
|
1000
|
-
* errors. Lives in its own leaf module (no `#internal/web` / WASM imports) so
|
|
1001
|
-
* lightweight consumers like `services/signedSession.ts` can use it without
|
|
1002
|
-
* pulling the MPC WASM bundle that `utils.ts` loads.
|
|
1003
|
-
*/ const getHttpStatus = (error)=>{
|
|
1004
|
-
var _error_response;
|
|
1005
|
-
return error instanceof axios.AxiosError ? (_error_response = error.response) == null ? void 0 : _error_response.status : undefined;
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
const STORAGE_KEY = 'dynamic-waas-wallet-client';
|
|
1009
|
-
const BACKUP_FILENAME = 'dynamicWalletKeyShareBackup.json';
|
|
1010
|
-
const CLIENT_KEYSHARE_EXPORT_FILENAME_PREFIX = 'dynamicWalletKeyShareBackup';
|
|
1011
|
-
const SIGNED_SESSION_ID_MIN_VERSION = '4.25.4';
|
|
1012
|
-
// Namespace-specific version requirements
|
|
1013
|
-
const SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE = {
|
|
1014
|
-
WalletKit: '4.25.4',
|
|
1015
|
-
ClientSDK: '0.1.0-alpha.0'
|
|
1016
|
-
};
|
|
1017
|
-
// Minimum SDK version that forwards `getSignedSessionId` to the iframe's postMessage
|
|
1018
|
-
// reverse channel, distinct from SIGNED_SESSION_ID_MIN_VERSION above (which only
|
|
1019
|
-
// gates whether a signed session value is sent at all).
|
|
1020
|
-
// WalletKit: dynamic-auth#10577 (v4.74.0). ClientSDK: dynamic-js-sdk#1937 (v1.12.1).
|
|
1021
|
-
const SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION = '4.74.0';
|
|
1022
|
-
const SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION_BY_NAMESPACE = {
|
|
1023
|
-
WalletKit: SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION,
|
|
1024
|
-
ClientSDK: '1.12.1'
|
|
1025
|
-
};
|
|
1026
|
-
const ROOM_EXPIRATION_TIME = 1000 * 60 * 10; // 10 minutes
|
|
1027
|
-
const ROOM_CACHE_COUNT = 5;
|
|
1028
|
-
const ENVIRONMENT_SETTINGS_STORAGE_KEY = 'dynamic_environment_settings';
|
|
1029
|
-
|
|
1030
1263
|
/**
|
|
1031
1264
|
* Normalizes an address to lowercase for consistent map key lookups.
|
|
1032
1265
|
* This ensures that addresses with different casing (e.g., EIP-55 checksummed vs lowercase)
|
|
@@ -1093,15 +1326,20 @@ const getClientKeyShareBackupInfo = (params)=>{
|
|
|
1093
1326
|
const timeoutPromise = ({ timeInMs, activity = 'Ceremony' })=>{
|
|
1094
1327
|
return new Promise((_, reject)=>setTimeout(()=>reject(new Error(`${activity} did not complete in ${timeInMs}ms`)), timeInMs));
|
|
1095
1328
|
};
|
|
1096
|
-
const
|
|
1097
|
-
var _error_response, _error_response1;
|
|
1329
|
+
const buildAxiosErrorContext = (error)=>{
|
|
1330
|
+
var _error_response, _error_response1, _error_request, _error_config;
|
|
1331
|
+
var _error_response_status;
|
|
1098
1332
|
return {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1333
|
+
axiosError: (_error_response = error.response) == null ? void 0 : _error_response.data,
|
|
1334
|
+
axiosStatus: (_error_response_status = (_error_response1 = error.response) == null ? void 0 : _error_response1.status) != null ? _error_response_status : (_error_request = error.request) == null ? void 0 : _error_request.status,
|
|
1335
|
+
axiosErrorCode: error.code,
|
|
1336
|
+
axiosUrl: (_error_config = error.config) == null ? void 0 : _error_config.url
|
|
1103
1337
|
};
|
|
1104
1338
|
};
|
|
1339
|
+
const buildErrorContext = (error)=>_extends({
|
|
1340
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
1341
|
+
errorStack: error instanceof Error ? error.stack : undefined
|
|
1342
|
+
}, error instanceof axios.AxiosError && buildAxiosErrorContext(error));
|
|
1105
1343
|
const logRetrySuccess = (operationName, attempts, logContext)=>{
|
|
1106
1344
|
core.Logger.info(`Successfully executed ${operationName} after ${attempts + 1} attempts`, _extends({}, logContext, {
|
|
1107
1345
|
attemptsTaken: attempts + 1
|
|
@@ -1133,6 +1371,7 @@ const logRetryExhausted = (operationName, maxAttempts, errorContext, logContext)
|
|
|
1133
1371
|
*/ async function retryPromise(operation, { maxAttempts = 5, retryInterval = 500, operationName = 'operation', logContext = {}, shouldRetry } = {}) {
|
|
1134
1372
|
let attempts = 0;
|
|
1135
1373
|
while(attempts < maxAttempts){
|
|
1374
|
+
const attemptStartMs = Date.now();
|
|
1136
1375
|
try {
|
|
1137
1376
|
const result = await operation();
|
|
1138
1377
|
if (attempts > 0) {
|
|
@@ -1143,9 +1382,12 @@ const logRetryExhausted = (operationName, maxAttempts, errorContext, logContext)
|
|
|
1143
1382
|
attempts++;
|
|
1144
1383
|
const errorContext = buildErrorContext(error);
|
|
1145
1384
|
const isNonRetryable = (error == null ? void 0 : error.isRetryable) === false;
|
|
1146
|
-
|
|
1385
|
+
const attemptLogContext = _extends({}, logContext, {
|
|
1386
|
+
requestDurationMs: Date.now() - attemptStartMs
|
|
1387
|
+
});
|
|
1388
|
+
logRetryFailure(operationName, attempts, maxAttempts, errorContext, attemptLogContext, isNonRetryable);
|
|
1147
1389
|
if (isNonRetryable) {
|
|
1148
|
-
core.Logger.debug(`Skipping retry for ${operationName}: error marked non-retryable`, _extends({},
|
|
1390
|
+
core.Logger.debug(`Skipping retry for ${operationName}: error marked non-retryable`, _extends({}, attemptLogContext, errorContext));
|
|
1149
1391
|
throw error;
|
|
1150
1392
|
}
|
|
1151
1393
|
if (shouldRetry) {
|
|
@@ -1153,7 +1395,7 @@ const logRetryExhausted = (operationName, maxAttempts, errorContext, logContext)
|
|
|
1153
1395
|
try {
|
|
1154
1396
|
allow = await shouldRetry(error, attempts);
|
|
1155
1397
|
} catch (predicateError) {
|
|
1156
|
-
core.Logger.warn(`shouldRetry predicate failed for ${operationName}, surfacing original error`, _extends({},
|
|
1398
|
+
core.Logger.warn(`shouldRetry predicate failed for ${operationName}, surfacing original error`, _extends({}, attemptLogContext, errorContext, {
|
|
1157
1399
|
predicateError: predicateError instanceof Error ? predicateError.message : 'Unknown error'
|
|
1158
1400
|
}));
|
|
1159
1401
|
}
|
|
@@ -1162,7 +1404,7 @@ const logRetryExhausted = (operationName, maxAttempts, errorContext, logContext)
|
|
|
1162
1404
|
}
|
|
1163
1405
|
}
|
|
1164
1406
|
if (attempts === maxAttempts) {
|
|
1165
|
-
logRetryExhausted(operationName, maxAttempts, errorContext,
|
|
1407
|
+
logRetryExhausted(operationName, maxAttempts, errorContext, attemptLogContext);
|
|
1166
1408
|
throw error;
|
|
1167
1409
|
}
|
|
1168
1410
|
const exponentialDelay = retryInterval * 2 ** (attempts - 1);
|
|
@@ -2412,6 +2654,22 @@ const createDynamicOnlyDistribution = ({ allShares })=>({
|
|
|
2412
2654
|
/** Track which wallets are currently executing inside a sign operation.
|
|
2413
2655
|
* Used to prevent deadlocks when recovery is called from within a sign op. */ WalletQueueManager.walletsWithActiveSignOp = new Map();
|
|
2414
2656
|
|
|
2657
|
+
// A DYNAMIC backup can persist multiple shares (e.g. 2-of-3); the singular
|
|
2658
|
+
// externalKeyShareId is a fallback for older single-id shapes.
|
|
2659
|
+
const collectDynamicKeyShareIds = (locations)=>{
|
|
2660
|
+
return locations.filter((loc)=>loc.location === core.BackupLocation.DYNAMIC).flatMap((loc)=>{
|
|
2661
|
+
if (loc.externalKeyShareIds && loc.externalKeyShareIds.length > 0) {
|
|
2662
|
+
return loc.externalKeyShareIds;
|
|
2663
|
+
}
|
|
2664
|
+
if (loc.externalKeyShareId) {
|
|
2665
|
+
return [
|
|
2666
|
+
loc.externalKeyShareId
|
|
2667
|
+
];
|
|
2668
|
+
}
|
|
2669
|
+
return [];
|
|
2670
|
+
});
|
|
2671
|
+
};
|
|
2672
|
+
|
|
2415
2673
|
// Pure routing helper: maps a completed reshare ceremony's results into the
|
|
2416
2674
|
// ShareDistribution that storage/publish should follow. Lives outside the
|
|
2417
2675
|
// client class so it's straightforward to reason about and unit-test
|
|
@@ -2612,205 +2870,6 @@ const readEnvironmentSettings = ()=>{
|
|
|
2612
2870
|
}
|
|
2613
2871
|
});
|
|
2614
2872
|
|
|
2615
|
-
function meetsMinVersion(sdkVersion, { minVersionByNamespace, fallbackMinVersion, label }, logger) {
|
|
2616
|
-
if (!sdkVersion) {
|
|
2617
|
-
return false;
|
|
2618
|
-
}
|
|
2619
|
-
try {
|
|
2620
|
-
const parsedVersion = core.parseNamespacedVersion(sdkVersion);
|
|
2621
|
-
if (!parsedVersion) {
|
|
2622
|
-
return false;
|
|
2623
|
-
}
|
|
2624
|
-
const { namespace, version } = parsedVersion;
|
|
2625
|
-
var _minVersionByNamespace_namespace;
|
|
2626
|
-
return gte(version, (_minVersionByNamespace_namespace = minVersionByNamespace[namespace]) != null ? _minVersionByNamespace_namespace : fallbackMinVersion);
|
|
2627
|
-
} catch (error) {
|
|
2628
|
-
logger.warn(`[DynamicWaasWalletClient] Error checking ${label} for version ${sdkVersion}`, {
|
|
2629
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2630
|
-
});
|
|
2631
|
-
return false;
|
|
2632
|
-
}
|
|
2633
|
-
}
|
|
2634
|
-
/** Whether the given SDK version requires a signed session ID to be sent at all. */ function isRequiresSignedSessionId(sdkVersion, logger) {
|
|
2635
|
-
return meetsMinVersion(sdkVersion, {
|
|
2636
|
-
minVersionByNamespace: SIGNED_SESSION_ID_MIN_VERSION_BY_NAMESPACE,
|
|
2637
|
-
fallbackMinVersion: SIGNED_SESSION_ID_MIN_VERSION,
|
|
2638
|
-
label: 'requiresSignedSessionId'
|
|
2639
|
-
}, logger);
|
|
2640
|
-
}
|
|
2641
|
-
/**
|
|
2642
|
-
* Whether the SDK version is known to forward `getSignedSessionId` to the iframe's
|
|
2643
|
-
* postMessage reverse channel — stricter than {@link isRequiresSignedSessionId},
|
|
2644
|
-
* which only checks whether a session value is sent at all.
|
|
2645
|
-
*/ function isReverseChannelSupported(sdkVersion, logger) {
|
|
2646
|
-
return meetsMinVersion(sdkVersion, {
|
|
2647
|
-
minVersionByNamespace: SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION_BY_NAMESPACE,
|
|
2648
|
-
fallbackMinVersion: SIGNED_SESSION_REVERSE_CHANNEL_MIN_VERSION,
|
|
2649
|
-
label: 'supportsReverseChannel'
|
|
2650
|
-
}, logger);
|
|
2651
|
-
}
|
|
2652
|
-
|
|
2653
|
-
/**
|
|
2654
|
-
* Datadog event name emitted on every signed-session nonce rejection (a 400 or
|
|
2655
|
-
* 401 from the backup/recovery endpoints). Log-only: lets us split the failure
|
|
2656
|
-
* bucket by `reason` and measure the true per-wallet unrecoverable rate
|
|
2657
|
-
* without changing any retry behavior. See DYNT-1599.
|
|
2658
|
-
*/ const NONCE_FAILURE_EVENT = 'waas.signed_session.nonce_failure';
|
|
2659
|
-
/**
|
|
2660
|
-
* Reasons that represent a genuine session-key signature mismatch (the 401 the
|
|
2661
|
-
* server returns per DYNT-1282). Used to gate 401 instrumentation so callers
|
|
2662
|
-
* that reuse 401 for unrelated rejections don't pollute the failure metric.
|
|
2663
|
-
*/ const SESSION_SIGNATURE_FAILURE_REASONS = new Set([
|
|
2664
|
-
'invalid_nonce_signature',
|
|
2665
|
-
'invalid_session_signature'
|
|
2666
|
-
]);
|
|
2667
|
-
/** Best-effort extraction of the server error `code`/`message` from a 4xx body. */ const readErrorBody = (error)=>{
|
|
2668
|
-
var _error_response;
|
|
2669
|
-
if (!(error instanceof axios.AxiosError)) {
|
|
2670
|
-
return {};
|
|
2671
|
-
}
|
|
2672
|
-
const data = (_error_response = error.response) == null ? void 0 : _error_response.data;
|
|
2673
|
-
if (typeof data === 'string') {
|
|
2674
|
-
return {
|
|
2675
|
-
message: data
|
|
2676
|
-
};
|
|
2677
|
-
}
|
|
2678
|
-
if (!data || typeof data !== 'object') {
|
|
2679
|
-
return {};
|
|
2680
|
-
}
|
|
2681
|
-
const record = data;
|
|
2682
|
-
// Prefer a string `code`, else fall back to `error_code` (the field the Key
|
|
2683
|
-
// Share Service emits, see KSS #271) or a bare `error`.
|
|
2684
|
-
const code = [
|
|
2685
|
-
record['code'],
|
|
2686
|
-
record['error_code'],
|
|
2687
|
-
record['error']
|
|
2688
|
-
].find((value)=>typeof value === 'string');
|
|
2689
|
-
const message = typeof record['message'] === 'string' ? record['message'] : undefined;
|
|
2690
|
-
return {
|
|
2691
|
-
code,
|
|
2692
|
-
message
|
|
2693
|
-
};
|
|
2694
|
-
};
|
|
2695
|
-
/** Maps the server error `code`/`message` onto a {@link NonceFailureReason}. */ const classifyNonceFailure = ({ code, message })=>{
|
|
2696
|
-
const haystack = `${code != null ? code : ''} ${message != null ? message : ''}`.toLowerCase();
|
|
2697
|
-
if (haystack.includes('already_used') || haystack.includes('already used') || haystack.includes('consumption failed')) {
|
|
2698
|
-
return 'nonce_already_used';
|
|
2699
|
-
}
|
|
2700
|
-
if (haystack.includes('invalid_nonce_signature') || haystack.includes('invalid nonce signature')) {
|
|
2701
|
-
return 'invalid_nonce_signature';
|
|
2702
|
-
}
|
|
2703
|
-
if (haystack.includes('invalid_session_signature') || haystack.includes('invalid session public key') || haystack.includes('invalid session signature')) {
|
|
2704
|
-
return 'invalid_session_signature';
|
|
2705
|
-
}
|
|
2706
|
-
return 'other';
|
|
2707
|
-
};
|
|
2708
|
-
/**
|
|
2709
|
-
* Owns everything about signed sessions and their single-use replay nonces:
|
|
2710
|
-
* resolving a session from an explicit value or the host reverse channel,
|
|
2711
|
-
* deciding whether the SDK version requires one, and building the
|
|
2712
|
-
* refresh-on-replay-400 retry predicate shared by the backup and recovery
|
|
2713
|
-
* flows.
|
|
2714
|
-
*
|
|
2715
|
-
* Extracted from `DynamicWalletClient` so this logic can be exercised in
|
|
2716
|
-
* isolation. The host callback is read lazily (`getCallback`) so it stays in
|
|
2717
|
-
* sync with the owning client even if reassigned after construction.
|
|
2718
|
-
*/ class SignedSessionManager {
|
|
2719
|
-
/** True when a reverse channel is configured to mint fresh signed sessions. */ get canRefresh() {
|
|
2720
|
-
return this.getCallback() !== undefined;
|
|
2721
|
-
}
|
|
2722
|
-
/**
|
|
2723
|
-
* Resolves the signed session ID from an explicit value or falls back to the
|
|
2724
|
-
* host reverse channel. Throws if neither source provides a value.
|
|
2725
|
-
*/ async resolve(signedSessionId) {
|
|
2726
|
-
const callback = this.getCallback();
|
|
2727
|
-
const resolved = signedSessionId != null ? signedSessionId : await (callback == null ? void 0 : callback());
|
|
2728
|
-
if (!resolved) {
|
|
2729
|
-
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');
|
|
2730
|
-
}
|
|
2731
|
-
return resolved;
|
|
2732
|
-
}
|
|
2733
|
-
/** Whether the configured SDK version requires a signed session ID. */ isRequired() {
|
|
2734
|
-
return this.required;
|
|
2735
|
-
}
|
|
2736
|
-
/** Whether the host SDK version supports the getSignedSessionId reverse channel (see version-check.ts). */ supportsReverseChannel() {
|
|
2737
|
-
return this.reverseChannelSupported;
|
|
2738
|
-
}
|
|
2739
|
-
/**
|
|
2740
|
-
* Builds a `retryPromise` `shouldRetry` predicate that refreshes a consumed
|
|
2741
|
-
* single-use nonce via the reverse channel on a replay 400 and retries once.
|
|
2742
|
-
* The signed session carries a single-use replay nonce: it is valid on its
|
|
2743
|
-
* first use, but once consumed (e.g. by a preceding operation) the server
|
|
2744
|
-
* rejects with a 400, at which point a fresh session is fetched and the call
|
|
2745
|
-
* retried. `alsoRetry` lets callers opt into additional retryable statuses
|
|
2746
|
-
* (e.g. 429 / 5xx) on top of the nonce refresh.
|
|
2747
|
-
*/ refreshShouldRetry({ onRefreshed, operationName, logContext, alsoRetry }) {
|
|
2748
|
-
return async (error, attempt)=>{
|
|
2749
|
-
const status = getHttpStatus(error);
|
|
2750
|
-
// A 400 is a refreshable replay nonce; a 401 is a session-key mismatch the
|
|
2751
|
-
// server returns to break the refresh loop (DYNT-1282) — logged only when the
|
|
2752
|
-
// body classifies as a genuine session-signature failure so unrelated 401s
|
|
2753
|
-
// (e.g. an exhausted add-signer grant) don't pollute the DYNT-1599 metric.
|
|
2754
|
-
// Only the 400 can actually refresh.
|
|
2755
|
-
const isSessionSignatureFailure = status === 401 && SESSION_SIGNATURE_FAILURE_REASONS.has(classifyNonceFailure(readErrorBody(error)));
|
|
2756
|
-
const shouldInstrument = status === 400 || isSessionSignatureFailure;
|
|
2757
|
-
if (shouldInstrument) {
|
|
2758
|
-
const willRefresh = status === 400 && attempt === 1 && this.canRefresh && this.supportsReverseChannel();
|
|
2759
|
-
this.instrumentNonceFailure({
|
|
2760
|
-
error,
|
|
2761
|
-
attempt,
|
|
2762
|
-
status,
|
|
2763
|
-
operationName,
|
|
2764
|
-
willRefresh,
|
|
2765
|
-
logContext
|
|
2766
|
-
});
|
|
2767
|
-
}
|
|
2768
|
-
if (status === 400 && attempt === 1 && this.canRefresh) {
|
|
2769
|
-
// canRefresh only proves a local callback exists, not that the host answers it.
|
|
2770
|
-
if (!this.supportsReverseChannel()) {
|
|
2771
|
-
this.logger.warn(`[${operationName}] host SDK version predates the signed-session reverse channel, declining nonce refresh`, _extends({
|
|
2772
|
-
status
|
|
2773
|
-
}, logContext));
|
|
2774
|
-
return alsoRetry ? alsoRetry(status) : false;
|
|
2775
|
-
}
|
|
2776
|
-
onRefreshed(await this.resolve());
|
|
2777
|
-
this.logger.info(`[${operationName}] refreshed signed session, retrying`, _extends({
|
|
2778
|
-
status
|
|
2779
|
-
}, logContext));
|
|
2780
|
-
return true;
|
|
2781
|
-
}
|
|
2782
|
-
return alsoRetry ? alsoRetry(status) : false;
|
|
2783
|
-
};
|
|
2784
|
-
}
|
|
2785
|
-
/**
|
|
2786
|
-
* Emits a single Datadog log per signed-session nonce rejection so the
|
|
2787
|
-
* failure bucket can be split by `reason` and correlated per wallet/request.
|
|
2788
|
-
* Purely observational — it never alters the retry decision.
|
|
2789
|
-
*/ instrumentNonceFailure({ error, attempt, status, operationName, willRefresh, logContext }) {
|
|
2790
|
-
const body = readErrorBody(error);
|
|
2791
|
-
this.logger.info(NONCE_FAILURE_EVENT, _extends({}, logContext, {
|
|
2792
|
-
operationName,
|
|
2793
|
-
attempt,
|
|
2794
|
-
status,
|
|
2795
|
-
reason: classifyNonceFailure(body),
|
|
2796
|
-
errorCode: body.code,
|
|
2797
|
-
errorMessage: body.message,
|
|
2798
|
-
willRefresh,
|
|
2799
|
-
canRefresh: this.canRefresh,
|
|
2800
|
-
reverseChannelSupported: this.reverseChannelSupported,
|
|
2801
|
-
required: this.required,
|
|
2802
|
-
sdkVersion: this.sdkVersion
|
|
2803
|
-
}));
|
|
2804
|
-
}
|
|
2805
|
-
constructor({ logger, sdkVersion, getCallback }){
|
|
2806
|
-
this.logger = logger;
|
|
2807
|
-
this.getCallback = getCallback;
|
|
2808
|
-
this.sdkVersion = sdkVersion;
|
|
2809
|
-
this.required = isRequiresSignedSessionId(sdkVersion, this.logger);
|
|
2810
|
-
this.reverseChannelSupported = isReverseChannelSupported(sdkVersion, this.logger);
|
|
2811
|
-
}
|
|
2812
|
-
}
|
|
2813
|
-
|
|
2814
2873
|
/**
|
|
2815
2874
|
* Determines the recovery state of a wallet based on backup info and local shares.
|
|
2816
2875
|
*
|
|
@@ -6372,6 +6431,7 @@ class DynamicWalletClient {
|
|
|
6372
6431
|
return {
|
|
6373
6432
|
location: core.BackupLocation.DYNAMIC,
|
|
6374
6433
|
externalKeyShareId: data.keyShareIds[0],
|
|
6434
|
+
externalKeyShareIds: data.keyShareIds,
|
|
6375
6435
|
passwordEncrypted: isPasswordEncrypted,
|
|
6376
6436
|
keygenId
|
|
6377
6437
|
};
|
|
@@ -6668,7 +6728,7 @@ class DynamicWalletClient {
|
|
|
6668
6728
|
// aborts the backup before activation, so an unrecoverable share is never
|
|
6669
6729
|
// marked active; the local share is never touched.
|
|
6670
6730
|
if (this.featureFlags[core.FEATURE_FLAGS.VERIFY_BACKUP_RECOVERABILITY_FLAG] === true) {
|
|
6671
|
-
const dynamicKeyShareIds = locations
|
|
6731
|
+
const dynamicKeyShareIds = collectDynamicKeyShareIds(locations);
|
|
6672
6732
|
await this.assertServerCopyRecoverable({
|
|
6673
6733
|
walletId: walletData.walletId,
|
|
6674
6734
|
shareSetId: targetShareSetId,
|