taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 35ebcf2dcd7e70c9300f80515e2d083bac0fbee6
parent 0fb407fe19cfe1b4b1159d0541ce88b70a71687d
Author: Florian Dold <florian@dold.me>
Date:   Wed, 28 May 2025 19:30:18 +0200

wallet-core: improve checkKycStatus, do not pass secret key to API Client

Diffstat:
Mpackages/kyc-ui/src/pages/TriggerKyc.tsx | 18++++++++++++------
Mpackages/taler-harness/src/index.ts | 19++++++++++++++-----
Mpackages/taler-harness/src/integrationtests/test-kyc-peer-push.ts | 2++
Mpackages/taler-util/src/http-client/exchange-client.ts | 34+++++++++++++++-------------------
Mpackages/taler-util/src/http-client/exchange.ts | 42++++++++++++++++++++++--------------------
Mpackages/taler-util/src/payto.ts | 2+-
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 19++++++++++++-------
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 12++++++++----
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 138++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
9 files changed, 174 insertions(+), 112 deletions(-)

diff --git a/packages/kyc-ui/src/pages/TriggerKyc.tsx b/packages/kyc-ui/src/pages/TriggerKyc.tsx @@ -13,9 +13,6 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { encodeCrock } from "@gnu-taler/taler-util"; -import { WalletKycRequest } from "@gnu-taler/taler-util"; -import { signWalletAccountSetup } from "@gnu-taler/taler-util"; import { AbsoluteTime, AccessToken, @@ -23,7 +20,12 @@ import { Amounts, assertUnreachable, createNewWalletKycAccount, + eddsaGetPublic, + encodeCrock, HttpStatusCode, + signKycAuth, + signWalletAccountSetup, + WalletKycRequest, } from "@gnu-taler/taler-util"; import { Button, @@ -93,7 +95,13 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { const paytoHash = kycAccount; async function check() { const { signingKey } = await accountPromise; - const result = await lib.exchange.checkKycStatus(signingKey, paytoHash); + const merchantPub = eddsaGetPublic(signingKey); + const accountOwnerSig = encodeCrock(signKycAuth(signingKey)); + const result = await lib.exchange.checkKycStatus({ + accountPub: encodeCrock(merchantPub), + accountSig: accountOwnerSig, + paytoHash, + }); switch (result.case) { case "ok": console.log("empty body"); @@ -109,7 +117,6 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { description: i18n.str`access denied`, when: AbsoluteTime.now(), }); - break; } case HttpStatusCode.NotFound: { @@ -119,7 +126,6 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { description: i18n.str`not found`, when: AbsoluteTime.now(), }); - break; } case HttpStatusCode.Conflict: { diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -41,15 +41,17 @@ import { createRFC8959AccessTokenEncoded, createRFC8959AccessTokenPlain, decodeCrock, + eddsaGetPublic, encodeCrock, generateIban, getRandomBytes, hashNormalizedPaytoUri, j2s, - parsePaytoUri, + parsePaytoUriOrThrow, randomBytes, rsaBlind, setGlobalLogLevelFromString, + signKycAuth, stringifyPayTemplateUri, succeedOrThrow, } from "@gnu-taler/taler-util"; @@ -1771,15 +1773,22 @@ merchantCli const instanceId = args.checkKyc.id ?? "admin"; const { merchant_priv } = await db.getInstancePrivateKey(instanceId); + const merchantPub = eddsaGetPublic(merchant_priv); + const allAccounts = await db.getInstanceKycStatus(instanceId); + const accountOwnerSig = encodeCrock(signKycAuth(merchant_priv)); + const info = await Promise.all( allAccounts.map(async ({ exchange_url, payto_uri }) => { const exchangeApi = new TalerExchangeHttpClient(exchange_url, {}); - const kyc_status = await exchangeApi.checkKycStatus( - merchant_priv, - encodeCrock(hashNormalizedPaytoUri(parsePaytoUri(payto_uri)!)), - ); + const kyc_status = await exchangeApi.checkKycStatus({ + accountPub: encodeCrock(merchantPub), + accountSig: accountOwnerSig, + paytoHash: encodeCrock( + hashNormalizedPaytoUri(parsePaytoUriOrThrow(payto_uri)), + ), + }); return { payto_uri, exchange_url, diff --git a/packages/taler-harness/src/integrationtests/test-kyc-peer-push.ts b/packages/taler-harness/src/integrationtests/test-kyc-peer-push.ts @@ -115,6 +115,8 @@ export async function runKycPeerPushTest(t: GlobalTestState) { t.assertTrue(!!kycPaytoHash); + t.assertTrue(!!txDet.kycAccessToken); + await postAmlDecisionNoRules(t, { amlPriv: amlKeypair.priv, amlPub: amlKeypair.pub, diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -37,10 +37,12 @@ import { opSuccessFromHttp, opUnknownHttpFailure, } from "../operation.js"; -import { EddsaPrivP, decodeCrock, encodeCrock } from "../taler-crypto.js"; +import { encodeCrock } from "../taler-crypto.js"; import { AccessToken, AmountString, + EddsaPublicKeyString, + EddsaSignatureString, OfficerAccount, PaginationParams, ReserveAccount, @@ -108,7 +110,6 @@ import { LongpollQueue, signAmlDecision, signAmlQuery, - signKycAuth, signWalletAccountSetup, } from "../index.js"; import { TalerErrorCode } from "../taler-error-codes.js"; @@ -613,14 +614,13 @@ export class TalerExchangeHttpClient2 { * https://docs.taler.net/core/api-exchange.html#get--kyc-check-$H_NORMALIZED_PAYTO * */ - async checkKycStatus( - signingKey: EddsaPrivP | string, - paytoHash: string, - longpoll: boolean = false, - params: { - awaitAuth?: boolean; - } = {}, - ): Promise< + async checkKycStatus(args: { + paytoHash: string; + accountPub: EddsaPublicKeyString; + accountSig: EddsaSignatureString; + longpoll?: boolean; + awaitAuth?: boolean; + }): Promise< | OperationOk<void> | OperationAlternative<HttpStatusCode.Ok, AccountKycStatus> | OperationAlternative<HttpStatusCode.Accepted, AccountKycStatus> @@ -628,22 +628,18 @@ export class TalerExchangeHttpClient2 { | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.Conflict> > { + const { paytoHash, accountPub, accountSig, longpoll, awaitAuth } = args; const url = new URL(`kyc-check/${paytoHash}`, this.baseUrl); - if (params.awaitAuth !== undefined) { - url.searchParams.set("await_auth", params.awaitAuth ? "YES" : "NO"); + if (awaitAuth !== undefined) { + url.searchParams.set("await_auth", awaitAuth ? "YES" : "NO"); } - const sigKeyPacked = - typeof signingKey === "string" ? decodeCrock(signingKey) : signingKey; - - const signature = encodeCrock(signKycAuth(sigKeyPacked)); - const resp = await this.fetch( url, { headers: { - "Account-Owner-Signature": signature, - "Account-Owner-Pub": encodeCrock(sigKeyPacked), + "Account-Owner-Signature": accountSig, + "Account-Owner-Pub": accountPub, }, }, longpoll, diff --git a/packages/taler-util/src/http-client/exchange.ts b/packages/taler-util/src/http-client/exchange.ts @@ -24,6 +24,7 @@ import { createPlatformHttpLib } from "../http.js"; import { LibtoolVersion } from "../libtool-version.js"; import { FailCasesByMethod, + OperationAlternative, OperationFail, OperationOk, ResultByMethod, @@ -35,7 +36,7 @@ import { opSuccessFromHttp, opUnknownHttpFailure, } from "../operation.js"; -import { EddsaPrivP, decodeCrock, encodeCrock } from "../taler-crypto.js"; +import { encodeCrock } from "../taler-crypto.js"; import { AccessToken, EddsaPublicKeyString, @@ -43,10 +44,10 @@ import { LongPollParams, OfficerAccount, PaginationParams, - PaytoHash, codecForTalerCommonConfigResponse, } from "../types-taler-common.js"; import { + AccountKycStatus, AmlDecisionRequest, ExchangeKycUploadFormRequest, ExchangePurseDeposits, @@ -92,7 +93,6 @@ import { LongpollQueue, signAmlDecision, signAmlQuery, - signKycAuth, } from "../index.js"; import { TalerErrorCode } from "../taler-error-codes.js"; import { AbsoluteTime } from "../time.js"; @@ -515,28 +515,30 @@ export class TalerExchangeHttpClient { * * https://docs.taler.net/core/api-exchange.html#get--kyc-check-$H_NORMALIZED_PAYTO */ - async checkKycStatus( - signingKey: EddsaPrivP | string, - paytoHash: PaytoHash, - params: { - awaitAuth?: boolean; - } = {}, - ) { + async checkKycStatus(args: { + paytoHash: string; + accountPub: EddsaPublicKeyString; + accountSig: EddsaSignatureString; + longpoll?: boolean; + awaitAuth?: boolean; + }): Promise< + | OperationOk<void> + | OperationAlternative<HttpStatusCode.Ok, AccountKycStatus> + | OperationAlternative<HttpStatusCode.Accepted, AccountKycStatus> + | OperationFail<HttpStatusCode.Forbidden> + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.Conflict> + > { + const { paytoHash, accountPub, accountSig, longpoll, awaitAuth } = args; const url = new URL(`kyc-check/${paytoHash}`, this.baseUrl); - - if (params.awaitAuth !== undefined) { - url.searchParams.set("await_auth", params.awaitAuth ? "YES" : "NO"); + if (awaitAuth !== undefined) { + url.searchParams.set("await_auth", awaitAuth ? "YES" : "NO"); } - const sigKeyPacked = - typeof signingKey === "string" ? decodeCrock(signingKey) : signingKey; - - const signature = encodeCrock(signKycAuth(sigKeyPacked)); - const resp = await this.httpLib.fetch(url.href, { headers: { - "Account-Owner-Signature": signature, - "Account-Owner-Pub": encodeCrock(sigKeyPacked), + "Account-Owner-Signature": accountSig, + "Account-Owner-Pub": accountPub, }, }); diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts @@ -375,7 +375,7 @@ export function stringifyReservePaytoUri( return `payto://${target}/${domainWithOptPort}${optPath}/${reservePub}`; } -export function parsePaytoUriOrThrow(s: string): PaytoUri | undefined { +export function parsePaytoUriOrThrow(s: string): PaytoUri { const ret = parsePaytoUri(s); if (!ret) { throw Error("invalid payto URI"); diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -192,17 +192,22 @@ export async function waitForKycCompletion( exchangeBaseUrl: exchangeUrl, }); + const accountPub = mergeReserveInfo.reservePub; + const accountPriv = mergeReserveInfo.reservePriv; + const sigResp = await wex.cryptoApi.signWalletKycAuth({ - accountPriv: mergeReserveInfo.reservePriv, - accountPub: mergeReserveInfo.reservePub, + accountPriv, + accountPub, }); const exchangeClient = walletExchangeClient(exchangeUrl, wex); - const resp = await exchangeClient.checkKycStatus( - sigResp.sig, - kycPaytoHash, - true, - ); + const resp = await exchangeClient.checkKycStatus({ + accountPub, + accountSig: sigResp.sig, + paytoHash: kycPaytoHash, + longpoll: true, + }); + switch (resp.case) { case "ok": case HttpStatusCode.Ok: diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -876,7 +876,7 @@ async function processPeerPullCreditBalanceKyc( async function processPeerPullCreditKycRequired( wex: WalletExecutionContext, peerIni: PeerPullCreditRecord, - kycPayoHash: string, + kycPaytoHash: string, ): Promise<TaskRunResult> { const ctx = new PeerPullCreditTransactionContext(wex, peerIni.pursePub); @@ -891,7 +891,11 @@ async function processPeerPullCreditKycRequired( }); const exchangeClient = walletExchangeClient(peerIni.exchangeBaseUrl, wex); - const res = await exchangeClient.checkKycStatus(sigResp.sig, kycPayoHash); + const res = await exchangeClient.checkKycStatus({ + accountPub: mergeReserveInfo.reservePub, + accountSig: sigResp.sig, + paytoHash: kycPaytoHash, + }); switch (res.case) { case "ok": @@ -901,9 +905,9 @@ async function processPeerPullCreditKycRequired( case HttpStatusCode.Accepted: { logger.info(`kyc status: ${j2s(res.body)}`); await recordTransition(ctx, {}, async (rec) => { - rec.kycPaytoHash = kycPayoHash; + rec.kycPaytoHash = kycPaytoHash; logger.info( - `setting peer-pull-credit kyc payto hash to ${kycPayoHash}`, + `setting peer-pull-credit kyc payto hash to ${kycPaytoHash}`, ); rec.kycAccessToken = res.body.access_token; rec.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired; diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -103,7 +103,7 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { walletExchangeClient, WalletExecutionContext } from "./wallet.js"; +import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; import { PerformCreateWithdrawalGroupResult, WithdrawTransactionContext, @@ -134,8 +134,8 @@ export class PeerPushCreditTransactionContext implements TransactionContext { } readonly store = "peerPushCredit"; - readonly recordId = this.peerPushCreditId - readonly recordState = computePeerPushCreditTransactionState + readonly recordId = this.peerPushCreditId; + readonly recordState = computePeerPushCreditTransactionState; readonly recordMeta = (rec: PeerPushPaymentIncomingRecord) => ({ transactionId: this.transactionId, status: rec.status, @@ -143,7 +143,9 @@ export class PeerPushCreditTransactionContext implements TransactionContext { currency: Amounts.currencyOf(rec.estimatedAmountEffective), exchanges: [rec.exchangeBaseUrl], }); - updateTransactionMeta = (tx: WalletDbReadWriteTransaction<["peerPushCredit", "transactionsMeta"]>) => recordUpdateMeta(this, tx) + updateTransactionMeta = ( + tx: WalletDbReadWriteTransaction<["peerPushCredit", "transactionsMeta"]>, + ) => recordUpdateMeta(this, tx); /** * Get the full transaction details for the transaction. @@ -226,7 +228,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { amountEffective: isUnsuccessfulTransaction(txState) ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount)) : // FIXME: This is wrong, needs to consider fees! - Amounts.stringify(peerContractTerms.amount), + Amounts.stringify(peerContractTerms.amount), amountRaw: Amounts.stringify(peerContractTerms.amount), exchangeBaseUrl: pushInc.exchangeBaseUrl, info: { @@ -284,7 +286,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { const res = await withdrawalCtx.deleteTransactionInTx(tx); notifs.push(...res.notifs); } - }) + }); } async suspendTransaction(): Promise<void> { @@ -491,9 +493,9 @@ export async function preparePeerPushCredit( break; case HttpStatusCode.NotFound: // FIXME: appropriated error code - throw Error("unknown P2P contract") + throw Error("unknown P2P contract"); default: - assertUnreachable(contractResp) + assertUnreachable(contractResp); } const pursePub = contractResp.body.purse_pub; @@ -510,12 +512,12 @@ export async function preparePeerPushCredit( break; case HttpStatusCode.Gone: // FIXME: appropriated error code - throw Error("aborted peer push credit") + throw Error("aborted peer push credit"); case HttpStatusCode.NotFound: // FIXME: appropriated error code - throw Error("unknown peer push credit") + throw Error("unknown peer push credit"); default: - assertUnreachable(resp) + assertUnreachable(resp); } const purseStatus = resp.body; @@ -546,29 +548,33 @@ export async function preparePeerPushCredit( } const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId); - await recordCreate(ctx, { - extraStores: ["contractTerms"] - }, async (tx) => { - await tx.contractTerms.put({ - h: contractTermsHash, - contractTermsRaw: dec.contractTerms, - }); - return { - peerPushCreditId, - contractPriv: contractPriv, - exchangeBaseUrl: exchangeBaseUrl, - mergePriv: dec.mergePriv, - pursePub: pursePub, - timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - contractTermsHash, - status: PeerPushCreditStatus.DialogProposed, - withdrawalGroupId, - currency: Amounts.currencyOf(purseStatus.balance), - estimatedAmountEffective: Amounts.stringify( - wi.withdrawalAmountEffective, - ), - }; - }); + await recordCreate( + ctx, + { + extraStores: ["contractTerms"], + }, + async (tx) => { + await tx.contractTerms.put({ + h: contractTermsHash, + contractTermsRaw: dec.contractTerms, + }); + return { + peerPushCreditId, + contractPriv: contractPriv, + exchangeBaseUrl: exchangeBaseUrl, + mergePriv: dec.mergePriv, + pursePub: pursePub, + timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + contractTermsHash, + status: PeerPushCreditStatus.DialogProposed, + withdrawalGroupId, + currency: Amounts.currencyOf(purseStatus.balance), + estimatedAmountEffective: Amounts.stringify( + wi.withdrawalAmountEffective, + ), + }; + }, + ); wex.taskScheduler.startShepherdTask(ctx.taskId); const currency = Amounts.currencyOf(wi.withdrawalAmountRaw); @@ -578,8 +584,8 @@ export async function preparePeerPushCredit( "exchanges", "exchangeDetails", "globalCurrencyExchanges", - "globalCurrencyAuditors" - ] + "globalCurrencyAuditors", + ], }, (tx) => getExchangeScopeInfo(tx, exchangeBaseUrl, currency), ); @@ -605,7 +611,11 @@ async function longpollKycStatus( const done = await waitForKycCompletion(wex, exchangeUrl, kycPaytoHash); if (done) { const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId); - await recordTransitionStatus(ctx, PeerPushCreditStatus.PendingMergeKycRequired, PeerPushCreditStatus.PendingMerge); + await recordTransitionStatus( + ctx, + PeerPushCreditStatus.PendingMergeKycRequired, + PeerPushCreditStatus.PendingMerge, + ); return TaskRunResult.progress(); } else { return TaskRunResult.longpollReturnedPending(); @@ -633,7 +643,11 @@ async function processPeerPushCreditKycRequired( }); const exchangeClient = walletExchangeClient(peerInc.exchangeBaseUrl, wex); - const resp = await exchangeClient.checkKycStatus(sigResp.sig, kycPending.h_payto) + const resp = await exchangeClient.checkKycStatus({ + accountPub: mergeReserveInfo.reservePub, + accountSig: sigResp.sig, + paytoHash: kycPending.h_payto, + }); switch (resp.case) { case "ok": @@ -673,7 +687,7 @@ async function processPeerPushCreditKycRequired( case HttpStatusCode.Conflict: case HttpStatusCode.Forbidden: case HttpStatusCode.NotFound: - throw Error(`unexpected kyc status response ${resp.case}`) + throw Error(`unexpected kyc status response ${resp.case}`); default: assertUnreachable(resp); } @@ -699,7 +713,11 @@ async function handlePendingMerge( amount: kycCheckRes.nextThreshold, exchangeBaseUrl: peerInc.exchangeBaseUrl, }); - await recordTransitionStatus(ctx, PeerPushCreditStatus.PendingMerge, PeerPushCreditStatus.PendingBalanceKycInit); + await recordTransitionStatus( + ctx, + PeerPushCreditStatus.PendingMerge, + PeerPushCreditStatus.PendingBalanceKycInit, + ); return TaskRunResult.progress(); } @@ -734,7 +752,7 @@ async function handlePendingMerge( reservePriv: mergeReserveInfo.reservePriv, }); - const exchangeClient = walletExchangeClient(peerInc.exchangeBaseUrl, wex) + const exchangeClient = walletExchangeClient(peerInc.exchangeBaseUrl, wex); const mergeReq: ExchangePurseMergeRequest = { payto_uri: reservePayto, @@ -745,7 +763,7 @@ async function handlePendingMerge( const mergeResp = await exchangeClient.postPurseMerge( peerInc.pursePub, - mergeReq + mergeReq, ); logger.trace(`merge request: ${j2s(mergeReq)}`); @@ -761,14 +779,15 @@ async function handlePendingMerge( case HttpStatusCode.Conflict: // FIXME: Check signature. // FIXME: status completed by other - await recordTransitionStatus(ctx, + await recordTransitionStatus( + ctx, PeerPushCreditStatus.PendingMerge, PeerPushCreditStatus.Aborted, ); return TaskRunResult.finished(); case HttpStatusCode.Gone: // FIXME: status expired - await ctx.abortTransaction() + await ctx.abortTransaction(); return TaskRunResult.finished(); case HttpStatusCode.Forbidden: case HttpStatusCode.NotFound: @@ -920,25 +939,36 @@ async function processPeerPushDebitDialogProposed( pullIni.peerPushCreditId, ); const exchangeClient = walletExchangeClient(pullIni.exchangeBaseUrl, wex); - const resp = await exchangeClient.getPurseStatusAtMerge(pullIni.pursePub, true); + const resp = await exchangeClient.getPurseStatusAtMerge( + pullIni.pursePub, + true, + ); switch (resp.case) { case "ok": break; case HttpStatusCode.Gone: // Exchange says that purse doesn't exist anymore => expired! - await recordTransitionStatus(ctx, PeerPushCreditStatus.DialogProposed, PeerPushCreditStatus.Aborted); + await recordTransitionStatus( + ctx, + PeerPushCreditStatus.DialogProposed, + PeerPushCreditStatus.Aborted, + ); return TaskRunResult.finished(); case HttpStatusCode.NotFound: await ctx.failTransaction(resp.detail); return TaskRunResult.finished(); default: - assertUnreachable(resp) + assertUnreachable(resp); } if (isPurseMerged(resp.body)) { logger.info("purse completed by another wallet"); - await recordTransitionStatus(ctx, PeerPushCreditStatus.DialogProposed, PeerPushCreditStatus.Aborted); + await recordTransitionStatus( + ctx, + PeerPushCreditStatus.DialogProposed, + PeerPushCreditStatus.Aborted, + ); return TaskRunResult.finished(); } @@ -1056,7 +1086,11 @@ async function processPeerPushCreditBalanceKyc( }); if (ret.result === "ok") { - await recordTransitionStatus(ctx, PeerPushCreditStatus.PendingBalanceKycRequired, PeerPushCreditStatus.PendingMerge); + await recordTransitionStatus( + ctx, + PeerPushCreditStatus.PendingBalanceKycRequired, + PeerPushCreditStatus.PendingMerge, + ); return TaskRunResult.progress(); } else if ( peerInc.status === PeerPushCreditStatus.PendingBalanceKycInit && @@ -1127,7 +1161,11 @@ export async function confirmPeerPushCredit( if (checkPeerCreditHardLimitExceeded(exchange, res.contractTerms.amount)) { throw Error("peer credit would exceed hard KYC limit"); } - await recordTransitionStatus(ctx, PeerPushCreditStatus.DialogProposed, PeerPushCreditStatus.PendingMerge); + await recordTransitionStatus( + ctx, + PeerPushCreditStatus.DialogProposed, + PeerPushCreditStatus.PendingMerge, + ); wex.taskScheduler.stopShepherdTask(ctx.taskId); wex.taskScheduler.startShepherdTask(ctx.taskId);