diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/aml-backoffice-ui/src/account.ts | 19 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/handlers/FormProvider.tsx | 2 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/hooks/useBackend.ts | 8 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts | 162 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/hooks/useCases.ts | 53 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/pages.ts | 2 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 131 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/pages/Cases.tsx | 53 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx | 40 | ||||
| -rw-r--r-- | packages/aml-backoffice-ui/src/types.ts | 36 | 
10 files changed, 362 insertions, 144 deletions
diff --git a/packages/aml-backoffice-ui/src/account.ts b/packages/aml-backoffice-ui/src/account.ts index 1c8cd7f53..2225bf2ff 100644 --- a/packages/aml-backoffice-ui/src/account.ts +++ b/packages/aml-backoffice-ui/src/account.ts @@ -1,6 +1,7 @@  import { -  PaytoUri, +  Amounts,    TalerSignaturePurpose, +  amountToBuffer,    bufferForUint32,    buildSigPS,    createEddsaKeyPair, @@ -11,8 +12,10 @@ import {    encodeCrock,    encryptWithDerivedKey,    getRandomBytesF, +  hash, +  hashTruncate32,    stringToBytes, -  stringifyPaytoUri, +  timestampRoundedToBuffer  } from "@gnu-taler/taler-util";  import { AmlExchangeBackend } from "./types.js"; @@ -60,12 +63,16 @@ export function buildQuerySignature(key: SigningKey): string {  }  export function buildDecisionSignature(    key: SigningKey, -  payto: PaytoUri, -  state: AmlExchangeBackend.AmlState, +  decision: AmlExchangeBackend.AmlDecision,  ): string { +    const sigBlob = buildSigPS(TalerSignaturePurpose.TALER_SIGNATURE_AML_DECISION) -    .put(decodeCrock(stringifyPaytoUri(payto))) -    .put(bufferForUint32(state)) +    .put(hash(stringToBytes(decision.justification))) +    // .put(timestampRoundedToBuffer(decision.decision_time)) +    .put(amountToBuffer(decision.new_threshold)) +    .put(decodeCrock(decision.h_payto)) +    // .put(hash(stringToBytes(decision.kyc_requirements))) +    .put(bufferForUint32(decision.new_state))      .build();    return encodeCrock(eddsaSign(sigBlob, key)); diff --git a/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx b/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx index a195c2051..3da2a4f07 100644 --- a/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx +++ b/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx @@ -29,7 +29,7 @@ export type FormState<T> = {      ? Partial<InputFieldState>      : T[field] extends Array<infer P>      ? Partial<InputArrayFieldState<P>> -    : T[field] extends object +    : T[field] extends (object | undefined)      ? FormState<T[field]>      : Partial<InputFieldState>;  }; diff --git a/packages/aml-backoffice-ui/src/hooks/useBackend.ts b/packages/aml-backoffice-ui/src/hooks/useBackend.ts index e68efb2e3..b9d66fca6 100644 --- a/packages/aml-backoffice-ui/src/hooks/useBackend.ts +++ b/packages/aml-backoffice-ui/src/hooks/useBackend.ts @@ -14,7 +14,7 @@ interface useBackendType {      path: string,      options?: RequestOptions,    ) => Promise<HttpResponseOk<T>>; -  fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; +  fetcher: <T>(args: [string, string]) => Promise<HttpResponseOk<T>>;    paginatedFetcher: <T>(      args: [string, number, number, string],    ) => Promise<HttpResponseOk<T>>; @@ -35,8 +35,10 @@ export function usePublicBackend(): useBackendType {    );    const fetcher = useCallback( -    function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { -      return requestHandler<T>(baseUrl, endpoint); +    function fetcherImpl<T>([endpoint, talerAmlOfficerSignature]: [string,string]): Promise<HttpResponseOk<T>> { +      return requestHandler<T>(baseUrl, endpoint, { +        talerAmlOfficerSignature +      });      },      [baseUrl],    ); diff --git a/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts b/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts new file mode 100644 index 000000000..980a35f21 --- /dev/null +++ b/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts @@ -0,0 +1,162 @@ + +import { +  HttpResponse, +  HttpResponseOk, +  RequestError +} from "@gnu-taler/web-util/browser"; +import { AmlExchangeBackend } from "../types.js"; +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; +import { AccountId } from "../account.js"; +import { usePublicBackend } from "./useBackend.js"; +const useSWR = _useSWR as unknown as SWRHook; + +export function useCaseDetails( +  account: AccountId, +  paytoHash: string, +  signature: string | undefined, +): HttpResponse< +  AmlExchangeBackend.AmlDecisionDetails, +  AmlExchangeBackend.AmlError +> { +  const { fetcher } = usePublicBackend(); + +  const { data, error } = useSWR< +  HttpResponseOk<AmlExchangeBackend.AmlDecisionDetails>, +  RequestError<AmlExchangeBackend.AmlError> +>(    [ +  `aml/${account}/decision/${(paytoHash)}`, +  signature, +], +fetcher, { +    refreshInterval: 0, +    refreshWhenHidden: false, +    revalidateOnFocus: false, +    revalidateOnReconnect: false, +    refreshWhenOffline: false, +    errorRetryCount: 0, +    errorRetryInterval: 1, +    shouldRetryOnError: false, +    keepPreviousData: true, +  }); + +  if (data) return data; +  if (error) return error.cause; +  return { loading: true }; +} + +const example1: AmlExchangeBackend.AmlDecisionDetails = { +  aml_history: [ +    { +      justification: "Lack of documentation", +      decider_pub: "ASDASDASD", +      decision_time: { +        t_s: Date.now() / 1000, +      }, +      new_state: 2, +      new_threshold: "USD:0", +    }, +    { +      justification: "Doing a transfer of high amount", +      decider_pub: "ASDASDASD", +      decision_time: { +        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6, +      }, +      new_state: 1, +      new_threshold: "USD:2000", +    }, +    { +      justification: "Account is known to the system", +      decider_pub: "ASDASDASD", +      decision_time: { +        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9, +      }, +      new_state: 0, +      new_threshold: "USD:100", +    }, +  ], +  kyc_attributes: [ +    { +      collection_time: { +        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8, +      }, +      expiration_time: { +        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4, +      }, +      provider_section: "asdasd", +      attributes: { +        name: "Sebastian", +      }, +    }, +    { +      collection_time: { +        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5, +      }, +      expiration_time: { +        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2, +      }, +      provider_section: "asdasd", +      attributes: { +        creditCard: "12312312312", +      }, +    }, +  ], +}; + +export const exampleResponse: HttpResponse<AmlExchangeBackend.AmlDecisionDetails,AmlExchangeBackend.AmlError> = { +  ok: true, +  data: example1, +} + + +export function useAmlCasesAPI(): AmlCaseAPI { +  const { request } = usePublicBackend(); +  const mutateAll = useMatchMutate(); + +  const updateDecision = async ( +    officer: AccountId, +    data: AmlExchangeBackend.AmlDecision, +  ): Promise<HttpResponseOk<void>> => { +    const res = await request<void>(`aml/${officer}/decision`, { +      method: "POST", +      data, +      contentType: "json", +    }); +    await mutateAll(/.*aml.*/); +    return res; +  }; + +  return { +    updateDecision, +  }; +} + +export interface AmlCaseAPI { +  updateDecision: ( +    officer: AccountId, +    data: AmlExchangeBackend.AmlDecision, +  ) => Promise<HttpResponseOk<void>>; +} + + +function useMatchMutate(): ( +  re: RegExp, +  value?: unknown, +) => Promise<any> { +  const { cache, mutate } = useSWRConfig(); + +  if (!(cache instanceof Map)) { +    throw new Error( +      "matchMutate requires the cache provider to be a Map instance", +    ); +  } + +  return function matchRegexMutate(re: RegExp, value?: unknown) { +    const allKeys = Array.from(cache.keys()); +    const keys = allKeys.filter((key) => re.test(key)); +    const mutations = keys.map((key) => { +      return mutate(key, value, true); +    }); +    return Promise.all(mutations); +  }; +} diff --git a/packages/aml-backoffice-ui/src/hooks/useCases.ts b/packages/aml-backoffice-ui/src/hooks/useCases.ts index 04b7c383b..c5a0fc489 100644 --- a/packages/aml-backoffice-ui/src/hooks/useCases.ts +++ b/packages/aml-backoffice-ui/src/hooks/useCases.ts @@ -92,3 +92,56 @@ export function useCases(    }    return { loading: true };  } + +const example1: AmlExchangeBackend.AmlRecords = { +  records: [ +    { +      current_state: 0, +      h_payto: "QWEQWEQWEQWEWQE", +      rowid: 1, +      threshold: "USD 100", +    }, +    { +      current_state: 1, +      h_payto: "ASDASDASD", +      rowid: 1, +      threshold: "USD 100", +    }, +    { +      current_state: 2, +      h_payto: "ZXCZXCZXCXZC", +      rowid: 1, +      threshold: "USD 1000", +    }, +    { +      current_state: 0, +      h_payto: "QWEQWEQWEQWEWQE", +      rowid: 1, +      threshold: "USD 100", +    }, +    { +      current_state: 1, +      h_payto: "ASDASDASD", +      rowid: 1, +      threshold: "USD 100", +    }, +    { +      current_state: 2, +      h_payto: "ZXCZXCZXCXZC", +      rowid: 1, +      threshold: "USD 1000", +    }, +  ].map((e, idx) => { +    e.rowid = idx; +    e.threshold = `${e.threshold}${idx}`; +    return e; +  }), +}; + +export const exampleResponse: HttpResponsePaginated<AmlExchangeBackend.AmlRecords,AmlExchangeBackend.AmlError> = { +  ok: true, +  data: example1, +  loadMore: () => {}, +  loadMorePrev: () => {},   +} + diff --git a/packages/aml-backoffice-ui/src/pages.ts b/packages/aml-backoffice-ui/src/pages.ts index 18fb7a158..e4e16f05f 100644 --- a/packages/aml-backoffice-ui/src/pages.ts +++ b/packages/aml-backoffice-ui/src/pages.ts @@ -16,7 +16,7 @@ const cases: PageEntry = {    url: "#/cases",    view: Cases,  }; -const account: PageEntry<{ account?: string }> = { +const account: PageEntry<{ account: string }> = {    url: pageDefinition("#/account/:account"),    view: CaseDetails,  }; diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx index d02d8b395..ce820d612 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -13,64 +13,13 @@ import { FlexibleForm } from "../forms/index.js";  import { UIFormField } from "../handlers/forms.js";  import { Pages } from "../pages.js";  import { AmlExchangeBackend } from "../types.js"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { useOfficer } from "../hooks/useOfficer.js"; +import { buildQuerySignature } from "../account.js"; +import { useCaseDetails } from "../hooks/useCaseDetails.js"; +import { handleNotOkResult } from "../utils/errors.js"; -const response: AmlExchangeBackend.AmlDecisionDetails = { -  aml_history: [ -    { -      justification: "Lack of documentation", -      decider_pub: "ASDASDASD", -      decision_time: { -        t_s: Date.now() / 1000, -      }, -      new_state: 2, -      new_threshold: "USD:0", -    }, -    { -      justification: "Doing a transfer of high amount", -      decider_pub: "ASDASDASD", -      decision_time: { -        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6, -      }, -      new_state: 1, -      new_threshold: "USD:2000", -    }, -    { -      justification: "Account is known to the system", -      decider_pub: "ASDASDASD", -      decision_time: { -        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9, -      }, -      new_state: 0, -      new_threshold: "USD:100", -    }, -  ], -  kyc_attributes: [ -    { -      collection_time: { -        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8, -      }, -      expiration_time: { -        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4, -      }, -      provider_section: "asdasd", -      attributes: { -        name: "Sebastian", -      }, -    }, -    { -      collection_time: { -        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5, -      }, -      expiration_time: { -        t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2, -      }, -      provider_section: "asdasd", -      attributes: { -        creditCard: "12312312312", -      }, -    }, -  ], -};  type AmlEvent = AmlFormEvent | KycCollectionEvent | KycExpirationEvent;  type AmlFormEvent = {    type: "aml-form"; @@ -127,7 +76,7 @@ function getEventsFromAmlHistory(      });      prev.push({        type: "kyc-expiration", -      title: "expired" as TranslatedString, +      title: "expiration" as TranslatedString,        when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time),        fields: !k.attributes ? [] : Object.keys(k.attributes),      }); @@ -136,19 +85,30 @@ function getEventsFromAmlHistory(    return ae.concat(ke).sort(selectSooner);  } -export function CaseDetails({ account }: { account?: string }) { -  const events = getEventsFromAmlHistory( -    response.aml_history, -    response.kyc_attributes, -  ); -  console.log("DETAILS", events, events[events.length - 1 - 2]); -  const [selected, setSelected] = useState<AmlEvent>( -    events[events.length - 1 - 2], -  ); +export function CaseDetails({ account: paytoHash }: { account: string }) { +  const [selected, setSelected] = useState<AmlEvent | undefined>(undefined); + +  const officer = useOfficer(); +  const { i18n } = useTranslationContext(); +  if (officer.state !== "ready") { +    return <HandleAccountNotReady officer={officer} />; +  } +  const signature = +    officer.state === "ready" +      ? buildQuerySignature(officer.account.signingKey) +      : undefined; +  const details = useCaseDetails(officer.account.accountId, paytoHash, signature) +  if (!details.ok && !details.loading) { +    return handleNotOkResult(i18n)(details); +  } +  const aml_history = details.loading ? [] : details.data.aml_history +  const kyc_attributes = details.loading ? [] : details.data.kyc_attributes +  const events = getEventsFromAmlHistory(aml_history,kyc_attributes); +      return (      <div>        <a -        href={Pages.newFormEntry.url({ account })} +        href={Pages.newFormEntry.url({ account: paytoHash })}          class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"        >          New AML form @@ -271,13 +231,24 @@ function ShowConsolidated({    history: AmlEvent[];    until: AmlEvent;  }): VNode { -  console.log("UNTIL", until);    const cons = getConsolidated(history, until.when);    const form: FlexibleForm<Consolidated> = {      versionId: "1",      behavior: (form) => { -      return {}; +      return { +        aml: { +          threshold: { +            hidden: !form.aml +          }, +          since: { +            hidden: !form.aml +          }, +          state: { +            hidden: !form.aml +          } +        } +      };      },      design: [        { @@ -356,8 +327,8 @@ function ShowConsolidated({  interface Consolidated {    aml: { -    state?: AmlExchangeBackend.AmlState; -    threshold?: AmountJson; +    state: AmlExchangeBackend.AmlState; +    threshold: AmountJson;      since: AbsoluteTime;    };    kyc: { @@ -375,7 +346,13 @@ function getConsolidated(  ): Consolidated {    const initial: Consolidated = {      aml: { -      since: AbsoluteTime.never(), +      state: AmlExchangeBackend.AmlState.normal, +      threshold: { +        currency: "ARS", +        value: 1000, +        fraction: 0, +      }, +      since: AbsoluteTime.never()      },      kyc: {},    }; @@ -391,9 +368,11 @@ function getConsolidated(          break;        }        case "aml-form": { -        prev.aml.threshold = cur.threshold; -        prev.aml.state = cur.state; -        prev.aml.since = cur.when; +        prev.aml = { +          since: cur.when, +          state: cur.state, +          threshold: cur.threshold +        }          break;        }        case "kyc-collection": { diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx index 8b115ed7e..990c0d2d4 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -12,59 +12,6 @@ import { buildQuerySignature } from "../account.js";  import { handleNotOkResult } from "../utils/errors.js";  import { useTranslationContext } from "@gnu-taler/web-util/browser"; -const response: AmlExchangeBackend.AmlRecords = { -  records: [ -    { -      current_state: 0, -      h_payto: "QWEQWEQWEQWEWQE", -      rowid: 1, -      threshold: "USD 100", -    }, -    { -      current_state: 1, -      h_payto: "ASDASDASD", -      rowid: 1, -      threshold: "USD 100", -    }, -    { -      current_state: 2, -      h_payto: "ZXCZXCZXCXZC", -      rowid: 1, -      threshold: "USD 1000", -    }, -    { -      current_state: 0, -      h_payto: "QWEQWEQWEQWEWQE", -      rowid: 1, -      threshold: "USD 100", -    }, -    { -      current_state: 1, -      h_payto: "ASDASDASD", -      rowid: 1, -      threshold: "USD 100", -    }, -    { -      current_state: 2, -      h_payto: "ZXCZXCZXCXZC", -      rowid: 1, -      threshold: "USD 1000", -    }, -  ].map((e, idx) => { -    e.rowid = idx; -    e.threshold = `${e.threshold}${idx}`; -    return e; -  }), -}; - -function doFilter( -  list: typeof response.records, -  filter: AmlExchangeBackend.AmlState | undefined, -): typeof response.records { -  if (filter === undefined) return list; -  return list.filter((r) => r.current_state === filter); -} -  export function Cases() {    const officer = useOfficer();    const { i18n } = useTranslationContext(); diff --git a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx index bbd04daee..13e78b169 100644 --- a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx +++ b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx @@ -2,8 +2,12 @@ import { VNode, h } from "preact";  import { allForms } from "./AntiMoneyLaunderingForm.js";  import { Pages } from "../pages.js";  import { NiceForm } from "../NiceForm.js"; -import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util"; +import { AbsoluteTime, Amounts, TalerProtocolTimestamp } from "@gnu-taler/taler-util";  import { AmlExchangeBackend } from "../types.js"; +import { useAmlCasesAPI } from "../hooks/useCaseDetails.js"; +import { useOfficer } from "../hooks/useOfficer.js"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; +import { buildDecisionSignature, buildQuerySignature } from "../account.js";  export function NewFormEntry({    account, @@ -12,30 +16,58 @@ export function NewFormEntry({    account?: string;    type?: string;  }): VNode { +  const officer = useOfficer(); +    if (!account) {      return <div>no account</div>;    }    if (!type) {      return <SelectForm account={account} />;    } +  if (officer.state !== "ready") { +    return <HandleAccountNotReady officer={officer} />; +  }    const selectedForm = Number.parseInt(type ?? "0", 10);    if (Number.isNaN(selectedForm)) {      return <div>WHAT! {type}</div>;    }    const showingFrom = allForms[selectedForm].impl; +  const formName = allForms[selectedForm].name    const initial = {      fullName: "loggedIn_user_fullname",      when: AbsoluteTime.now(),      state: AmlExchangeBackend.AmlState.pending, -    threshold: Amounts.parseOrThrow("USD:10"), +    threshold: Amounts.parseOrThrow("ARS:1000"),    }; +  const api = useAmlCasesAPI() +      return (      <NiceForm        initial={initial}        form={showingFrom(initial)} -      onSubmit={(v) => { -        alert(JSON.stringify(v)); +      onSubmit={(formValue) => { +        if (formValue.state === undefined || formValue.threshold === undefined) return; +         +        const justification = { +          index: selectedForm, +          name: formName, +          value: formValue +        } +        const decision: AmlExchangeBackend.AmlDecision = { +          justification: JSON.stringify(justification), +          decision_time: TalerProtocolTimestamp.now(), +          h_payto: account, +          new_state: formValue.state, +          new_threshold: Amounts.stringify(formValue.threshold), +          officer_sig: "", +          kyc_requirements: undefined +        } +        const signature = buildDecisionSignature(officer.account.signingKey, decision); +        decision.officer_sig = signature +        api.updateDecision(officer.account.accountId, decision); + +        // alert(JSON.stringify(formValue));        }}      >        <div class="mt-6 flex items-center justify-end gap-x-6"> diff --git a/packages/aml-backoffice-ui/src/types.ts b/packages/aml-backoffice-ui/src/types.ts index 104d938b3..429b538e7 100644 --- a/packages/aml-backoffice-ui/src/types.ts +++ b/packages/aml-backoffice-ui/src/types.ts @@ -59,6 +59,9 @@ export namespace AmlExchangeBackend {    type PaytoHash = string;    type Integer = number;    type Amount = string; +  // EdDSA signatures are transmitted as 64-bytes base32 +  // binary-encoded objects with just the R and S values (base32_ binary-only). +  type EddsaSignature = string;    export interface AmlRecords {      // Array of AML records matching the query. @@ -85,4 +88,37 @@ export namespace AmlExchangeBackend {      pending = 1,      frozen = 2,    } + +   +  export interface AmlDecision { + +    // Human-readable justification for the decision. +    justification: string; +   +    // At what monthly transaction volume should the +    // decision be automatically reviewed? +    new_threshold: Amount; +   +    // Which payto-address is the decision about? +    // Identifies a GNU Taler wallet or an affected bank account. +    h_payto: PaytoHash; +   +    // What is the new AML state (e.g. frozen, unfrozen, etc.) +    // Numerical values are defined in AmlDecisionState. +    new_state: Integer; +   +    // Signature by the AML officer over a +    // TALER_MasterAmlOfficerStatusPS. +    // Must have purpose TALER_SIGNATURE_MASTER_AML_KEY. +    officer_sig: EddsaSignature; +   +    // When was the decision made? +    decision_time: Timestamp; +   +    // Optional argument to impose new KYC requirements +    // that the customer has to satisfy to unblock transactions. +    kyc_requirements?: string[]; +  } +   +  }  | 
