diff --git a/packages/snap/locales/en.json b/packages/snap/locales/en.json index eaa949d0..7dc71390 100644 --- a/packages/snap/locales/en.json +++ b/packages/snap/locales/en.json @@ -73,26 +73,26 @@ "confirmation.signAuthEntry.warning": { "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." }, - "confirmation.signAuthEntry.contract": { - "message": "Contract Address" - }, - "confirmation.signAuthEntry.function": { - "message": "Function Name" - }, - "confirmation.signAuthEntry.argument": { - "message": "Argument {index}" - }, "confirmation.signAuthEntry.expiresAt": { "message": "Expires at ledger" }, "confirmation.signAuthEntry.nonce": { "message": "Nonce" }, - "confirmation.signAuthEntry.subInvocations": { - "message": "Nested authorizations" + "confirmation.authorization.heading": { + "message": "Authorizations" + }, + "confirmation.authorization.authorizedAddress": { + "message": "Authorized address" }, - "confirmation.signAuthEntry.createContract": { - "message": "Deploy contract" + "confirmation.invocation.contractAddress": { + "message": "Contract Address" + }, + "confirmation.invocation.functionName": { + "message": "Function" + }, + "confirmation.invocation.argument": { + "message": "Argument {index}" }, "confirmation.account": { "message": "Account" @@ -319,6 +319,9 @@ "confirmation.transaction.param.argument": { "message": "Argument {index}" }, + "confirmation.transaction.param.authorizedAddress": { + "message": "Authorized address" + }, "confirmation.transaction.param.from": { "message": "From" }, diff --git a/packages/snap/locales/es.json b/packages/snap/locales/es.json index f2771f1b..605a2711 100644 --- a/packages/snap/locales/es.json +++ b/packages/snap/locales/es.json @@ -73,26 +73,26 @@ "confirmation.signAuthEntry.warning": { "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." }, - "confirmation.signAuthEntry.contract": { - "message": "Contract Address" - }, - "confirmation.signAuthEntry.function": { - "message": "Function Name" - }, - "confirmation.signAuthEntry.argument": { - "message": "Argument {index}" - }, "confirmation.signAuthEntry.expiresAt": { "message": "Expires at ledger" }, "confirmation.signAuthEntry.nonce": { "message": "Nonce" }, - "confirmation.signAuthEntry.subInvocations": { - "message": "Nested authorizations" + "confirmation.authorization.heading": { + "message": "Authorizations" + }, + "confirmation.authorization.authorizedAddress": { + "message": "Authorized address" }, - "confirmation.signAuthEntry.createContract": { - "message": "Deploy contract" + "confirmation.invocation.contractAddress": { + "message": "Contract Address" + }, + "confirmation.invocation.functionName": { + "message": "Function" + }, + "confirmation.invocation.argument": { + "message": "Argument {index}" }, "confirmation.account": { "message": "Account" @@ -319,6 +319,9 @@ "confirmation.transaction.param.argument": { "message": "Argument {index}" }, + "confirmation.transaction.param.authorizedAddress": { + "message": "Authorized address" + }, "confirmation.transaction.param.from": { "message": "From" }, diff --git a/packages/snap/messages.json b/packages/snap/messages.json index 42fd05f3..951f97e5 100644 --- a/packages/snap/messages.json +++ b/packages/snap/messages.json @@ -71,26 +71,26 @@ "confirmation.signAuthEntry.warning": { "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." }, - "confirmation.signAuthEntry.contract": { - "message": "Contract Address" - }, - "confirmation.signAuthEntry.function": { - "message": "Function Name" - }, - "confirmation.signAuthEntry.argument": { - "message": "Argument {index}" - }, "confirmation.signAuthEntry.expiresAt": { "message": "Expires at ledger" }, "confirmation.signAuthEntry.nonce": { "message": "Nonce" }, - "confirmation.signAuthEntry.subInvocations": { - "message": "Nested authorizations" + "confirmation.authorization.heading": { + "message": "Authorizations" + }, + "confirmation.authorization.authorizedAddress": { + "message": "Authorized address" }, - "confirmation.signAuthEntry.createContract": { - "message": "Deploy contract" + "confirmation.invocation.contractAddress": { + "message": "Contract Address" + }, + "confirmation.invocation.functionName": { + "message": "Function" + }, + "confirmation.invocation.argument": { + "message": "Argument {index}" }, "confirmation.account": { "message": "Account" @@ -317,6 +317,9 @@ "confirmation.transaction.param.argument": { "message": "Argument {index}" }, + "confirmation.transaction.param.authorizedAddress": { + "message": "Authorized address" + }, "confirmation.transaction.param.from": { "message": "From" }, diff --git a/packages/snap/src/handlers/keyring/signAuthEntry.test.ts b/packages/snap/src/handlers/keyring/signAuthEntry.test.ts index 9a174b38..0db30639 100644 --- a/packages/snap/src/handlers/keyring/signAuthEntry.test.ts +++ b/packages/snap/src/handlers/keyring/signAuthEntry.test.ts @@ -168,13 +168,24 @@ describe('SignAuthEntryHandler', () => { expect.objectContaining({ renderContext: expect.objectContaining({ readableAuthEntry: expect.objectContaining({ - functionType: 'invoke', - functionName: 'transfer', signatureExpirationLedger: 1_000_000, nonce: '123456789', - args: [], - subInvocations: [], - contractAddress: expect.stringMatching(/^C[A-Z2-7]+$/u), + authorizations: [ + { + params: [ + { + key: 'contractId', + value: expect.stringMatching(/^C[A-Z2-7]+$/u), + type: 'copyable', + }, + { + key: 'functionName', + value: 'transfer', + type: 'text', + }, + ], + }, + ], }), }), }), @@ -222,15 +233,40 @@ describe('SignAuthEntryHandler', () => { expect.objectContaining({ renderContext: expect.objectContaining({ readableAuthEntry: expect.objectContaining({ - functionName: 'transfer', - args: [recipient, '10'], - subInvocations: [ - expect.objectContaining({ - functionType: 'invoke', - functionName: 'approve', - args: [], - subInvocations: [], - }), + authorizations: [ + { + params: [ + { + key: 'contractId', + value: expect.stringMatching(/^C[A-Z2-7]+$/u), + type: 'copyable', + }, + { + key: 'functionName', + value: 'transfer', + type: 'text', + }, + { + key: 'arguments', + value: [recipient, '10'], + type: 'json', + }, + ], + }, + { + params: [ + { + key: 'contractId', + value: expect.stringMatching(/^C[A-Z2-7]+$/u), + type: 'copyable', + }, + { + key: 'functionName', + value: 'approve', + type: 'text', + }, + ], + }, ], }), }), diff --git a/packages/snap/src/handlers/keyring/signAuthEntry.ts b/packages/snap/src/handlers/keyring/signAuthEntry.ts index 04fa4890..69a1860f 100644 --- a/packages/snap/src/handlers/keyring/signAuthEntry.ts +++ b/packages/snap/src/handlers/keyring/signAuthEntry.ts @@ -7,44 +7,21 @@ import type { AccountResolver } from '../accountResolver'; import { BaseSep43KeyringHandler } from './base'; import type { Sep43Error } from './exceptions'; import type { StellarKeyringAccount } from '../../services/account'; -import { - getAddress, - getFunctionName, - parseScValToReadableJson, -} from '../../services/transaction/xdrParser'; +import type { ReadableAuthorizationJson } from '../../services/transaction'; +import { AuthorizationMapper } from '../../services/transaction'; import type { Wallet } from '../../services/wallet'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import type { ILogger } from '../../utils'; -/** - * Decoded summary of a single Soroban authorized invocation — used both for - * the root call the user is authorizing and, recursively, for every nested - * call the same authorization implicitly covers. - */ -export type ReadableInvocation = { - /** `'invoke'` for direct contract calls, `'createContract'` / `'createContractV2'` for deployments. */ - functionType: 'invoke' | 'createContract' | 'createContractV2'; - /** Strkey-encoded contract `C…` address being invoked, or `null` for contract-creation entries. */ - contractAddress: string | null; - /** Function being invoked, or `null` for contract-creation entries. */ - functionName: string | null; - /** - * Decoded function arguments as display strings, in declaration order. - * Empty array for contract-creation entries (which carry no args). - */ - args: string[]; - /** Nested invocations this authorization also covers. */ - subInvocations: ReadableInvocation[]; -}; - /** * Human-readable Soroban auth entry summary rendered in the confirmation - * dialog. The struct guarantees the preimage parses and is the Soroban - * authorization variant; this shape extracts only the fields a user can - * meaningfully verify. + * dialog. Invocation details come from {@link AuthorizationMapper}; nonce and + * expiry are preimage-only fields. */ -export type ReadableAuthEntry = ReadableInvocation & { +export type ReadableAuthEntry = { + /** Flat list: root invocation first, then depth-first sub-invocations. */ + authorizations: ReadableAuthorizationJson[]; /** Ledger sequence at which this authorization expires (exclusive). */ signatureExpirationLedger: number; /** Replay-protection nonce. */ @@ -94,7 +71,7 @@ export class SignAuthEntryHandler extends BaseSep43KeyringHandler< const { account, wallet } = resolved; const { authEntry } = request.request.params; - const readableAuthEntry = decodeSorobanAuthPreimage(authEntry); + const readableAuthEntry = this.#decodeSorobanAuthPreimage(authEntry); if (!(await this.#confirm(request, account, readableAuthEntry))) { throw new UserRejectedRequestError() as unknown as Error; @@ -137,93 +114,25 @@ export class SignAuthEntryHandler extends BaseSep43KeyringHandler< })) === true ); } -} -/** - * Decodes a SEP-43 `signAuthEntry` payload into the user-facing summary. - * The struct has already validated that the input parses as - * `HashIdPreimage.envelopeTypeSorobanAuthorization`, so the cast is safe. - * - * @param authEntry - Base64-encoded `HashIdPreimage` XDR. - * @returns Fields displayed in the confirmation dialog. - */ -function decodeSorobanAuthPreimage(authEntry: string): ReadableAuthEntry { - const preimage = xdr.HashIdPreimage.fromXDR(authEntry, 'base64'); - const sorobanAuth = preimage.sorobanAuthorization(); - - return { - ...decodeInvocation(sorobanAuth.invocation()), - signatureExpirationLedger: sorobanAuth.signatureExpirationLedger(), - nonce: sorobanAuth.nonce().toString(), - }; -} - -/** - * Recursively decodes a single Soroban authorized invocation (the root call - * or any nested sub-invocation) into a UI-friendly shape. The same data - * matters at every depth: which contract, which function, what arguments, - * what's nested below. - * - * @param invocation - The `SorobanAuthorizedInvocation` to decode. - * @returns A {@link ReadableInvocation} for display. - */ -function decodeInvocation( - invocation: xdr.SorobanAuthorizedInvocation, -): ReadableInvocation { - const fn = invocation.function(); + /** + * Decodes a SEP-43 `signAuthEntry` payload into the user-facing summary. + * The struct has already validated that the input parses as + * `HashIdPreimage.envelopeTypeSorobanAuthorization`, so the cast is safe. + * + * @param authEntry - Base64-encoded `HashIdPreimage` XDR. + * @returns Fields displayed in the confirmation dialog. + */ + #decodeSorobanAuthPreimage(authEntry: string): ReadableAuthEntry { + const preimage = xdr.HashIdPreimage.fromXDR(authEntry, 'base64'); + const sorobanAuth = preimage.sorobanAuthorization(); - let functionType: ReadableInvocation['functionType']; - let contractAddress: string | null; - let functionName: string | null; - let args: string[]; - switch (fn.switch()) { - case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn(): { - const contractFn = fn.contractFn(); - functionType = 'invoke'; - contractAddress = getAddress(contractFn.contractAddress()); - functionName = getFunctionName(contractFn.functionName()); - args = readScVals(contractFn.args()); - break; - } - case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractHostFn(): - functionType = 'createContract'; - contractAddress = null; - functionName = null; - args = []; - break; - case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractV2HostFn(): - functionType = 'createContractV2'; - contractAddress = null; - functionName = null; - args = []; - break; - /* istanbul ignore next — exhaustive switch over an SDK enum */ - default: - functionType = 'invoke'; - contractAddress = null; - functionName = null; - args = []; + return { + authorizations: new AuthorizationMapper().mapInvocation( + sorobanAuth.invocation(), + ), + signatureExpirationLedger: sorobanAuth.signatureExpirationLedger(), + nonce: sorobanAuth.nonce().toString(), + }; } - - return { - functionType, - contractAddress, - functionName, - args, - subInvocations: invocation.subInvocations().map(decodeInvocation), - }; } - -/** - * Decodes the contract-function `ScVal[]` arguments into a list of - * user-readable strings via {@link parseScValToReadableJson}. - * - * @param scVals - Function arguments as raw `ScVal`s. - * @returns One display string per argument, in declaration order. - */ -function readScVals(scVals: xdr.ScVal[]): string[] { - return scVals.map((scv) => parseScValToReadableJson(scv)); -} - -/* istanbul ignore next — re-export for tests */ -export { decodeSorobanAuthPreimage }; diff --git a/packages/snap/src/services/transaction/OperationMapper.test.ts b/packages/snap/src/services/transaction/OperationMapper.test.ts index 88dbe433..41c45828 100644 --- a/packages/snap/src/services/transaction/OperationMapper.test.ts +++ b/packages/snap/src/services/transaction/OperationMapper.test.ts @@ -70,6 +70,7 @@ describe('OperationMapper', () => { // Builder `fee` is per operation; total is fee × operation count. expect(json.feeStroops).toBe('400'); expect(json.operationCount).toBe(2); + expect(json.authorizations).toStrictEqual([]); expect(() => JSON.stringify(json)).not.toThrow(); expect(json.operations[0]).toMatchObject({ @@ -739,6 +740,63 @@ describe('OperationMapper', () => { expect(keys).not.toContain('arguments'); }); + it('maps invokeHostFunction auth as a separate Authorizations section with args', () => { + // Mainnet envelope: host `swap` ≠ auth `transfer` (with args). + const envelopeXdr = + 'AAAAAgAAAACKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXAABhqAAAAAAAAAAZQAAAAEAAAAAAAAAAAAAAABqcybwAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABJbT82FmuwvpjSEOMSJs8PBDJi20hvk/TyzDLaJU++XcAAAAEc3dhcAAAAAIAAAAKAAAAAAAAAAAAAAAAAJiWgAAAAAoAAAAAAAAAAAAAAAAAiVRAAAAAAQAAAAEAAAAAAAAAAIE5dw6ofRdfVqNUZsNMfszLjYqRtO43ol32D1uPybOUzy04OgscWvgC+vCAAAAAEAAAAAEAAAABAAAAEQAAAAEAAAACAAAADwAAAApwdWJsaWNfa2V5AAAAAAANAAAAIIE5dw6ofRdfVqNUZsNMfszLjYqRtO43ol32D1uPybOUAAAADwAAAAlzaWduYXR1cmUAAAAAAAANAAAAQKJCq/0PE6THPsfREBepJMi+4Lqg68QfwGMF8zI7dhXRMTSuTC6Kb3fqqUh1zSKfoC4gSsM6GN0l6ew9Jsp6TgEAAAAAAAAAAdeSi3LCcDzP6vfrn/TvTVBKVai5efybRQ6iyEK00c5hAAAACHRyYW5zZmVyAAAAAwAAABIAAAAAAAAAAIE5dw6ofRdfVqNUZsNMfszLjYqRtO43ol32D1uPybOUAAAAEgAAAAAAAAAA7UkoxijRwsbq6QM4kFmVYSlZJzpcY/k2NsFGFKyHN9EAAAAKAAAAAAAAAAAAAAAAAJiWgAAAAAAAAAAAAAAAAA=='; + + const wrapped = Transaction.fromXdr({ + xdr: envelopeXdr, + scope: KnownCaip2ChainId.Mainnet, + }); + const readable = mapper.mapTransaction(wrapped); + const [op] = readable.operations; + + expect(op?.type).toBe('invokeHostFunction'); + expect(op?.params.map((param) => param.key)).toStrictEqual([ + 'contractId', + 'functionName', + 'arguments', + ]); + expect(op?.params).toStrictEqual( + expect.arrayContaining([ + { key: 'functionName', value: 'swap', type: 'text' }, + ]), + ); + + expect(readable.authorizations).toHaveLength(1); + expect( + readable.authorizations[0]?.params.map((param) => param.key), + ).toStrictEqual([ + 'authorizedAddress', + 'contractId', + 'functionName', + 'arguments', + ]); + expect(readable.authorizations[0]?.params).toStrictEqual([ + { + key: 'authorizedAddress', + value: 'GCATS5YOVB6ROX2WUNKGNQ2MP3GMXDMKSG2O4N5CLX3A6W4PZGZZI55U', + type: 'copyable', + }, + { + key: 'contractId', + value: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC', + type: 'copyable', + }, + { key: 'functionName', value: 'transfer', type: 'text' }, + { + key: 'arguments', + value: [ + 'GCATS5YOVB6ROX2WUNKGNQ2MP3GMXDMKSG2O4N5CLX3A6W4PZGZZI55U', + 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + '10000000', + ], + type: 'json', + }, + ]); + }); + it('maps extendFootprintTtl operation', () => { const kp = Keypair.random(); const account = new Account(kp.publicKey(), '1'); diff --git a/packages/snap/src/services/transaction/OperationMapper.ts b/packages/snap/src/services/transaction/OperationMapper.ts index b87f4ff6..215a623e 100644 --- a/packages/snap/src/services/transaction/OperationMapper.ts +++ b/packages/snap/src/services/transaction/OperationMapper.ts @@ -16,17 +16,20 @@ import { bufferToUint8Array } from '../../utils'; /** * Semantic hint for how a confirmation row should be rendered. */ -export type ReadableFieldType = - | 'address' - | 'asset' - | 'assetWithAmount' - | 'amount' - | 'price' - | 'text' - | 'number' - | 'boolean' - | 'json' - | 'copyable'; +export const FieldType = { + address: 'address', + asset: 'asset', + assetWithAmount: 'assetWithAmount', + amount: 'amount', + price: 'price', + text: 'text', + number: 'number', + boolean: 'boolean', + json: 'json', + copyable: 'copyable', +} as const; + +export type FieldType = (typeof FieldType)[keyof typeof FieldType]; /** * One labeled value row for operation confirmation UI. @@ -34,7 +37,7 @@ export type ReadableFieldType = export type ReadableOperationField = { key: string; value: Json; - type: ReadableFieldType; + type: FieldType; }; /** @@ -59,6 +62,18 @@ export type ReadableOperationJson = { params: ReadableOperationField[]; }; +/** + * One Soroban authorization entry for the confirmation dialog (own section, + * parallel to {@link ReadableOperationJson} for `invokeHostFunction`). + */ +export type ReadableAuthorizationJson = { + params: ReadableAuthorizationParams[]; +}; + +export type ReadableAuthorizationParams = ReadableOperationField & { + key: 'authorizedAddress' | 'contractId' | 'functionName' | 'arguments'; +}; + /** * Transaction-level envelope plus per-operation summaries. */ @@ -70,6 +85,11 @@ export type ReadableTransactionJson = { feeSourceAccount: string; memo: string | null; operations: ReadableOperationJson[]; + /** + * Auth entries from `invokeHostFunction` ops. Rendered as a separate + * "Authorizations" section — not nested under the host-function params. + */ + authorizations: ReadableAuthorizationJson[]; }; const SOROBAN_OPERATION_TYPES = new Set([ @@ -114,10 +134,170 @@ function accountAuthFlagsMaskToText(flags: number): string[] { } /* eslint-enable no-bitwise */ +/** + * Abstract class for mapping operations to readable JSON. + */ +class AbstractOperationMapper { + protected field( + key: string, + value: Json, + type: FieldType, + ): ReadableOperationField { + let normalizedValue: Json = value; + if (type === FieldType.amount) { + normalizedValue = this.normalizeAmount(value); + } else if (type === FieldType.assetWithAmount && Array.isArray(value)) { + const [asset, amount] = value as [Json, Json]; + normalizedValue = [asset, this.normalizeAmount(amount)]; + } + return { key, value: normalizedValue, type }; + } + + protected normalizeAmount(value: Json): Json { + if (typeof value === 'string' && /^-?\d+(\.\d+)?$/u.test(value)) { + return new BigNumber(value).toFixed(); + } + return value; + } +} + +export class AuthorizationMapper extends AbstractOperationMapper { + /** + * Collects Soroban auth entries into dialog sections (root invocation + + * nested sub-invocations flattened). + * + * @param entries - Soroban authorization entries from `invokeHostFunction`. + * @returns Authorization summaries for the confirmation UI. + */ + mapAuthorizations( + entries: readonly xdr.SorobanAuthorizationEntry[], + ): ReadableAuthorizationJson[] { + const authorizations: ReadableAuthorizationJson[] = []; + for (const entry of entries) { + try { + authorizations.push( + ...this.mapInvocation( + entry.rootInvocation(), + this.#getAuthAddress(entry), + ), + ); + } catch { + // Skip malformed auth entries; host-function section still renders. + } + } + return authorizations; + } + + /** + * Maps one authorized invocation tree into flat confirmation sections. + * Used by transaction auth entries and SEP-43 `signAuthEntry` preimages. + * + * @param invocation - Root or nested `SorobanAuthorizedInvocation`. + * @param authAddress - Credential address when known; otherwise `null`. + * @returns One section per invocation (root first, then depth-first subs). + */ + mapInvocation( + invocation: xdr.SorobanAuthorizedInvocation, + authAddress: string | null = null, + ): ReadableAuthorizationJson[] { + const authorizations: ReadableAuthorizationJson[] = []; + this.#appendInvocationAuthorizations( + authorizations, + invocation, + authAddress, + ); + return authorizations; + } + + #appendInvocationAuthorizations( + authorizations: ReadableAuthorizationJson[], + invocation: xdr.SorobanAuthorizedInvocation, + authAddress: string | null, + ): void { + const params = this.#mapAuthorizedInvocationParams( + invocation, + authAddress, + ) as ReadableAuthorizationParams[]; + + if (params.length > 0) { + authorizations.push({ params }); + } + for (const sub of invocation.subInvocations()) { + this.#appendInvocationAuthorizations(authorizations, sub, authAddress); + } + } + + #mapAuthorizedInvocationParams( + invocation: xdr.SorobanAuthorizedInvocation, + authAddress: string | null, + ): ReadableOperationField[] { + const rows: ReadableOperationField[] = []; + if (authAddress !== null) { + rows.push( + this.field('authorizedAddress', authAddress, FieldType.copyable), + ); + } + + const fn = invocation.function(); + switch (fn.switch()) { + case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn(): { + const contractFn = fn.contractFn(); + rows.push( + this.field( + 'contractId', + getAddress(contractFn.contractAddress()), + FieldType.copyable, + ), + ); + rows.push( + this.field( + 'functionName', + getFunctionName(contractFn.functionName()), + FieldType.text, + ), + ); + const args = contractFn.args(); + if (args.length > 0) { + rows.push( + this.field( + 'arguments', + args.map((arg) => parseScValToReadableJson(arg)), + FieldType.json, + ), + ); + } + break; + } + case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractHostFn(): + rows.push(this.field('functionName', 'createContract', FieldType.text)); + break; + case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractV2HostFn(): + rows.push( + this.field('functionName', 'createContractV2', FieldType.text), + ); + break; + default: + rows.push(this.field('functionName', 'authorization', FieldType.text)); + } + return rows; + } + + #getAuthAddress(entry: xdr.SorobanAuthorizationEntry): string | null { + const credentials = entry.credentials(); + if ( + credentials.switch() !== + xdr.SorobanCredentialsType.sorobanCredentialsAddress() + ) { + return null; + } + return getAddress(credentials.address().address()); + } +} + /** * Maps Stellar {@link Operation} values to plain JSON-friendly objects for signing UX. */ -export class OperationMapper { +export class OperationMapper extends AbstractOperationMapper { /** * Builds a readable summary for every operation in the wrapped transaction. * @@ -128,9 +308,11 @@ export class OperationMapper { const { sourceAccount } = transaction; const operations = transaction.transactionOperations.map( (sdkOperation, index) => - this.mapOperation(sdkOperation, index, sourceAccount), + this.#mapOperation(sdkOperation, index, sourceAccount), + ); + const authorizations = this.#mapAuthorizations( + transaction.transactionOperations, ); - return { scope: transaction.scope, feeStroops: transaction.totalFee.toFixed(0), @@ -139,9 +321,28 @@ export class OperationMapper { feeSourceAccount: transaction.feeSourceAccount, memo: transaction.getMemo(), operations, + authorizations, }; } + #mapAuthorizations( + operations: readonly Operation[], + ): ReadableAuthorizationJson[] { + const authMapper = new AuthorizationMapper(); + const authorizations: ReadableAuthorizationJson[] = []; + for (const operation of operations) { + if (operation.type !== StellarOperationType.InvokeHostFunction) { + continue; + } + const { auth } = operation; + if (!auth || auth.length === 0) { + continue; + } + authorizations.push(...authMapper.mapAuthorizations(auth)); + } + return authorizations; + } + /** * Maps a single SDK operation. * @@ -150,7 +351,7 @@ export class OperationMapper { * @param transactionSource - Transaction source when `operation.source` is omitted. * @returns Serializable operation summary. */ - mapOperation( + #mapOperation( operation: Operation, index: number, transactionSource: string, @@ -178,6 +379,7 @@ export class OperationMapper { const rows: ReadableOperationField[] = []; try { const { func } = hostOp; + if ( func?.switch() === xdr.HostFunctionType.hostFunctionTypeInvokeContract() @@ -185,12 +387,14 @@ export class OperationMapper { const invokeArgs = func.invokeContract(); const contractAddress = getAddress(invokeArgs.contractAddress()); const functionName = getFunctionName(invokeArgs.functionName()); - rows.push(this.#field('contractId', contractAddress, 'copyable')); - rows.push(this.#field('functionName', functionName, 'text')); + rows.push( + this.field('contractId', contractAddress, FieldType.copyable), + ); + rows.push(this.field('functionName', functionName, FieldType.text)); const args = invokeArgs.args(); if (args.length > 0) { rows.push( - this.#field( + this.field( 'arguments', args.map((arg) => parseScValToReadableJson(arg)), 'json', @@ -203,10 +407,10 @@ export class OperationMapper { } if (rows.length === 0) { rows.push( - this.#field( + this.field( 'note', 'Soroban invokeHostFunction; review contract call on a block explorer or dedicated UI.', - 'text', + FieldType.text, ), ); } @@ -214,16 +418,16 @@ export class OperationMapper { } if (operation.type === StellarOperationType.ExtendFootprintTtl) { const extendOp = operation; - return [this.#field('extendTo', extendOp.extendTo, 'number')]; + return [this.field('extendTo', extendOp.extendTo, FieldType.number)]; } if (operation.type === StellarOperationType.RestoreFootprint) { - return [this.#field('note', 'Soroban restoreFootprint.', 'text')]; + return [this.field('note', 'Soroban restoreFootprint.', FieldType.text)]; } return [ - this.#field( + this.field( 'note', 'Soroban operation; expand separately if needed.', - 'text', + FieldType.text, ), ]; } @@ -233,22 +437,26 @@ export class OperationMapper { case StellarOperationType.Payment: { const payment = operation; return [ - this.#field('destination', payment.destination, 'address'), - this.#field( + this.field('destination', payment.destination, FieldType.address), + this.field( 'asset', [payment.asset.toString(), payment.amount], - 'assetWithAmount', + FieldType.assetWithAmount, ), ]; } case StellarOperationType.CreateAccount: { const createAccount = operation; return [ - this.#field('destination', createAccount.destination, 'address'), - this.#field( + this.field( + 'destination', + createAccount.destination, + FieldType.address, + ), + this.field( 'startingBalance', createAccount.startingBalance, - 'amount', + FieldType.amount, ), ]; } @@ -257,96 +465,104 @@ export class OperationMapper { return [ // we don't use assetWithAmount here because the line is not necessarily a classic asset // and we are not sending amount here. - this.#field('line', this.#formatTrustLine(changeTrust.line), 'text'), - this.#field('limit', changeTrust.limit, 'amount'), + this.field( + 'line', + this.#formatTrustLine(changeTrust.line), + FieldType.text, + ), + this.field('limit', changeTrust.limit, FieldType.amount), ]; } case StellarOperationType.AccountMerge: { const accountMerge = operation; return [ - this.#field('destination', accountMerge.destination, 'address'), + this.field( + 'destination', + accountMerge.destination, + FieldType.address, + ), ]; } case StellarOperationType.PathPaymentStrictReceive: { const pathReceive = operation; return [ - this.#field( + this.field( 'sendAsset', [pathReceive.sendAsset.toString(), pathReceive.sendMax], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field('destination', pathReceive.destination, 'address'), - this.#field( + this.field('destination', pathReceive.destination, FieldType.address), + this.field( 'destAsset', [pathReceive.destAsset.toString(), pathReceive.destAmount], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field( + this.field( 'path', pathReceive.path.map((asset) => asset.toString()), - 'json', + FieldType.json, ), ]; } case StellarOperationType.PathPaymentStrictSend: { const pathSend = operation; return [ - this.#field( + this.field( 'sendAsset', [pathSend.sendAsset.toString(), pathSend.sendAmount], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field('destination', pathSend.destination, 'address'), - this.#field( + this.field('destination', pathSend.destination, FieldType.address), + this.field( 'destAsset', [pathSend.destAsset.toString(), pathSend.destMin], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field( + this.field( 'path', pathSend.path.map((asset) => asset.toString()), - 'json', + FieldType.json, ), ]; } case StellarOperationType.ManageSellOffer: { const sellOffer = operation; return [ - this.#field( + this.field( 'selling', [sellOffer.selling.toString(), sellOffer.amount], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field('buying', sellOffer.buying.toString(), 'asset'), - this.#field('price', sellOffer.price, 'price'), - this.#field('offerId', sellOffer.offerId, 'text'), + this.field('buying', sellOffer.buying.toString(), FieldType.asset), + this.field('price', sellOffer.price, FieldType.price), + this.field('offerId', sellOffer.offerId, FieldType.text), ]; } case StellarOperationType.ManageBuyOffer: { const buyOffer = operation; return [ - this.#field( + this.field( 'buying', [buyOffer.buying.toString(), buyOffer.buyAmount], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field('selling', buyOffer.selling.toString(), 'asset'), - this.#field('price', buyOffer.price, 'price'), - this.#field('offerId', buyOffer.offerId, 'text'), + this.field('selling', buyOffer.selling.toString(), FieldType.asset), + this.field('price', buyOffer.price, FieldType.price), + this.field('offerId', buyOffer.offerId, FieldType.text), ]; } case StellarOperationType.CreatePassiveSellOffer: { const passiveOffer = operation; return [ - this.#field( + this.field( 'selling', [passiveOffer.selling.toString(), passiveOffer.amount], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field('buying', passiveOffer.buying.toString(), 'asset'), - this.#field('price', passiveOffer.price, 'price'), + this.field('buying', passiveOffer.buying.toString(), FieldType.asset), + this.field('price', passiveOffer.price, FieldType.price), ]; } case StellarOperationType.SetOptions: { @@ -354,49 +570,71 @@ export class OperationMapper { const rows: ReadableOperationField[] = []; if (setOptions.inflationDest !== undefined) { rows.push( - this.#field('inflationDest', setOptions.inflationDest, 'address'), + this.field( + 'inflationDest', + setOptions.inflationDest, + FieldType.address, + ), ); } if (setOptions.clearFlags !== undefined) { rows.push( - this.#field( + this.field( 'clearFlags', accountAuthFlagsMaskToText(setOptions.clearFlags), - 'text', + FieldType.text, ), ); } if (setOptions.setFlags !== undefined) { rows.push( - this.#field( + this.field( 'setFlags', accountAuthFlagsMaskToText(setOptions.setFlags), - 'text', + FieldType.text, ), ); } if (setOptions.masterWeight !== undefined) { rows.push( - this.#field('masterWeight', setOptions.masterWeight, 'number'), + this.field( + 'masterWeight', + setOptions.masterWeight, + FieldType.number, + ), ); } if (setOptions.lowThreshold !== undefined) { rows.push( - this.#field('lowThreshold', setOptions.lowThreshold, 'number'), + this.field( + 'lowThreshold', + setOptions.lowThreshold, + FieldType.number, + ), ); } if (setOptions.medThreshold !== undefined) { rows.push( - this.#field('medThreshold', setOptions.medThreshold, 'number'), + this.field( + 'medThreshold', + setOptions.medThreshold, + FieldType.number, + ), ); } if (setOptions.highThreshold !== undefined) { rows.push( - this.#field('highThreshold', setOptions.highThreshold, 'number'), + this.field( + 'highThreshold', + setOptions.highThreshold, + FieldType.number, + ), ); } if (setOptions.homeDomain !== undefined) { - rows.push(this.#field('homeDomain', setOptions.homeDomain, 'text')); + rows.push( + this.field('homeDomain', setOptions.homeDomain, FieldType.text), + ); } if ('signer' in setOptions && setOptions.signer !== undefined) { // SDK Signer is a union of disjoint interfaces; cast to Record for key-based branching. @@ -406,40 +644,44 @@ export class OperationMapper { >; if ('ed25519PublicKey' in signer) { rows.push( - this.#field( + this.field( 'signerEd25519', signer.ed25519PublicKey as string, - 'address', + FieldType.address, ), ); } else if ('sha256Hash' in signer) { rows.push( - this.#field( + this.field( 'signerSha256Hash', bufferToUint8Array(signer.sha256Hash as Buffer).toString('hex'), - 'text', + FieldType.text, ), ); } else if ('preAuthTx' in signer) { rows.push( - this.#field( + this.field( 'signerPreAuthTx', bufferToUint8Array(signer.preAuthTx as Buffer).toString('hex'), - 'text', + FieldType.text, ), ); } else if ('ed25519SignedPayload' in signer) { rows.push( - this.#field( + this.field( 'signerSignedPayload', signer.ed25519SignedPayload as string, - 'text', + FieldType.text, ), ); } if (signer.weight !== undefined) { rows.push( - this.#field('signerWeight', Number(signer.weight), 'number'), + this.field( + 'signerWeight', + Number(signer.weight), + FieldType.number, + ), ); } } @@ -448,15 +690,15 @@ export class OperationMapper { case StellarOperationType.AllowTrust: { const allowTrustOp = operation; const rows: ReadableOperationField[] = [ - this.#field('trustor', allowTrustOp.trustor, 'address'), - this.#field('assetCode', allowTrustOp.assetCode, 'text'), + this.field('trustor', allowTrustOp.trustor, FieldType.address), + this.field('assetCode', allowTrustOp.assetCode, FieldType.text), ]; if (allowTrustOp.authorize !== undefined) { const auth = allowTrustOp.authorize; if (typeof auth === 'boolean') { - rows.push(this.#field('authorize', auth, 'boolean')); + rows.push(this.field('authorize', auth, FieldType.boolean)); } else { - rows.push(this.#field('authorize', String(auth), 'text')); + rows.push(this.field('authorize', String(auth), FieldType.text)); } } return rows; @@ -464,48 +706,52 @@ export class OperationMapper { case StellarOperationType.ManageData: { const manageDataOp = operation; return [ - this.#field('name', manageDataOp.name, 'text'), - this.#field( + this.field('name', manageDataOp.name, FieldType.text), + this.field( 'valueBase64', manageDataOp.value ? bufferToUint8Array(manageDataOp.value).toString('base64') : null, - 'text', + FieldType.text, ), ]; } case StellarOperationType.BumpSequence: { const bumpSequence = operation; - return [this.#field('bumpTo', bumpSequence.bumpTo, 'text')]; + return [this.field('bumpTo', bumpSequence.bumpTo, FieldType.text)]; } case StellarOperationType.Inflation: return []; case StellarOperationType.CreateClaimableBalance: { const createCb = operation; return [ - this.#field( + this.field( 'asset', [createCb.asset.toString(), createCb.amount], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field( + this.field( 'claimants', createCb.claimants.map((claimant) => ({ destination: claimant.destination, - predicate: OperationMapper.#formatPredicate(claimant.predicate), + predicate: this.#formatPredicate(claimant.predicate), })), - 'json', + FieldType.json, ), ]; } case StellarOperationType.ClaimClaimableBalance: { const claimCb = operation; - return [this.#field('balanceId', claimCb.balanceId, 'text')]; + return [this.field('balanceId', claimCb.balanceId, FieldType.text)]; } case StellarOperationType.BeginSponsoringFutureReserves: { const beginSponsor = operation; return [ - this.#field('sponsoredId', beginSponsor.sponsoredId, 'address'), + this.field( + 'sponsoredId', + beginSponsor.sponsoredId, + FieldType.address, + ), ]; } case StellarOperationType.EndSponsoringFutureReserves: @@ -515,17 +761,17 @@ export class OperationMapper { case StellarOperationType.Clawback: { const clawback = operation; return [ - this.#field( + this.field( 'asset', [clawback.asset.toString(), clawback.amount], - 'assetWithAmount', + FieldType.assetWithAmount, ), - this.#field('from', clawback.from, 'address'), + this.field('from', clawback.from, FieldType.address), ]; } case StellarOperationType.ClawbackClaimableBalance: { const clawbackCb = operation; - return [this.#field('balanceId', clawbackCb.balanceId, 'text')]; + return [this.field('balanceId', clawbackCb.balanceId, FieldType.text)]; } case StellarOperationType.SetTrustLineFlags: { const trustFlags = operation; @@ -547,53 +793,60 @@ export class OperationMapper { clearFlagLabels.push('clawbackEnabled'); } const rows: ReadableOperationField[] = [ - this.#field('trustor', trustFlags.trustor, 'address'), - this.#field('asset', trustFlags.asset.toString(), 'asset'), + this.field('trustor', trustFlags.trustor, FieldType.address), + this.field('asset', trustFlags.asset.toString(), FieldType.asset), ]; if (setFlagLabels.length > 0) { - rows.push(this.#field('setFlags', setFlagLabels, 'text')); + rows.push(this.field('setFlags', setFlagLabels, FieldType.text)); } if (clearFlagLabels.length > 0) { - rows.push(this.#field('clearFlags', clearFlagLabels, 'text')); + rows.push(this.field('clearFlags', clearFlagLabels, FieldType.text)); } return rows; } case StellarOperationType.LiquidityPoolDeposit: { const poolDeposit = operation; return [ - this.#field('liquidityPoolId', poolDeposit.liquidityPoolId, 'text'), - this.#field('maxAmountA', poolDeposit.maxAmountA, 'amount'), - this.#field('maxAmountB', poolDeposit.maxAmountB, 'amount'), - this.#field('minPrice', poolDeposit.minPrice, 'price'), - this.#field('maxPrice', poolDeposit.maxPrice, 'price'), + this.field( + 'liquidityPoolId', + poolDeposit.liquidityPoolId, + FieldType.text, + ), + this.field('maxAmountA', poolDeposit.maxAmountA, FieldType.amount), + this.field('maxAmountB', poolDeposit.maxAmountB, FieldType.amount), + this.field('minPrice', poolDeposit.minPrice, FieldType.price), + this.field('maxPrice', poolDeposit.maxPrice, FieldType.price), ]; } case StellarOperationType.LiquidityPoolWithdraw: { const poolWithdraw = operation; return [ - this.#field('liquidityPoolId', poolWithdraw.liquidityPoolId, 'text'), - this.#field('amount', poolWithdraw.amount, 'amount'), - this.#field('minAmountA', poolWithdraw.minAmountA, 'amount'), - this.#field('minAmountB', poolWithdraw.minAmountB, 'amount'), + this.field( + 'liquidityPoolId', + poolWithdraw.liquidityPoolId, + FieldType.text, + ), + this.field('amount', poolWithdraw.amount, FieldType.amount), + this.field('minAmountA', poolWithdraw.minAmountA, FieldType.amount), + this.field('minAmountB', poolWithdraw.minAmountB, FieldType.amount), ]; } case StellarOperationType.InvokeHostFunction: case StellarOperationType.ExtendFootprintTtl: case StellarOperationType.RestoreFootprint: return [ - this.#field( + this.field( 'note', 'Soroban operation; use non-classic mapping path.', - 'text', + FieldType.text, ), ]; default: { - const unknownOp = operation; return [ - this.#field( + this.field( 'note', - `Unhandled or newer operation type "${unknownOp.type}".`, - 'text', + `Unhandled or newer operation type.`, + FieldType.text, ), ]; } @@ -607,25 +860,25 @@ export class OperationMapper { offerId: string; }; return [ - this.#field('seller', revokeOffer.seller, 'address'), - this.#field('offerId', revokeOffer.offerId, 'text'), + this.field('seller', revokeOffer.seller, 'address'), + this.field('offerId', revokeOffer.offerId, 'text'), ]; } if ('balanceId' in operation && !('account' in operation)) { const revokeCb = operation as { balanceId: string }; - return [this.#field('balanceId', revokeCb.balanceId, 'text')]; + return [this.field('balanceId', revokeCb.balanceId, 'text')]; } if ('liquidityPoolId' in operation && !('account' in operation)) { const revokePool = operation as { liquidityPoolId: string }; return [ - this.#field('liquidityPoolId', revokePool.liquidityPoolId, 'text'), + this.field('liquidityPoolId', revokePool.liquidityPoolId, 'text'), ]; } if ('account' in operation && 'name' in operation) { const revokeData = operation as { account: string; name: string }; return [ - this.#field('account', revokeData.account, 'address'), - this.#field('name', revokeData.name, 'text'), + this.field('account', revokeData.account, 'address'), + this.field('name', revokeData.name, 'text'), ]; } if ('account' in operation && 'signer' in operation) { @@ -634,8 +887,8 @@ export class OperationMapper { signer: unknown; }; return [ - this.#field('account', revokeSigner.account, 'address'), - this.#field('signer', JSON.stringify(revokeSigner.signer), 'text'), + this.field('account', revokeSigner.account, 'address'), + this.field('signer', JSON.stringify(revokeSigner.signer), 'text'), ]; } if ('account' in operation && 'asset' in operation) { @@ -649,50 +902,19 @@ export class OperationMapper { ? asset.getLiquidityPoolId() : asset.toString(); return [ - this.#field('account', revokeTrust.account, 'address'), - this.#field('asset', assetLabel, 'asset'), + this.field('account', revokeTrust.account, 'address'), + this.field('asset', assetLabel, 'asset'), ]; } if ('account' in operation) { const revokeAccount = operation as { account: string }; - return [this.#field('account', revokeAccount.account, 'address')]; + return [this.field('account', revokeAccount.account, 'address')]; } return [ - this.#field('note', 'revokeSponsorship shape not recognized.', 'text'), + this.field('note', 'revokeSponsorship shape not recognized.', 'text'), ]; } - #field( - key: string, - value: Json, - type: ReadableFieldType, - ): ReadableOperationField { - let normalizedValue: Json = value; - if (type === 'amount') { - normalizedValue = OperationMapper.#normalizeStellarAmount(value); - } else if (type === 'assetWithAmount' && Array.isArray(value)) { - const [asset, amount] = value as [Json, Json]; - normalizedValue = [ - asset, - OperationMapper.#normalizeStellarAmount(amount), - ]; - } - return { key, value: normalizedValue, type }; - } - - /** - * Strips trailing zeros from Stellar amount strings; passes other values through. - * - * @param value - Field value as produced by the Stellar SDK operation. - * @returns Normalized amount string, or the original value when not numeric. - */ - static #normalizeStellarAmount(value: Json): Json { - if (typeof value === 'string' && /^-?\d+(\.\d+)?$/u.test(value)) { - return new BigNumber(value).toFixed(); - } - return value; - } - #formatTrustLine(line: Asset | LiquidityPoolAsset): string { if (line instanceof LiquidityPoolAsset) { return `${line.assetA.toString()} / ${line.assetB.toString()} (LP fee ${line.fee})`; @@ -700,7 +922,7 @@ export class OperationMapper { return line.toString(); } - static #formatPredicate(predicate: xdr.ClaimPredicate): string { + #formatPredicate(predicate: xdr.ClaimPredicate): string { try { const type = predicate.switch(); if (type === xdr.ClaimPredicateType.claimPredicateUnconditional()) { @@ -720,7 +942,7 @@ export class OperationMapper { const left = preds[0]; const right = preds[1]; if (left && right) { - return `(${OperationMapper.#formatPredicate(left)} AND ${OperationMapper.#formatPredicate(right)})`; + return `(${this.#formatPredicate(left)} AND ${this.#formatPredicate(right)})`; } } if (type === xdr.ClaimPredicateType.claimPredicateOr()) { @@ -728,14 +950,12 @@ export class OperationMapper { const left = preds[0]; const right = preds[1]; if (left && right) { - return `(${OperationMapper.#formatPredicate(left)} OR ${OperationMapper.#formatPredicate(right)})`; + return `(${this.#formatPredicate(left)} OR ${this.#formatPredicate(right)})`; } } if (type === xdr.ClaimPredicateType.claimPredicateNot()) { const inner = predicate.notPredicate(); - return inner - ? `NOT ${OperationMapper.#formatPredicate(inner)}` - : 'NOT(null)'; + return inner ? `NOT ${this.#formatPredicate(inner)}` : 'NOT(null)'; } } catch { // Fall through diff --git a/packages/snap/src/ui/confirmation/components/Authorizations.tsx b/packages/snap/src/ui/confirmation/components/Authorizations.tsx new file mode 100644 index 00000000..0cf94315 --- /dev/null +++ b/packages/snap/src/ui/confirmation/components/Authorizations.tsx @@ -0,0 +1,63 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Box, + Copyable, + Heading, + Section, + Text as SnapText, + Divider, +} from '@metamask/snaps-sdk/jsx'; + +import { InvocationSummary } from './InvocationSummary'; +import type { ReadableAuthorizationJson } from '../../../services/transaction/OperationMapper'; +import { i18n } from '../../../utils'; +import { getParam } from '../utils'; + +export type AuthorizationsProps = { + locale: string; + authorizations: ReadableAuthorizationJson[]; +}; + +export const Authorizations = ({ + authorizations, + locale, +}: AuthorizationsProps): ComponentOrElement => { + const translate = i18n(locale); + + return ( +
+ {translate('confirmation.authorization.heading')} + {authorizations.map((authJson, index) => { + const authorizedAddress = getParam( + authJson.params, + 'authorizedAddress', + ); + return ( + + {authorizedAddress === null ? null : ( + + + {translate('confirmation.authorization.authorizedAddress')} + + + + )} + + {null} + + {null} + + ); + })} +
+ ); +}; diff --git a/packages/snap/src/ui/confirmation/components/InvocationSummary.tsx b/packages/snap/src/ui/confirmation/components/InvocationSummary.tsx new file mode 100644 index 00000000..41058046 --- /dev/null +++ b/packages/snap/src/ui/confirmation/components/InvocationSummary.tsx @@ -0,0 +1,60 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Box, Copyable, Text as SnapText } from '@metamask/snaps-sdk/jsx'; +import type { Json } from '@metamask/utils'; + +import { JsonParamsSummary } from './JsonParamsSummary'; +import { i18n } from '../../../utils'; + +export type InvocationSummaryProps = { + locale: string; + contractAddress: string | null; + functionName: string | null; + args: Json; +}; + +/** + * Shared Soroban call layout for sign-transaction and sign-auth-entry: + * + * - Contract Address (copyable), when present + * - Function + * - Argument 1…N (copyable) + * + * @param props - Contract / function / args and i18n helper. + * @param props.locale - The locale to use for the translation. + * @param props.contractAddress - Contract `C…` strkey, or `null` for deploy. + * @param props.functionName - Contract function name, if any. + * @param props.args - Decoded argument display strings. + * @returns Vertical confirmation rows for one contract invocation. + */ +export const InvocationSummary = ({ + locale, + contractAddress, + functionName, + args, +}: InvocationSummaryProps): ComponentOrElement => { + const translate = i18n(locale); + return ( + + {contractAddress ? ( + + + {translate('confirmation.invocation.contractAddress')} + + + + ) : null} + + {functionName === null ? null : ( + + + {translate('confirmation.invocation.functionName')} + + {functionName} + + )} + {args === null ? null : ( + + )} + + ); +}; diff --git a/packages/snap/src/ui/confirmation/components/JsonParamsSummary.tsx b/packages/snap/src/ui/confirmation/components/JsonParamsSummary.tsx new file mode 100644 index 00000000..9919e5a8 --- /dev/null +++ b/packages/snap/src/ui/confirmation/components/JsonParamsSummary.tsx @@ -0,0 +1,48 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Box, Copyable, Text as SnapText } from '@metamask/snaps-sdk/jsx'; +import type { Json } from '@metamask/utils'; + +import { i18n } from '../../../utils'; + +/** + * Renders decoded Soroban args as labeled rows (`Arg 1`, `Arg 2`, …). + * All values are {@link Copyable}, including address / contract strkeys. + * + * @param props - Component props. + * @param props.value - JSON field value (typically `string[]`). + * @param props.locale - Locale to use for translation. + * @returns JSX for the confirmation row value. + */ +export const JsonParamsSummary = ({ + value, + locale, +}: { + value: Json; + locale: string; +}): ComponentOrElement => { + const translate = i18n(locale); + if (Array.isArray(value)) { + return ( + + {value.map((item, index) => { + const display = + typeof item === 'string' ? item : JSON.stringify(item); + return ( + + + {translate('confirmation.invocation.argument', { + index: (index + 1).toString(), + })} + + + + ); + })} + + ); + } + if (typeof value === 'object') { + return ; + } + return ; +}; diff --git a/packages/snap/src/ui/confirmation/components/index.ts b/packages/snap/src/ui/confirmation/components/index.ts index 644ed769..f8e5f613 100644 --- a/packages/snap/src/ui/confirmation/components/index.ts +++ b/packages/snap/src/ui/confirmation/components/index.ts @@ -6,3 +6,6 @@ export * from './TransactionAlert'; export * from './TransactionValidationAlert'; export * from './ConfirmationAlerts'; export * from './ConfirmationFooter'; +export * from './Authorizations'; +export * from './InvocationSummary'; +export * from './JsonParamsSummary'; diff --git a/packages/snap/src/ui/confirmation/utils.test.ts b/packages/snap/src/ui/confirmation/utils.test.ts index 050b9791..2c73dd28 100644 --- a/packages/snap/src/ui/confirmation/utils.test.ts +++ b/packages/snap/src/ui/confirmation/utils.test.ts @@ -5,6 +5,7 @@ import { import { FetchStatus } from './api'; import { ConfirmationBanner, + getParam, isFetchInProgress, formatOrigin, isLocalTransactionValidationFailed, @@ -13,6 +14,8 @@ import { resolveConfirmationBanner, shouldDisableConfirmation, } from './utils'; +import { FieldType } from '../../services/transaction'; +import type { ReadableOperationField } from '../../services/transaction'; import { TransactionScanValidationType } from '../../services/transaction-scan'; const warningScan = { @@ -288,4 +291,38 @@ describe('confirmation utils', () => { ).toBe(ConfirmationBanner.None); }); }); + + describe('getParam', () => { + const params: ReadableOperationField[] = [ + { key: 'contractId', value: 'CABC123', type: FieldType.copyable }, + { key: 'functionName', value: 'transfer', type: FieldType.text }, + { + key: 'arguments', + value: ['GABC', '100'], + type: FieldType.json, + }, + { key: 'optional', value: null, type: FieldType.text }, + ]; + + it('returns the value for a matching key', () => { + expect(getParam(params, 'functionName')).toBe('transfer'); + expect(getParam(params, 'contractId')).toBe('CABC123'); + }); + + it('returns json values unchanged', () => { + expect(getParam(params, 'arguments')).toStrictEqual(['GABC', '100']); + }); + + it('returns null when the key is missing', () => { + expect(getParam(params, 'authorizedAddress')).toBeNull(); + }); + + it('returns null when the field value is null', () => { + expect(getParam(params, 'optional')).toBeNull(); + }); + + it('returns null for an empty params list', () => { + expect(getParam([], 'functionName')).toBeNull(); + }); + }); }); diff --git a/packages/snap/src/ui/confirmation/utils.ts b/packages/snap/src/ui/confirmation/utils.ts index cd5b50a8..123cdadf 100644 --- a/packages/snap/src/ui/confirmation/utils.ts +++ b/packages/snap/src/ui/confirmation/utils.ts @@ -1,5 +1,5 @@ import type { GetPreferencesResult } from '@metamask/snaps-sdk'; -import type { CaipAccountId } from '@metamask/utils'; +import type { CaipAccountId, Json } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { FetchStatus } from './api'; @@ -8,6 +8,7 @@ import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; +import type { ReadableOperationField } from '../../services/transaction/OperationMapper'; import { parseOperationAssetReferenceSafe } from '../../services/transaction/utils'; import { TransactionScanValidationType } from '../../services/transaction-scan'; import type { TransactionScanResult } from '../../services/transaction-scan'; @@ -392,3 +393,18 @@ export function resolveAssetDisplay( link: getClassicAssetExplorerUrl(assetReference), }; } + +/** + * Gets a parameter from a list of ReadableOperationField. + * + * @param params - The parameters to search through. + * @param key - The key of the parameter to get. + * @returns The value of the parameter, or `null` if the parameter is not found. + */ +export function getParam( + params: ReadableOperationField[], + key: string, +): Response | null { + const value = params.find((param) => param.key === key)?.value; + return (value ?? null) as Response | null; +} diff --git a/packages/snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx b/packages/snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx index 2370449b..5a8a43c9 100644 --- a/packages/snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx +++ b/packages/snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx @@ -5,8 +5,6 @@ import { Box, Button, Container, - Copyable, - Divider, Footer, Heading, Icon, @@ -16,14 +14,12 @@ import { } from '@metamask/snaps-sdk/jsx'; import { ConfirmSignAuthEntryFormNames } from './events'; -import type { - ReadableAuthEntry, - ReadableInvocation, -} from '../../../../handlers/keyring/signAuthEntry'; +import type { ReadableAuthEntry } from '../../../../handlers/keyring/signAuthEntry'; import type { StellarKeyringAccount } from '../../../../services/account'; import type { Locale } from '../../../../utils'; import { i18n } from '../../../../utils'; import type { ConfirmationBaseProps } from '../../api'; +import { Authorizations } from '../../components/Authorizations'; import { NetworkRow } from '../../components/Network'; import { getAccountName } from '../../utils'; @@ -35,91 +31,6 @@ export type ConfirmSignAuthEntryProps = Pick< account: StellarKeyringAccount; }; -// Vertical, full-width summary of one Soroban authorized invocation. Used both -// for the root call the user is authorizing and recursively (one level deep) -// for any nested calls. Layout follows Freighter: function name as a heading -// at the top, then Contract ID, Function Name, Parameters stacked vertically -// so long values (G/C addresses, i128 amounts) never get squeezed into a -// right-aligned column and wrap badly. -// -// `showNestedCount` controls whether to render a "Nested authorizations: N" -// row inside this card. Disabled for the root and direct sub-invocations -// (whose children we expand into their own card right below) and enabled -// only for deeper nesting where we don't recurse — there the count is the -// only signal the user gets that more calls exist beneath. -const InvocationSummary = ({ - invocation, - translate, - showHeading, - showNestedCount, -}: { - invocation: ReadableInvocation; - translate: ReturnType; - showHeading: boolean; - showNestedCount: boolean; -}): ComponentOrElement => { - const { contractAddress, functionName, args, subInvocations } = invocation; - - return ( - - {showHeading && functionName !== null ? ( - {functionName} - ) : null} - - {contractAddress === null ? ( - - - {translate('confirmation.signAuthEntry.contract')} - - - {translate('confirmation.signAuthEntry.createContract')} - - - ) : ( - - - {translate('confirmation.signAuthEntry.contract')} - - - - )} - - {functionName === null ? null : ( - - - {translate('confirmation.signAuthEntry.function')} - - {functionName} - - )} - - {args.length > 0 ? ( - - {args.map((arg, index) => ( - - - {translate('confirmation.signAuthEntry.argument', { - index: (index + 1).toString(), - })} - - - - ))} - - ) : null} - - {showNestedCount && subInvocations.length > 0 ? ( - - - {translate('confirmation.signAuthEntry.subInvocations')} - - {String(subInvocations.length)} - - ) : null} - - ); -}; - export const ConfirmSignAuthEntry = ({ readableAuthEntry, account, @@ -131,7 +42,8 @@ export const ConfirmSignAuthEntry = ({ const translate = i18n(locale); const { address } = account; const addressCaip10 = getAccountName(scope, address); - const { subInvocations } = readableAuthEntry; + const { authorizations, nonce, signatureExpirationLedger } = + readableAuthEntry; return ( @@ -174,31 +86,22 @@ export const ConfirmSignAuthEntry = ({ scope={scope} locale={locale as Locale} /> + + + {translate('confirmation.signAuthEntry.expiresAt')} + + {String(signatureExpirationLedger)} + + + + {translate('confirmation.signAuthEntry.nonce')} + + {nonce} + -
- -
- - {subInvocations.length > 0 ? ( -
- {subInvocations.map((sub, index) => ( - - {index > 0 ? : null} - - - ))} -
+ {authorizations.length > 0 ? ( + ) : null}